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;
11pub mod document;
12mod json;
13pub mod manifest;
14mod privacy;
15
16pub use directory::{DIRECTORY_ID, DirectoryError, VendorDirectory, VendorEntry};
17pub use document::{
18 AggregatedArtifact, ArtifactOccurrence, DocumentArtifactInput, DocumentError,
19 DocumentExtractor, DocumentReport, DocumentRequest, FindingCounts, document_request_from_json,
20 document_request_from_value,
21};
22pub use manifest::{
23 Assertion, ManifestError, ManifestRulePack, MatchSpec, PackRule, ParamContract, ParamStyle,
24 Requirement, RulePackManifest, ValueFormat,
25};
26
27pub const BUILTIN_VENDOR_DIRECTORY: &str = include_str!("../rulepacks/directory.json");
29
30pub const BUILTIN_VENDOR_MANIFESTS: &[(&str, &str)] = &[
35 (
36 "vendor/adform",
37 include_str!("../rulepacks/vendor/adform.json"),
38 ),
39 (
40 "vendor/adjust",
41 include_str!("../rulepacks/vendor/adjust.json"),
42 ),
43 (
44 "vendor/adobe-analytics",
45 include_str!("../rulepacks/vendor/adobe-analytics.json"),
46 ),
47 (
48 "vendor/adobe-ecid",
49 include_str!("../rulepacks/vendor/adobe-ecid.json"),
50 ),
51 (
52 "vendor/adobe-web-sdk",
53 include_str!("../rulepacks/vendor/adobe-web-sdk.json"),
54 ),
55 (
56 "vendor/amazon-ads",
57 include_str!("../rulepacks/vendor/amazon-ads.json"),
58 ),
59 (
60 "vendor/amazon-vfw",
61 include_str!("../rulepacks/vendor/amazon-vfw.json"),
62 ),
63 (
64 "vendor/amplitude",
65 include_str!("../rulepacks/vendor/amplitude.json"),
66 ),
67 (
68 "vendor/amplitude-group-identify",
69 include_str!("../rulepacks/vendor/amplitude-group-identify.json"),
70 ),
71 (
72 "vendor/amplitude-identify",
73 include_str!("../rulepacks/vendor/amplitude-identify.json"),
74 ),
75 (
76 "vendor/appsflyer",
77 include_str!("../rulepacks/vendor/appsflyer.json"),
78 ),
79 (
80 "vendor/appsflyer-onelink-impression",
81 include_str!("../rulepacks/vendor/appsflyer-onelink-impression.json"),
82 ),
83 ("vendor/awin", include_str!("../rulepacks/vendor/awin.json")),
84 (
85 "vendor/awin-mastertag",
86 include_str!("../rulepacks/vendor/awin-mastertag.json"),
87 ),
88 (
89 "vendor/baidu",
90 include_str!("../rulepacks/vendor/baidu.json"),
91 ),
92 (
93 "vendor/branch",
94 include_str!("../rulepacks/vendor/branch.json"),
95 ),
96 (
97 "vendor/braze",
98 include_str!("../rulepacks/vendor/braze.json"),
99 ),
100 (
101 "vendor/brevo",
102 include_str!("../rulepacks/vendor/brevo.json"),
103 ),
104 (
105 "vendor/brevo-js",
106 include_str!("../rulepacks/vendor/brevo-js.json"),
107 ),
108 (
109 "vendor/chartbeat",
110 include_str!("../rulepacks/vendor/chartbeat.json"),
111 ),
112 ("vendor/cj", include_str!("../rulepacks/vendor/cj.json")),
113 (
114 "vendor/cloudflare",
115 include_str!("../rulepacks/vendor/cloudflare.json"),
116 ),
117 (
118 "vendor/cm360-tracking-ad",
119 include_str!("../rulepacks/vendor/cm360-tracking-ad.json"),
120 ),
121 (
122 "vendor/cm360-vast-event",
123 include_str!("../rulepacks/vendor/cm360-vast-event.json"),
124 ),
125 (
126 "vendor/comscore",
127 include_str!("../rulepacks/vendor/comscore.json"),
128 ),
129 (
130 "vendor/cookiebot",
131 include_str!("../rulepacks/vendor/cookiebot.json"),
132 ),
133 (
134 "vendor/cookiebot-declaration",
135 include_str!("../rulepacks/vendor/cookiebot-declaration.json"),
136 ),
137 (
138 "vendor/crazyegg",
139 include_str!("../rulepacks/vendor/crazyegg.json"),
140 ),
141 (
142 "vendor/criteo",
143 include_str!("../rulepacks/vendor/criteo.json"),
144 ),
145 (
146 "vendor/didomi",
147 include_str!("../rulepacks/vendor/didomi.json"),
148 ),
149 (
150 "vendor/doubleverify",
151 include_str!("../rulepacks/vendor/doubleverify.json"),
152 ),
153 (
154 "vendor/drift",
155 include_str!("../rulepacks/vendor/drift.json"),
156 ),
157 (
158 "vendor/flashtalking",
159 include_str!("../rulepacks/vendor/flashtalking.json"),
160 ),
161 (
162 "vendor/floodlight",
163 include_str!("../rulepacks/vendor/floodlight.json"),
164 ),
165 (
166 "vendor/freewheel",
167 include_str!("../rulepacks/vendor/freewheel.json"),
168 ),
169 (
170 "vendor/google-ad-manager",
171 include_str!("../rulepacks/vendor/google-ad-manager.json"),
172 ),
173 (
174 "vendor/google-ads-conversion",
175 include_str!("../rulepacks/vendor/google-ads-conversion.json"),
176 ),
177 (
178 "vendor/google-ads-call-conversions",
179 include_str!("../rulepacks/vendor/google-ads-call-conversions.json"),
180 ),
181 (
182 "vendor/google-ads-click-conversions",
183 include_str!("../rulepacks/vendor/google-ads-click-conversions.json"),
184 ),
185 (
186 "vendor/google-ads-conversion-adjustments",
187 include_str!("../rulepacks/vendor/google-ads-conversion-adjustments.json"),
188 ),
189 (
190 "vendor/google-analytics",
191 include_str!("../rulepacks/vendor/google-analytics.json"),
192 ),
193 (
194 "vendor/google-analytics-collect",
195 include_str!("../rulepacks/vendor/google-analytics-collect.json"),
196 ),
197 (
198 "vendor/google-tag-manager",
199 include_str!("../rulepacks/vendor/google-tag-manager.json"),
200 ),
201 ("vendor/heap", include_str!("../rulepacks/vendor/heap.json")),
202 (
203 "vendor/heap-classic",
204 include_str!("../rulepacks/vendor/heap-classic.json"),
205 ),
206 (
207 "vendor/heap-identify",
208 include_str!("../rulepacks/vendor/heap-identify.json"),
209 ),
210 (
211 "vendor/heap-track",
212 include_str!("../rulepacks/vendor/heap-track.json"),
213 ),
214 (
215 "vendor/heap-user-properties",
216 include_str!("../rulepacks/vendor/heap-user-properties.json"),
217 ),
218 (
219 "vendor/hotjar",
220 include_str!("../rulepacks/vendor/hotjar.json"),
221 ),
222 (
223 "vendor/hubspot",
224 include_str!("../rulepacks/vendor/hubspot.json"),
225 ),
226 (
227 "vendor/hubspot-pixel",
228 include_str!("../rulepacks/vendor/hubspot-pixel.json"),
229 ),
230 ("vendor/ias", include_str!("../rulepacks/vendor/ias.json")),
231 (
232 "vendor/ias-video",
233 include_str!("../rulepacks/vendor/ias-video.json"),
234 ),
235 ("vendor/id5", include_str!("../rulepacks/vendor/id5.json")),
236 (
237 "vendor/id5-ctv",
238 include_str!("../rulepacks/vendor/id5-ctv.json"),
239 ),
240 (
241 "vendor/impact",
242 include_str!("../rulepacks/vendor/impact.json"),
243 ),
244 (
245 "vendor/impact-conversions",
246 include_str!("../rulepacks/vendor/impact-conversions.json"),
247 ),
248 (
249 "vendor/intercom",
250 include_str!("../rulepacks/vendor/intercom.json"),
251 ),
252 (
253 "vendor/intercom-events",
254 include_str!("../rulepacks/vendor/intercom-events.json"),
255 ),
256 ("vendor/iqm", include_str!("../rulepacks/vendor/iqm.json")),
257 (
258 "vendor/ispot",
259 include_str!("../rulepacks/vendor/ispot.json"),
260 ),
261 (
262 "vendor/ispot-conversion",
263 include_str!("../rulepacks/vendor/ispot-conversion.json"),
264 ),
265 (
266 "vendor/kantar",
267 include_str!("../rulepacks/vendor/kantar.json"),
268 ),
269 (
270 "vendor/kevel",
271 include_str!("../rulepacks/vendor/kevel.json"),
272 ),
273 (
274 "vendor/klaviyo",
275 include_str!("../rulepacks/vendor/klaviyo.json"),
276 ),
277 (
278 "vendor/kochava",
279 include_str!("../rulepacks/vendor/kochava.json"),
280 ),
281 ("vendor/kwai", include_str!("../rulepacks/vendor/kwai.json")),
282 (
283 "vendor/linkedin",
284 include_str!("../rulepacks/vendor/linkedin.json"),
285 ),
286 (
287 "vendor/linkedin-conversions-api",
288 include_str!("../rulepacks/vendor/linkedin-conversions-api.json"),
289 ),
290 (
291 "vendor/liveramp-envelope",
292 include_str!("../rulepacks/vendor/liveramp-envelope.json"),
293 ),
294 (
295 "vendor/liveramp-envelope-refresh",
296 include_str!("../rulepacks/vendor/liveramp-envelope-refresh.json"),
297 ),
298 (
299 "vendor/lotame",
300 include_str!("../rulepacks/vendor/lotame.json"),
301 ),
302 (
303 "vendor/mailchimp",
304 include_str!("../rulepacks/vendor/mailchimp.json"),
305 ),
306 (
307 "vendor/matomo",
308 include_str!("../rulepacks/vendor/matomo.json"),
309 ),
310 (
311 "vendor/mediamath",
312 include_str!("../rulepacks/vendor/mediamath.json"),
313 ),
314 (
315 "vendor/mediamath-mobile",
316 include_str!("../rulepacks/vendor/mediamath-mobile.json"),
317 ),
318 ("vendor/meta", include_str!("../rulepacks/vendor/meta.json")),
319 (
320 "vendor/meta-conversions-api",
321 include_str!("../rulepacks/vendor/meta-conversions-api.json"),
322 ),
323 (
324 "vendor/microsoft-clarity",
325 include_str!("../rulepacks/vendor/microsoft-clarity.json"),
326 ),
327 (
328 "vendor/microsoft-conversions-api",
329 include_str!("../rulepacks/vendor/microsoft-conversions-api.json"),
330 ),
331 (
332 "vendor/microsoft-uet",
333 include_str!("../rulepacks/vendor/microsoft-uet.json"),
334 ),
335 (
336 "vendor/mixpanel",
337 include_str!("../rulepacks/vendor/mixpanel.json"),
338 ),
339 (
340 "vendor/mixpanel-import",
341 include_str!("../rulepacks/vendor/mixpanel-import.json"),
342 ),
343 (
344 "vendor/mixpanel-engage",
345 include_str!("../rulepacks/vendor/mixpanel-engage.json"),
346 ),
347 (
348 "vendor/mixpanel-groups",
349 include_str!("../rulepacks/vendor/mixpanel-groups.json"),
350 ),
351 (
352 "vendor/mouseflow",
353 include_str!("../rulepacks/vendor/mouseflow.json"),
354 ),
355 (
356 "vendor/nextdoor-conversions-api",
357 include_str!("../rulepacks/vendor/nextdoor-conversions-api.json"),
358 ),
359 (
360 "vendor/nielsen",
361 include_str!("../rulepacks/vendor/nielsen.json"),
362 ),
363 (
364 "vendor/nielsen-audit",
365 include_str!("../rulepacks/vendor/nielsen-audit.json"),
366 ),
367 (
368 "vendor/onetrust",
369 include_str!("../rulepacks/vendor/onetrust.json"),
370 ),
371 (
372 "vendor/openai",
373 include_str!("../rulepacks/vendor/openai.json"),
374 ),
375 (
376 "vendor/openai-conversions-api",
377 include_str!("../rulepacks/vendor/openai-conversions-api.json"),
378 ),
379 (
380 "vendor/oracle-bluekai",
381 include_str!("../rulepacks/vendor/oracle-bluekai.json"),
382 ),
383 (
384 "vendor/outbrain",
385 include_str!("../rulepacks/vendor/outbrain.json"),
386 ),
387 (
388 "vendor/pardot",
389 include_str!("../rulepacks/vendor/pardot.json"),
390 ),
391 (
392 "vendor/parsely",
393 include_str!("../rulepacks/vendor/parsely.json"),
394 ),
395 (
396 "vendor/parsely-collect",
397 include_str!("../rulepacks/vendor/parsely-collect.json"),
398 ),
399 (
400 "vendor/partnerize",
401 include_str!("../rulepacks/vendor/partnerize.json"),
402 ),
403 (
404 "vendor/pinterest",
405 include_str!("../rulepacks/vendor/pinterest.json"),
406 ),
407 (
408 "vendor/pinterest-conversions-api",
409 include_str!("../rulepacks/vendor/pinterest-conversions-api.json"),
410 ),
411 (
412 "vendor/plausible",
413 include_str!("../rulepacks/vendor/plausible.json"),
414 ),
415 (
416 "vendor/posthog",
417 include_str!("../rulepacks/vendor/posthog.json"),
418 ),
419 (
420 "vendor/quantcast",
421 include_str!("../rulepacks/vendor/quantcast.json"),
422 ),
423 (
424 "vendor/rakuten",
425 include_str!("../rulepacks/vendor/rakuten.json"),
426 ),
427 (
428 "vendor/reddit",
429 include_str!("../rulepacks/vendor/reddit.json"),
430 ),
431 (
432 "vendor/reddit-conversions-api",
433 include_str!("../rulepacks/vendor/reddit-conversions-api.json"),
434 ),
435 (
436 "vendor/rudderstack",
437 include_str!("../rulepacks/vendor/rudderstack.json"),
438 ),
439 (
440 "vendor/segment",
441 include_str!("../rulepacks/vendor/segment.json"),
442 ),
443 (
444 "vendor/singular",
445 include_str!("../rulepacks/vendor/singular.json"),
446 ),
447 (
448 "vendor/snapchat",
449 include_str!("../rulepacks/vendor/snapchat.json"),
450 ),
451 (
452 "vendor/taboola",
453 include_str!("../rulepacks/vendor/taboola.json"),
454 ),
455 (
456 "vendor/taboola-s2s",
457 include_str!("../rulepacks/vendor/taboola-s2s.json"),
458 ),
459 (
460 "vendor/taboola-s2s-bulk",
461 include_str!("../rulepacks/vendor/taboola-s2s-bulk.json"),
462 ),
463 (
464 "vendor/taboola-unip",
465 include_str!("../rulepacks/vendor/taboola-unip.json"),
466 ),
467 (
468 "vendor/the-trade-desk",
469 include_str!("../rulepacks/vendor/the-trade-desk.json"),
470 ),
471 (
472 "vendor/tiktok",
473 include_str!("../rulepacks/vendor/tiktok.json"),
474 ),
475 (
476 "vendor/tiktok-events-api",
477 include_str!("../rulepacks/vendor/tiktok-events-api.json"),
478 ),
479 (
480 "vendor/tiktok-events-2",
481 include_str!("../rulepacks/vendor/tiktok-events-2.json"),
482 ),
483 (
484 "vendor/trustarc",
485 include_str!("../rulepacks/vendor/trustarc.json"),
486 ),
487 (
488 "vendor/trustarc-notice",
489 include_str!("../rulepacks/vendor/trustarc-notice.json"),
490 ),
491 ("vendor/x", include_str!("../rulepacks/vendor/x.json")),
492 (
493 "vendor/x-conversions-api",
494 include_str!("../rulepacks/vendor/x-conversions-api.json"),
495 ),
496 (
497 "vendor/xandr",
498 include_str!("../rulepacks/vendor/xandr.json"),
499 ),
500 (
501 "vendor/xandr-sspx",
502 include_str!("../rulepacks/vendor/xandr-sspx.json"),
503 ),
504 (
505 "vendor/yahoo-conversions-api",
506 include_str!("../rulepacks/vendor/yahoo-conversions-api.json"),
507 ),
508 (
509 "vendor/yahoo-dot",
510 include_str!("../rulepacks/vendor/yahoo-dot.json"),
511 ),
512 (
513 "vendor/yandex-metrica",
514 include_str!("../rulepacks/vendor/yandex-metrica.json"),
515 ),
516 (
517 "vendor/yandex-watch",
518 include_str!("../rulepacks/vendor/yandex-watch.json"),
519 ),
520 (
521 "vendor/zendesk",
522 include_str!("../rulepacks/vendor/zendesk.json"),
523 ),
524];
525
526#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
527#[serde(rename_all = "snake_case")]
528pub enum ArtifactKind {
529 Url,
530 #[serde(rename = "html")]
531 HtmlSnippet,
532 #[serde(rename = "js")]
533 JavaScriptSnippet,
534 #[serde(rename = "gtm")]
535 GtmTemplate,
536 #[serde(rename = "request")]
537 NetworkRequest,
538 #[serde(rename = "vast")]
539 VastTracker,
540 #[serde(rename = "postback")]
541 ServerPostback,
542 #[serde(rename = "json")]
544 JsonPayload,
545 Unknown,
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
549#[serde(rename_all = "snake_case")]
550pub enum ExpansionState {
551 #[default]
552 Unknown,
553 Template,
554 Fired,
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
558#[serde(rename_all = "snake_case")]
559pub enum RuleSourceLevel {
560 Normative,
561 OfficialVendor,
562 OfficialTemplate,
563 EcosystemReference,
564 Heuristic,
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
568#[serde(rename_all = "snake_case")]
569pub enum Severity {
570 Error,
571 Warning,
572 Info,
573}
574
575#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
576pub struct RuleSource {
577 pub level: RuleSourceLevel,
578 pub name: String,
579 pub reference: Option<String>,
580}
581
582impl RuleSource {
583 pub fn normative(name: impl Into<String>, reference: impl Into<String>) -> Self {
584 Self {
585 level: RuleSourceLevel::Normative,
586 name: name.into(),
587 reference: Some(reference.into()),
588 }
589 }
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
593#[serde(rename_all = "snake_case")]
594pub enum ViolationTargetComponent {
595 WholeUrl,
596 Scheme,
597 Authority,
598 UserInfo,
599 Host,
600 Port,
601 Path,
602 QueryParam,
603 Fragment,
604 BodyField,
606 WholeBody,
608}
609
610#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
611pub struct ViolationTarget {
612 pub component: ViolationTargetComponent,
613 pub name: Option<String>,
614 pub value: Option<String>,
615 pub start: usize,
616 pub end: usize,
617}
618
619#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
620pub struct Violation {
621 pub code: String,
622 pub message: String,
623 pub severity: Severity,
624 pub field: Option<String>,
625 pub fix_hint: Option<String>,
626 pub source: RuleSource,
627 #[serde(default, skip_serializing_if = "Vec::is_empty")]
628 pub targets: Vec<ViolationTarget>,
629}
630
631#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
632pub struct RulePackMetadata {
633 pub id: String,
634 pub display_name: String,
635 pub version: String,
636 pub description: String,
637 pub source_level: RuleSourceLevel,
638 pub vendor: Option<String>,
640}
641
642#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
643pub struct ValidationRequest {
644 pub artifact_kind: ArtifactKind,
645 pub artifact: String,
646 pub claimed_vendor: Option<String>,
647 pub expansion_state: ExpansionState,
648}
649
650#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
651pub struct ValidationOptions {
652 pub only_rulepacks: Vec<String>,
653 pub except_rulepacks: Vec<String>,
654}
655
656#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
657pub struct ValidationReport {
658 pub plugin_id: String,
659 pub detected_vendor: Option<String>,
660 pub violations: Vec<Violation>,
661}
662
663impl ValidationReport {
664 pub fn is_ok(&self) -> bool {
665 self.violations
666 .iter()
667 .all(|violation| violation.severity != Severity::Error)
668 }
669}
670
671#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
672pub struct ValidationSummary {
673 pub reports: Vec<ValidationReport>,
674}
675
676impl ValidationSummary {
677 pub fn is_ok(&self) -> bool {
678 self.reports.iter().all(ValidationReport::is_ok)
679 }
680}
681
682pub trait ValidatorPlugin: Send + Sync {
683 fn metadata(&self) -> &RulePackMetadata;
684 fn supports(&self, request: &ValidationRequest) -> bool;
685 fn validate(&self, request: &ValidationRequest) -> ValidationReport;
686}
687
688#[derive(Debug, Clone, PartialEq, Eq)]
689pub enum EngineError {
690 PluginNotFound(String),
691 NoMatchingPlugin,
692 NoRulepacksSelected,
693}
694
695impl fmt::Display for EngineError {
696 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
697 match self {
698 Self::PluginNotFound(plugin_id) => {
699 write!(f, "rulepack not found: {plugin_id}")
700 }
701 Self::NoMatchingPlugin => write!(f, "no matching rulepack found"),
702 Self::NoRulepacksSelected => write!(f, "no rulepacks remain after applying toggles"),
703 }
704 }
705}
706
707impl Error for EngineError {}
708
709pub struct Engine {
710 plugins: BTreeMap<String, Arc<dyn ValidatorPlugin>>,
711 directory: VendorDirectory,
712}
713
714impl Default for Engine {
715 fn default() -> Self {
719 let mut engine = Self::new();
720 engine.set_directory(VendorDirectory::builtin());
721 engine.register(CoreRulePack::default());
722
723 for (id, json) in BUILTIN_VENDOR_MANIFESTS {
724 engine.register_manifest_json(json).unwrap_or_else(|error| {
725 panic!("built-in rulepack `{id}` failed to compile: {error}")
726 });
727 }
728
729 engine
730 }
731}
732
733impl Engine {
734 pub fn new() -> Self {
735 Self {
736 plugins: BTreeMap::new(),
737 directory: VendorDirectory::default(),
738 }
739 }
740
741 pub fn set_directory(&mut self, directory: VendorDirectory) {
744 self.directory = directory;
745 }
746
747 pub fn directory(&self) -> &VendorDirectory {
749 &self.directory
750 }
751
752 pub fn merge_directory(&mut self, extra: VendorDirectory) -> Result<(), crate::DirectoryError> {
755 self.directory.merge(extra)
756 }
757
758 pub fn register<P>(&mut self, plugin: P)
759 where
760 P: ValidatorPlugin + 'static,
761 {
762 self.plugins
763 .insert(plugin.metadata().id.clone(), Arc::new(plugin));
764 }
765
766 pub fn register_manifest_json(&mut self, json: &str) -> Result<(), ManifestError> {
768 self.register(ManifestRulePack::from_json(json)?);
769 Ok(())
770 }
771
772 pub fn register_manifest_path(&mut self, path: impl AsRef<Path>) -> Result<(), ManifestError> {
774 self.register(ManifestRulePack::from_path(path)?);
775 Ok(())
776 }
777
778 pub fn list_rulepacks(&self) -> Vec<RulePackMetadata> {
779 self.plugins
780 .values()
781 .map(|plugin| plugin.metadata().clone())
782 .collect()
783 }
784
785 pub fn validate(
786 &self,
787 request: &ValidationRequest,
788 options: &ValidationOptions,
789 ) -> Result<ValidationSummary, EngineError> {
790 let plugins = self.select_plugins(request, options)?;
791 let mut reports: Vec<ValidationReport> = plugins
792 .into_iter()
793 .map(|plugin| plugin.validate(request))
794 .collect();
795
796 if let Some(report) = self.directory_report(request, options, &reports) {
797 reports.push(report);
798 }
799
800 Ok(ValidationSummary { reports })
801 }
802
803 fn select_plugins(
804 &self,
805 request: &ValidationRequest,
806 options: &ValidationOptions,
807 ) -> Result<Vec<Arc<dyn ValidatorPlugin>>, EngineError> {
808 self.ensure_known_rulepacks(&options.only_rulepacks)?;
809 self.ensure_known_rulepacks(&options.except_rulepacks)?;
810
811 let excluded: BTreeSet<&str> = options
812 .except_rulepacks
813 .iter()
814 .map(String::as_str)
815 .collect();
816
817 let only_packs: Vec<&String> = options
820 .only_rulepacks
821 .iter()
822 .filter(|rulepack_id| rulepack_id.as_str() != DIRECTORY_ID)
823 .collect();
824
825 if !options.only_rulepacks.is_empty() {
826 if only_packs.is_empty() {
827 return Ok(Vec::new());
828 }
829
830 let selected = only_packs
831 .into_iter()
832 .filter(|rulepack_id| !excluded.contains(rulepack_id.as_str()))
833 .filter_map(|rulepack_id| self.plugins.get(rulepack_id).map(Arc::clone))
834 .collect::<Vec<_>>();
835
836 if selected.is_empty() {
837 return Err(EngineError::NoRulepacksSelected);
838 }
839
840 return Ok(selected);
841 }
842
843 let selected = self
844 .plugins
845 .values()
846 .filter(|plugin| !excluded.contains(plugin.metadata().id.as_str()))
847 .filter(|plugin| plugin.supports(request))
848 .map(Arc::clone)
849 .collect::<Vec<_>>();
850
851 if selected.is_empty() {
852 if excluded.is_empty() {
853 Err(EngineError::NoMatchingPlugin)
854 } else {
855 Err(EngineError::NoRulepacksSelected)
856 }
857 } else {
858 Ok(selected)
859 }
860 }
861
862 fn directory_report(
867 &self,
868 request: &ValidationRequest,
869 options: &ValidationOptions,
870 reports: &[ValidationReport],
871 ) -> Option<ValidationReport> {
872 if options.except_rulepacks.iter().any(|id| id == DIRECTORY_ID) {
873 return None;
874 }
875
876 if !options.only_rulepacks.is_empty()
877 && !options.only_rulepacks.iter().any(|id| id == DIRECTORY_ID)
878 {
879 return None;
880 }
881
882 if reports
883 .iter()
884 .any(|report| report.detected_vendor.is_some())
885 {
886 return None;
887 }
888
889 let artifact = request.artifact.trim();
890 let host = manifest::artifact_host(artifact)?;
891 let entry = self.directory.lookup_host(&host)?;
892
893 let mut message = format!(
894 "This endpoint belongs to {} ({}). No Pixellint rulepack covers it, so only the core checks ran.",
895 entry.display_name, entry.category
896 );
897
898 if let Some(rulepack) = &entry.rulepack {
899 message.push_str(&format!(
900 " The `{rulepack}` rulepack covers other {} endpoints, not this one.",
901 entry.display_name
902 ));
903 }
904
905 let target = artifact
906 .find(&host)
907 .map(|start| ViolationTarget {
908 component: ViolationTargetComponent::Host,
909 name: None,
910 value: Some(host.clone()),
911 start,
912 end: start + host.len(),
913 })
914 .unwrap_or(ViolationTarget {
915 component: ViolationTargetComponent::WholeUrl,
916 name: None,
917 value: None,
918 start: 0,
919 end: artifact.len(),
920 });
921
922 Some(ValidationReport {
923 plugin_id: DIRECTORY_ID.to_string(),
924 detected_vendor: Some(entry.vendor.clone()),
925 violations: vec![Violation {
926 code: "directory.no_rulepack_coverage".to_string(),
927 message,
928 severity: Severity::Info,
929 field: Some("url.host".to_string()),
930 fix_hint: Some(
931 "Write a custom rulepack for this endpoint, or ask for first-party coverage."
932 .to_string(),
933 ),
934 source: RuleSource {
935 level: RuleSourceLevel::EcosystemReference,
936 name: "Pixellint vendor directory".to_string(),
937 reference: None,
938 },
939 targets: vec![target],
940 }],
941 })
942 }
943
944 fn ensure_known_rulepacks(&self, rulepack_ids: &[String]) -> Result<(), EngineError> {
945 for rulepack_id in rulepack_ids {
946 if rulepack_id == DIRECTORY_ID {
947 continue;
948 }
949
950 if !self.plugins.contains_key(rulepack_id) {
951 return Err(EngineError::PluginNotFound(rulepack_id.clone()));
952 }
953 }
954
955 Ok(())
956 }
957}
958
959pub struct CoreRulePack {
960 metadata: RulePackMetadata,
961}
962
963impl Default for CoreRulePack {
964 fn default() -> Self {
965 Self {
966 metadata: RulePackMetadata {
967 id: "core".to_string(),
968 display_name: "Core Cardinal Rules".to_string(),
969 version: env!("CARGO_PKG_VERSION").to_string(),
970 description:
971 "Shared, spec-backed baseline checks for URL-like measurement artifacts."
972 .to_string(),
973 source_level: RuleSourceLevel::Normative,
974 vendor: None,
975 },
976 }
977 }
978}
979
980impl ValidatorPlugin for CoreRulePack {
981 fn metadata(&self) -> &RulePackMetadata {
982 &self.metadata
983 }
984
985 fn supports(&self, _request: &ValidationRequest) -> bool {
986 true
987 }
988
989 fn validate(&self, request: &ValidationRequest) -> ValidationReport {
990 let mut violations = Vec::new();
991 let artifact = request.artifact.trim();
992
993 if artifact.is_empty() {
994 violations.push(Violation {
995 code: "core.input.empty".to_string(),
996 message: "Artifact is empty.".to_string(),
997 severity: Severity::Error,
998 field: None,
999 fix_hint: Some(
1000 "Provide a pixel URL, snippet, template, or request to validate.".to_string(),
1001 ),
1002 source: RuleSource {
1003 level: RuleSourceLevel::Heuristic,
1004 name: "Pixellint input baseline".to_string(),
1005 reference: None,
1006 },
1007 targets: Vec::new(),
1008 });
1009 }
1010
1011 if !artifact.is_empty() {
1012 match request.artifact_kind {
1013 ArtifactKind::Url | ArtifactKind::VastTracker | ArtifactKind::ServerPostback => {
1014 validate_url_like_artifact(artifact, request.expansion_state, &mut violations);
1015 privacy::apply_privacy_rules(artifact, &mut violations);
1016 }
1017 ArtifactKind::JsonPayload => validate_json_artifact(artifact, &mut violations),
1018 ArtifactKind::Unknown if json::JsonDocument::looks_like_json(artifact) => {
1021 validate_json_artifact(artifact, &mut violations);
1022 }
1023 _ => {}
1024 }
1025 }
1026
1027 ValidationReport {
1030 plugin_id: self.metadata.id.clone(),
1031 detected_vendor: None,
1032 violations,
1033 }
1034 }
1035}
1036
1037fn validate_json_artifact(artifact: &str, violations: &mut Vec<Violation>) {
1040 let Err(error) = json::JsonDocument::parse(artifact) else {
1041 return;
1042 };
1043
1044 violations.push(Violation {
1045 code: "core.json.parse_error".to_string(),
1046 message: format!("Request body is not valid JSON: {error}."),
1047 severity: Severity::Error,
1048 field: Some("body".to_string()),
1049 fix_hint: Some(
1050 "Fix the payload so it parses, then validate it again. Serializers that emit trailing commas or unquoted keys are the usual cause."
1051 .to_string(),
1052 ),
1053 source: RuleSource::normative(
1054 "RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format",
1055 "https://www.rfc-editor.org/rfc/rfc8259",
1056 ),
1057 targets: vec![ViolationTarget {
1058 component: ViolationTargetComponent::WholeBody,
1059 name: None,
1060 value: None,
1061 start: error.offset.min(artifact.len()),
1062 end: artifact.len(),
1063 }],
1064 });
1065}
1066
1067fn validate_url_like_artifact(
1068 artifact: &str,
1069 expansion_state: ExpansionState,
1070 violations: &mut Vec<Violation>,
1071) {
1072 let macro_spans = detect_macro_spans(artifact);
1073 let has_unsafe_macro_positions =
1074 apply_macro_rules(artifact, expansion_state, ¯o_spans, violations);
1075
1076 if has_unsafe_macro_positions {
1077 return;
1078 }
1079
1080 let parse_artifact = if macro_spans.is_empty() {
1081 artifact.to_string()
1082 } else {
1083 sanitize_macro_spans(artifact, ¯o_spans)
1084 };
1085
1086 if has_missing_network_host(&parse_artifact) {
1087 violations.push(Violation {
1088 code: "core.url.host_missing".to_string(),
1089 message: "Network-delivered tracking URLs must include a host component.".to_string(),
1090 severity: Severity::Error,
1091 field: Some("url".to_string()),
1092 fix_hint: Some(
1093 "Provide a fully qualified endpoint such as https://example.com/pixel.".to_string(),
1094 ),
1095 source: RuleSource::normative("URL Standard", "https://url.spec.whatwg.org/"),
1096 targets: Vec::new(),
1097 });
1098 return;
1099 }
1100
1101 match Url::parse(&parse_artifact) {
1102 Ok(url) => {
1103 if url.scheme() == "http" {
1104 violations.push(Violation {
1105 code: "core.url.insecure_transport".to_string(),
1106 message:
1107 "Plain http tracking endpoints are discouraged; use https for measurement artifacts."
1108 .to_string(),
1109 severity: Severity::Warning,
1110 field: Some("url".to_string()),
1111 fix_hint: Some(
1112 "Upgrade the endpoint to https so trackers remain compatible with secure playback and delivery environments."
1113 .to_string(),
1114 ),
1115 source: RuleSource {
1116 level: RuleSourceLevel::EcosystemReference,
1117 name: "Secure tracking transport baseline".to_string(),
1118 reference: None,
1119 },
1120 targets: Vec::new(),
1121 });
1122 }
1123
1124 if !matches!(url.scheme(), "http" | "https") {
1125 violations.push(Violation {
1126 code: "core.url.unsupported_scheme".to_string(),
1127 message:
1128 "Only http and https URL artifacts are supported by the core rulepack."
1129 .to_string(),
1130 severity: Severity::Error,
1131 field: Some("url".to_string()),
1132 fix_hint: Some(
1133 "Use an http or https endpoint for network-delivered tracking artifacts."
1134 .to_string(),
1135 ),
1136 source: RuleSource::normative(
1137 "W3C Beacon / URL transport baseline",
1138 "https://www.w3.org/TR/beacon/",
1139 ),
1140 targets: Vec::new(),
1141 });
1142 }
1143
1144 if url.host_str().is_none() {
1145 violations.push(Violation {
1146 code: "core.url.host_missing".to_string(),
1147 message: "Network-delivered tracking URLs must include a host component."
1148 .to_string(),
1149 severity: Severity::Error,
1150 field: Some("url".to_string()),
1151 fix_hint: Some(
1152 "Provide a fully qualified endpoint such as https://example.com/pixel."
1153 .to_string(),
1154 ),
1155 source: RuleSource::normative("URL Standard", "https://url.spec.whatwg.org/"),
1156 targets: Vec::new(),
1157 });
1158 }
1159
1160 if !url.username().is_empty() || url.password().is_some() {
1161 violations.push(Violation {
1162 code: "core.url.userinfo_deprecated".to_string(),
1163 message:
1164 "Credentials embedded in tracking URLs are deprecated and should not be used."
1165 .to_string(),
1166 severity: Severity::Warning,
1167 field: Some("url".to_string()),
1168 fix_hint: Some(
1169 "Move credentials to a safer transport or server-side configuration."
1170 .to_string(),
1171 ),
1172 source: RuleSource::normative(
1173 "RFC 3986 URI generic syntax",
1174 "https://www.rfc-editor.org/rfc/rfc3986",
1175 ),
1176 targets: Vec::new(),
1177 });
1178 }
1179
1180 if url.fragment().is_some() {
1181 violations.push(Violation {
1182 code: "core.url.fragment_ignored".to_string(),
1183 message:
1184 "URL fragments are not transmitted to the server and cannot carry measurement parameters."
1185 .to_string(),
1186 severity: Severity::Warning,
1187 field: Some("url".to_string()),
1188 fix_hint: Some(
1189 "Move tracking data into the query string or request body."
1190 .to_string(),
1191 ),
1192 source: RuleSource::normative(
1193 "RFC 3986 URI generic syntax",
1194 "https://www.rfc-editor.org/rfc/rfc3986",
1195 ),
1196 targets: Vec::new(),
1197 });
1198 }
1199 }
1200 Err(_) => violations.push(Violation {
1201 code: "core.url.invalid".to_string(),
1202 message: "Artifact is not a valid URL.".to_string(),
1203 severity: Severity::Error,
1204 field: Some("url".to_string()),
1205 fix_hint: Some(
1206 "Provide a fully qualified URL such as https://example.com/pixel?x=1".to_string(),
1207 ),
1208 source: RuleSource::normative("URL Standard", "https://url.spec.whatwg.org/"),
1209 targets: Vec::new(),
1210 }),
1211 }
1212}
1213
1214#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1215enum MacroSyntax {
1216 Bracket,
1217 DollarBraces,
1218 DoubleBraces,
1219}
1220
1221impl MacroSyntax {
1222 fn example(self) -> &'static str {
1223 match self {
1224 Self::Bracket => "[NAME]",
1225 Self::DollarBraces => "${NAME}",
1226 Self::DoubleBraces => "{{NAME}}",
1227 }
1228 }
1229}
1230
1231#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1232enum MacroPosition {
1233 Scheme,
1234 Authority,
1235 UserInfo,
1236 Host,
1237 Port,
1238 Path,
1239 Query,
1240 Fragment,
1241 WholeUrl,
1242}
1243
1244impl MacroPosition {
1245 fn field(self) -> &'static str {
1246 match self {
1247 Self::Scheme => "url.scheme",
1248 Self::Authority => "url.authority",
1249 Self::UserInfo => "url.userinfo",
1250 Self::Host => "url.host",
1251 Self::Port => "url.port",
1252 Self::Path => "url.path",
1253 Self::Query => "url.query",
1254 Self::Fragment => "url.fragment",
1255 Self::WholeUrl => "url",
1256 }
1257 }
1258
1259 fn label(self) -> &'static str {
1260 match self {
1261 Self::Scheme => "scheme",
1262 Self::Authority => "authority",
1263 Self::UserInfo => "userinfo",
1264 Self::Host => "host",
1265 Self::Port => "port",
1266 Self::Path => "path",
1267 Self::Query => "query",
1268 Self::Fragment => "fragment",
1269 Self::WholeUrl => "url",
1270 }
1271 }
1272
1273 fn target_component(self) -> ViolationTargetComponent {
1274 match self {
1275 Self::Scheme => ViolationTargetComponent::Scheme,
1276 Self::Authority => ViolationTargetComponent::Authority,
1277 Self::UserInfo => ViolationTargetComponent::UserInfo,
1278 Self::Host => ViolationTargetComponent::Host,
1279 Self::Port => ViolationTargetComponent::Port,
1280 Self::Path => ViolationTargetComponent::Path,
1281 Self::Query => ViolationTargetComponent::QueryParam,
1282 Self::Fragment => ViolationTargetComponent::Fragment,
1283 Self::WholeUrl => ViolationTargetComponent::WholeUrl,
1284 }
1285 }
1286
1287 fn is_unsafe(self) -> bool {
1288 matches!(
1289 self,
1290 Self::Scheme | Self::Authority | Self::UserInfo | Self::Host | Self::Port
1291 )
1292 }
1293}
1294
1295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1296pub(crate) struct MacroSpan {
1297 start: usize,
1298 end: usize,
1299 syntax: MacroSyntax,
1300}
1301
1302fn apply_macro_rules(
1303 artifact: &str,
1304 expansion_state: ExpansionState,
1305 macro_spans: &[MacroSpan],
1306 violations: &mut Vec<Violation>,
1307) -> bool {
1308 if macro_spans.is_empty() {
1309 return false;
1310 }
1311
1312 let syntaxes = macro_spans
1313 .iter()
1314 .map(|span| span.syntax)
1315 .collect::<BTreeSet<_>>();
1316
1317 if syntaxes.len() > 1 {
1318 let targets = macro_spans
1319 .iter()
1320 .map(|span| target_for_macro_span(artifact, span))
1321 .collect();
1322 let syntax_list = syntaxes
1323 .iter()
1324 .map(|syntax| syntax.example())
1325 .collect::<Vec<_>>()
1326 .join(", ");
1327 violations.push(Violation {
1328 code: "core.macro.mixed_syntax".to_string(),
1329 message: format!(
1330 "Multiple macro syntaxes were detected in the same artifact: {syntax_list}."
1331 ),
1332 severity: Severity::Warning,
1333 field: Some("url".to_string()),
1334 fix_hint: Some(
1335 "Standardize on one macro syntax per artifact so trafficking and expansion behavior stays predictable."
1336 .to_string(),
1337 ),
1338 source: macro_rule_source(),
1339 targets,
1340 });
1341 }
1342
1343 if expansion_state == ExpansionState::Fired {
1344 let targets = macro_spans
1345 .iter()
1346 .map(|span| target_for_macro_span(artifact, span))
1347 .collect();
1348 violations.push(Violation {
1349 code: "core.macro.unexpanded_in_fired_url".to_string(),
1350 message: "Observed fired URLs should not contain unresolved macro tokens."
1351 .to_string(),
1352 severity: Severity::Error,
1353 field: Some("url".to_string()),
1354 fix_hint: Some(
1355 "Expand macros before the request is fired, or validate the artifact in template mode instead."
1356 .to_string(),
1357 ),
1358 source: macro_rule_source(),
1359 targets,
1360 });
1361 }
1362
1363 let unsafe_spans = macro_spans
1364 .iter()
1365 .map(|span| (*span, classify_macro_position(artifact, span)))
1366 .filter(|(_, position)| position.is_unsafe())
1367 .collect::<Vec<_>>();
1368
1369 let unsafe_positions = unsafe_spans
1370 .iter()
1371 .map(|(_, position)| *position)
1372 .collect::<BTreeSet<_>>();
1373
1374 if unsafe_positions.is_empty() {
1375 return false;
1376 }
1377
1378 let position_list = unsafe_positions
1379 .iter()
1380 .map(|position| position.label())
1381 .collect::<Vec<_>>()
1382 .join(", ");
1383 let first_position = *unsafe_positions
1384 .iter()
1385 .next()
1386 .unwrap_or(&MacroPosition::WholeUrl);
1387 let targets = unsafe_spans
1388 .iter()
1389 .map(|(span, _)| target_for_macro_span(artifact, span))
1390 .collect();
1391
1392 violations.push(Violation {
1393 code: "core.macro.unsafe_position".to_string(),
1394 message: format!(
1395 "Macros in URL {position_list} components are unsafe because they can prevent reliable endpoint resolution."
1396 ),
1397 severity: Severity::Error,
1398 field: Some(first_position.field().to_string()),
1399 fix_hint: Some(
1400 "Keep macros in path or query values, or expand scheme and authority components before validation."
1401 .to_string(),
1402 ),
1403 source: macro_rule_source(),
1404 targets,
1405 });
1406
1407 true
1408}
1409
1410fn macro_rule_source() -> RuleSource {
1411 RuleSource {
1412 level: RuleSourceLevel::EcosystemReference,
1413 name: "Ad-tech macro handling baseline".to_string(),
1414 reference: None,
1415 }
1416}
1417
1418pub(crate) fn detect_macro_spans(artifact: &str) -> Vec<MacroSpan> {
1419 let mut spans = Vec::new();
1420 let mut index = 0;
1421
1422 while index < artifact.len() {
1423 let remainder = &artifact[index..];
1424
1425 if let Some(span) = match_macro_span(artifact, index, remainder) {
1426 index = span.end;
1427 spans.push(span);
1428 continue;
1429 }
1430
1431 index += remainder.chars().next().map(|c| c.len_utf8()).unwrap_or(1);
1434 }
1435
1436 spans
1437}
1438
1439const MACRO_DELIMITERS: [(&str, &str, MacroSyntax); 3] = [
1441 ("${", "}", MacroSyntax::DollarBraces),
1442 ("{{", "}}", MacroSyntax::DoubleBraces),
1443 ("[", "]", MacroSyntax::Bracket),
1444];
1445
1446fn match_macro_span(artifact: &str, index: usize, remainder: &str) -> Option<MacroSpan> {
1447 for (open, close, syntax) in MACRO_DELIMITERS {
1448 let Some(after_open) = remainder.strip_prefix(open) else {
1449 continue;
1450 };
1451 let Some(close_offset) = after_open.find(close) else {
1452 continue;
1453 };
1454
1455 let body_start = index + open.len();
1456 let body_end = body_start + close_offset;
1457
1458 if is_macro_body(&artifact[body_start..body_end]) {
1459 return Some(MacroSpan {
1460 start: index,
1461 end: body_end + close.len(),
1462 syntax,
1463 });
1464 }
1465 }
1466
1467 None
1468}
1469
1470fn is_macro_body(body: &str) -> bool {
1471 if body.is_empty() || body.trim() != body {
1472 return false;
1473 }
1474
1475 let mut has_identifier_character = false;
1476
1477 for character in body.chars() {
1478 match character {
1479 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '.' | '-' => {
1480 if character.is_ascii_alphabetic() || character == '_' {
1481 has_identifier_character = true;
1482 }
1483 }
1484 _ => return false,
1485 }
1486 }
1487
1488 has_identifier_character
1489}
1490
1491pub(crate) fn sanitize_macro_spans(artifact: &str, macro_spans: &[MacroSpan]) -> String {
1492 let mut sanitized = String::with_capacity(artifact.len());
1493 let mut cursor = 0;
1494
1495 for span in macro_spans {
1496 sanitized.push_str(&artifact[cursor..span.start]);
1497 sanitized.push_str("macro");
1498 cursor = span.end;
1499 }
1500
1501 sanitized.push_str(&artifact[cursor..]);
1502 sanitized
1503}
1504
1505fn target_for_macro_span(artifact: &str, span: &MacroSpan) -> ViolationTarget {
1506 let position = classify_macro_position(artifact, span);
1507 let value = &artifact[span.start..span.end];
1508
1509 if position == MacroPosition::Query {
1510 let (name, param_value) = query_param_context(artifact, span);
1511 return ViolationTarget {
1512 component: ViolationTargetComponent::QueryParam,
1513 name,
1514 value: param_value,
1515 start: span.start,
1516 end: span.end,
1517 };
1518 }
1519
1520 ViolationTarget {
1521 component: position.target_component(),
1522 name: None,
1523 value: Some(value.to_string()),
1524 start: span.start,
1525 end: span.end,
1526 }
1527}
1528
1529fn query_param_context(artifact: &str, span: &MacroSpan) -> (Option<String>, Option<String>) {
1530 let length = artifact.len();
1531 let fragment_start = artifact.find('#').unwrap_or(length);
1532 let Some(query_start) = artifact[..fragment_start].find('?') else {
1533 return (None, Some(artifact[span.start..span.end].to_string()));
1534 };
1535
1536 let query_value_start = query_start + 1;
1537 if !overlaps(span, query_value_start, fragment_start) {
1538 return (None, Some(artifact[span.start..span.end].to_string()));
1539 }
1540
1541 let segment_start = artifact[..span.start]
1542 .rfind(['?', '&'])
1543 .map(|index| index + 1)
1544 .unwrap_or(query_value_start);
1545 let segment_end = artifact[span.end..fragment_start]
1546 .find('&')
1547 .map(|offset| span.end + offset)
1548 .unwrap_or(fragment_start);
1549 let segment = &artifact[segment_start..segment_end];
1550
1551 if let Some(separator) = segment.find('=') {
1552 let name = &segment[..separator];
1553 let value = &segment[separator + 1..];
1554 (
1555 (!name.is_empty()).then(|| name.to_string()),
1556 Some(value.to_string()),
1557 )
1558 } else {
1559 (
1560 (!segment.is_empty()).then(|| segment.to_string()),
1561 Some(segment.to_string()),
1562 )
1563 }
1564}
1565
1566fn classify_macro_position(artifact: &str, span: &MacroSpan) -> MacroPosition {
1567 let length = artifact.len();
1568 let Some(scheme_end) = artifact.find("://") else {
1569 return MacroPosition::WholeUrl;
1570 };
1571
1572 if overlaps(span, 0, scheme_end) {
1573 return MacroPosition::Scheme;
1574 }
1575
1576 let authority_start = scheme_end + 3;
1577 let authority_end = artifact[authority_start..]
1578 .find(['/', '?', '#'])
1579 .map(|offset| authority_start + offset)
1580 .unwrap_or(length);
1581
1582 let fragment_start = artifact.find('#');
1583 let query_search_end = fragment_start.unwrap_or(length);
1584 let query_start = artifact[..query_search_end].find('?');
1585 let path_end = query_start.or(fragment_start).unwrap_or(length);
1586
1587 if overlaps(span, authority_start, authority_end) {
1588 let authority = &artifact[authority_start..authority_end];
1589 let (userinfo_end, host_start) = if let Some(at_offset) = authority.find('@') {
1590 let userinfo_end = authority_start + at_offset;
1591 if overlaps(span, authority_start, userinfo_end) {
1592 return MacroPosition::UserInfo;
1593 }
1594 (Some(userinfo_end), userinfo_end + 1)
1595 } else {
1596 (None, authority_start)
1597 };
1598
1599 if host_start < authority_end {
1600 let host_port = &artifact[host_start..authority_end];
1601 if host_port.starts_with('[') {
1602 if let Some(close_offset) = host_port.find(']') {
1603 let host_end = host_start + close_offset + 1;
1604 if overlaps(span, host_start, host_end) {
1605 return MacroPosition::Host;
1606 }
1607
1608 if host_end < authority_end
1609 && artifact.as_bytes().get(host_end) == Some(&b':')
1610 && overlaps(span, host_end + 1, authority_end)
1611 {
1612 return MacroPosition::Port;
1613 }
1614 }
1615 } else if let Some(port_separator) = host_port.rfind(':') {
1616 let port_start = host_start + port_separator + 1;
1617 let host_end = host_start + port_separator;
1618
1619 if overlaps(span, host_start, host_end) {
1620 return MacroPosition::Host;
1621 }
1622
1623 if overlaps(span, port_start, authority_end) {
1624 return MacroPosition::Port;
1625 }
1626 } else if overlaps(span, host_start, authority_end) {
1627 return MacroPosition::Host;
1628 }
1629 }
1630
1631 if userinfo_end.is_some() || overlaps(span, authority_start, authority_end) {
1632 return MacroPosition::Authority;
1633 }
1634 }
1635
1636 if authority_end < path_end
1637 && artifact.as_bytes().get(authority_end) == Some(&b'/')
1638 && overlaps(span, authority_end, path_end)
1639 {
1640 return MacroPosition::Path;
1641 }
1642
1643 if let Some(query_start) = query_start {
1644 let query_value_start = query_start + 1;
1645 let query_end = fragment_start.unwrap_or(length);
1646 if overlaps(span, query_value_start, query_end) {
1647 return MacroPosition::Query;
1648 }
1649 }
1650
1651 if let Some(fragment_start) = fragment_start {
1652 let fragment_value_start = fragment_start + 1;
1653 if overlaps(span, fragment_value_start, length) {
1654 return MacroPosition::Fragment;
1655 }
1656 }
1657
1658 MacroPosition::WholeUrl
1659}
1660
1661fn overlaps(span: &MacroSpan, start: usize, end: usize) -> bool {
1662 start < end && span.start < end && span.end > start
1663}
1664
1665fn has_missing_network_host(artifact: &str) -> bool {
1666 let lowered = artifact.to_ascii_lowercase();
1667 let Some(remainder) = lowered
1668 .strip_prefix("http://")
1669 .or_else(|| lowered.strip_prefix("https://"))
1670 else {
1671 return false;
1672 };
1673
1674 matches!(
1675 remainder.chars().next(),
1676 None | Some('/') | Some('?') | Some('#') | Some(':') | Some('@')
1677 )
1678}
1679
1680#[cfg(test)]
1681mod tests {
1682 use super::*;
1683
1684 struct FixtureRulePack {
1685 metadata: RulePackMetadata,
1686 }
1687
1688 impl Default for FixtureRulePack {
1689 fn default() -> Self {
1690 Self {
1691 metadata: RulePackMetadata {
1692 id: "fixture".to_string(),
1693 display_name: "Fixture RulePack".to_string(),
1694 version: "0.0.0".to_string(),
1695 description: "Test helper rulepack".to_string(),
1696 source_level: RuleSourceLevel::Heuristic,
1697 vendor: None,
1698 },
1699 }
1700 }
1701 }
1702
1703 impl ValidatorPlugin for FixtureRulePack {
1704 fn metadata(&self) -> &RulePackMetadata {
1705 &self.metadata
1706 }
1707
1708 fn supports(&self, _request: &ValidationRequest) -> bool {
1709 true
1710 }
1711
1712 fn validate(&self, request: &ValidationRequest) -> ValidationReport {
1713 ValidationReport {
1714 plugin_id: self.metadata.id.clone(),
1715 detected_vendor: request.claimed_vendor.clone(),
1716 violations: vec![Violation {
1717 code: "fixture.info".to_string(),
1718 message: "fixture ran".to_string(),
1719 severity: Severity::Info,
1720 field: None,
1721 fix_hint: None,
1722 source: RuleSource {
1723 level: RuleSourceLevel::Heuristic,
1724 name: "Fixture".to_string(),
1725 reference: None,
1726 },
1727 targets: Vec::new(),
1728 }],
1729 }
1730 }
1731 }
1732
1733 fn sample_request(artifact: &str) -> ValidationRequest {
1734 sample_request_with_kind(ArtifactKind::Url, artifact)
1735 }
1736
1737 fn sample_request_with_kind(artifact_kind: ArtifactKind, artifact: &str) -> ValidationRequest {
1738 sample_request_with_kind_and_state(artifact_kind, ExpansionState::Unknown, artifact)
1739 }
1740
1741 fn sample_request_with_kind_and_state(
1742 artifact_kind: ArtifactKind,
1743 expansion_state: ExpansionState,
1744 artifact: &str,
1745 ) -> ValidationRequest {
1746 ValidationRequest {
1747 artifact_kind,
1748 artifact: artifact.to_string(),
1749 claimed_vendor: None,
1750 expansion_state,
1751 }
1752 }
1753
1754 fn violation_codes(summary: &ValidationSummary) -> Vec<String> {
1755 summary
1756 .reports
1757 .iter()
1758 .flat_map(|report| report.violations.iter())
1759 .map(|violation| violation.code.clone())
1760 .collect()
1761 }
1762
1763 #[test]
1764 fn core_rulepack_runs_in_auto_mode() {
1765 let engine = Engine::default();
1766 let summary = engine
1767 .validate(
1768 &sample_request("https://example.com/pixel?id=1#ignored"),
1769 &ValidationOptions::default(),
1770 )
1771 .unwrap();
1772
1773 assert_eq!(summary.reports.len(), 1);
1774 assert_eq!(summary.reports[0].plugin_id, "core");
1775 assert_eq!(
1776 summary.reports[0].violations[0].code,
1777 "core.url.fragment_ignored"
1778 );
1779 }
1780
1781 #[test]
1782 fn explicit_rulepack_selection_is_toggleable() {
1783 let mut engine = Engine::default();
1784 engine.register(FixtureRulePack::default());
1785
1786 let summary = engine
1787 .validate(
1788 &sample_request("https://example.com/pixel?id=1"),
1789 &ValidationOptions {
1790 only_rulepacks: vec!["core".to_string(), "fixture".to_string()],
1791 except_rulepacks: Vec::new(),
1792 },
1793 )
1794 .unwrap();
1795
1796 assert_eq!(summary.reports.len(), 2);
1797 assert_eq!(summary.reports[0].plugin_id, "core");
1798 assert_eq!(summary.reports[1].plugin_id, "fixture");
1799 }
1800
1801 #[test]
1802 fn excluding_all_selected_rulepacks_returns_an_error() {
1803 let engine = Engine::default();
1804 let error = engine
1805 .validate(
1806 &sample_request("https://example.com/pixel?id=1"),
1807 &ValidationOptions {
1808 only_rulepacks: vec!["core".to_string()],
1809 except_rulepacks: vec!["core".to_string()],
1810 },
1811 )
1812 .unwrap_err();
1813
1814 assert_eq!(error, EngineError::NoRulepacksSelected);
1815 }
1816
1817 #[test]
1818 fn core_rulepack_warns_on_deprecated_userinfo() {
1819 let engine = Engine::default();
1820 let summary = engine
1821 .validate(
1822 &sample_request("https://user:pass@example.com/pixel?id=1"),
1823 &ValidationOptions::default(),
1824 )
1825 .unwrap();
1826
1827 assert_eq!(
1828 summary.reports[0].violations[0].code,
1829 "core.url.userinfo_deprecated"
1830 );
1831 }
1832
1833 #[test]
1834 fn core_rulepack_reports_empty_input_without_cascading_url_noise() {
1835 let engine = Engine::default();
1836 let summary = engine
1837 .validate(&sample_request(" \n\t"), &ValidationOptions::default())
1838 .unwrap();
1839
1840 assert_eq!(violation_codes(&summary), vec!["core.input.empty"]);
1841 }
1842
1843 #[test]
1844 fn core_rulepack_errors_on_invalid_url() {
1845 let engine = Engine::default();
1846 let summary = engine
1847 .validate(&sample_request("not a url"), &ValidationOptions::default())
1848 .unwrap();
1849
1850 assert_eq!(violation_codes(&summary), vec!["core.url.invalid"]);
1851 }
1852
1853 #[test]
1854 fn core_rulepack_errors_on_unsupported_scheme() {
1855 let engine = Engine::default();
1856 let summary = engine
1857 .validate(
1858 &sample_request("ftp://example.com/pixel?id=1"),
1859 &ValidationOptions::default(),
1860 )
1861 .unwrap();
1862
1863 assert_eq!(
1864 violation_codes(&summary),
1865 vec!["core.url.unsupported_scheme"]
1866 );
1867 }
1868
1869 #[test]
1870 fn core_rulepack_warns_on_insecure_http_transport() {
1871 let engine = Engine::default();
1872 let summary = engine
1873 .validate(
1874 &sample_request("http://example.com/pixel?id=1"),
1875 &ValidationOptions::default(),
1876 )
1877 .unwrap();
1878
1879 assert_eq!(
1880 violation_codes(&summary),
1881 vec!["core.url.insecure_transport"]
1882 );
1883 }
1884
1885 #[test]
1886 fn core_rulepack_errors_on_missing_host() {
1887 let engine = Engine::default();
1888 let summary = engine
1889 .validate(
1890 &sample_request("https:///pixel?id=1"),
1891 &ValidationOptions::default(),
1892 )
1893 .unwrap();
1894
1895 assert_eq!(violation_codes(&summary), vec!["core.url.host_missing"]);
1896 }
1897
1898 #[test]
1899 fn core_rulepack_accepts_clean_https_pixel() {
1900 let engine = Engine::default();
1901 let summary = engine
1902 .validate(
1903 &sample_request("https://example.com/pixel?id=1"),
1904 &ValidationOptions::default(),
1905 )
1906 .unwrap();
1907
1908 assert!(summary.is_ok());
1909 assert!(summary.reports[0].violations.is_empty());
1910 }
1911
1912 #[test]
1913 fn core_rulepack_accepts_trimmed_https_pixel() {
1914 let engine = Engine::default();
1915 let summary = engine
1916 .validate(
1917 &sample_request(" \n https://example.com/pixel?id=1 \t "),
1918 &ValidationOptions::default(),
1919 )
1920 .unwrap();
1921
1922 assert!(summary.is_ok());
1923 assert!(summary.reports[0].violations.is_empty());
1924 }
1925
1926 #[test]
1927 fn core_rulepack_accepts_localhost_with_port() {
1928 let engine = Engine::default();
1929 let summary = engine
1930 .validate(
1931 &sample_request("https://localhost:8443/pixel?id=1&source=qa"),
1932 &ValidationOptions::default(),
1933 )
1934 .unwrap();
1935
1936 assert!(summary.is_ok());
1937 assert!(summary.reports[0].violations.is_empty());
1938 }
1939
1940 #[test]
1941 fn core_rulepack_accepts_ipv6_hosts() {
1942 let engine = Engine::default();
1943 let summary = engine
1944 .validate(
1945 &sample_request("https://[2001:db8::1]/pixel?id=1"),
1946 &ValidationOptions::default(),
1947 )
1948 .unwrap();
1949
1950 assert!(summary.is_ok());
1951 assert!(summary.reports[0].violations.is_empty());
1952 }
1953
1954 #[test]
1955 fn core_rulepack_combines_insecure_transport_userinfo_and_fragment_warnings() {
1956 let engine = Engine::default();
1957 let summary = engine
1958 .validate(
1959 &sample_request("http://user:pass@example.com/pixel?id=1#frag"),
1960 &ValidationOptions::default(),
1961 )
1962 .unwrap();
1963
1964 assert_eq!(
1965 violation_codes(&summary),
1966 vec![
1967 "core.url.insecure_transport",
1968 "core.url.userinfo_deprecated",
1969 "core.url.fragment_ignored"
1970 ]
1971 );
1972 }
1973
1974 #[test]
1975 fn core_rulepack_validates_vast_tracker_urls() {
1976 let engine = Engine::default();
1977 let summary = engine
1978 .validate(
1979 &sample_request_with_kind(
1980 ArtifactKind::VastTracker,
1981 "https://example.com/vast/track?event=start#ignored",
1982 ),
1983 &ValidationOptions::default(),
1984 )
1985 .unwrap();
1986
1987 assert_eq!(violation_codes(&summary), vec!["core.url.fragment_ignored"]);
1988 }
1989
1990 #[test]
1991 fn core_rulepack_warns_on_insecure_vast_tracker_transport() {
1992 let engine = Engine::default();
1993 let summary = engine
1994 .validate(
1995 &sample_request_with_kind(
1996 ArtifactKind::VastTracker,
1997 "http://tracker.example.com/vast/track?event=start#ignored",
1998 ),
1999 &ValidationOptions::default(),
2000 )
2001 .unwrap();
2002
2003 assert_eq!(
2004 violation_codes(&summary),
2005 vec!["core.url.insecure_transport", "core.url.fragment_ignored"]
2006 );
2007 }
2008
2009 #[test]
2010 fn core_rulepack_validates_server_postback_urls() {
2011 let engine = Engine::default();
2012 let summary = engine
2013 .validate(
2014 &sample_request_with_kind(
2015 ArtifactKind::ServerPostback,
2016 "ftp://example.com/postback?tx=abc123",
2017 ),
2018 &ValidationOptions::default(),
2019 )
2020 .unwrap();
2021
2022 assert_eq!(
2023 violation_codes(&summary),
2024 vec!["core.url.unsupported_scheme"]
2025 );
2026 }
2027
2028 #[test]
2029 fn core_rulepack_warns_on_insecure_server_postback_transport() {
2030 let engine = Engine::default();
2031 let summary = engine
2032 .validate(
2033 &sample_request_with_kind(
2034 ArtifactKind::ServerPostback,
2035 "http://collector.example.com/postback?tx=abc123",
2036 ),
2037 &ValidationOptions::default(),
2038 )
2039 .unwrap();
2040
2041 assert_eq!(
2042 violation_codes(&summary),
2043 vec!["core.url.insecure_transport"]
2044 );
2045 }
2046
2047 #[test]
2048 fn core_rulepack_accepts_query_macros_in_unknown_state() {
2049 let engine = Engine::default();
2050 let summary = engine
2051 .validate(
2052 &sample_request("https://example.com/pixel?cb=[CACHEBUSTING]&id=1"),
2053 &ValidationOptions::default(),
2054 )
2055 .unwrap();
2056
2057 assert!(summary.is_ok());
2058 assert!(summary.reports[0].violations.is_empty());
2059 }
2060
2061 #[test]
2062 fn core_rulepack_errors_on_unexpanded_macro_in_fired_url() {
2063 let engine = Engine::default();
2064 let summary = engine
2065 .validate(
2066 &sample_request_with_kind_and_state(
2067 ArtifactKind::Url,
2068 ExpansionState::Fired,
2069 "https://example.com/pixel?cb=[CACHEBUSTING]&id=1",
2070 ),
2071 &ValidationOptions::default(),
2072 )
2073 .unwrap();
2074
2075 assert_eq!(
2076 violation_codes(&summary),
2077 vec!["core.macro.unexpanded_in_fired_url"]
2078 );
2079
2080 let targets = &summary.reports[0].violations[0].targets;
2081 assert_eq!(targets.len(), 1);
2082 assert_eq!(targets[0].component, ViolationTargetComponent::QueryParam);
2083 assert_eq!(targets[0].name.as_deref(), Some("cb"));
2084 assert_eq!(targets[0].value.as_deref(), Some("[CACHEBUSTING]"));
2085 }
2086
2087 #[test]
2088 fn core_rulepack_warns_on_mixed_macro_syntax() {
2089 let engine = Engine::default();
2090 let summary = engine
2091 .validate(
2092 &sample_request_with_kind_and_state(
2093 ArtifactKind::Url,
2094 ExpansionState::Template,
2095 "https://example.com/pixel?cb=[CACHEBUSTING]&price=${AUCTION_PRICE}",
2096 ),
2097 &ValidationOptions::default(),
2098 )
2099 .unwrap();
2100
2101 assert_eq!(violation_codes(&summary), vec!["core.macro.mixed_syntax"]);
2102
2103 let targets = &summary.reports[0].violations[0].targets;
2104 assert_eq!(targets.len(), 2);
2105 assert_eq!(targets[0].name.as_deref(), Some("cb"));
2106 assert_eq!(targets[1].name.as_deref(), Some("price"));
2107 }
2108
2109 #[test]
2110 fn core_rulepack_errors_on_macro_in_host_position() {
2111 let engine = Engine::default();
2112 let summary = engine
2113 .validate(
2114 &sample_request_with_kind_and_state(
2115 ArtifactKind::Url,
2116 ExpansionState::Template,
2117 "https://${HOST}/pixel?id=1",
2118 ),
2119 &ValidationOptions::default(),
2120 )
2121 .unwrap();
2122
2123 assert_eq!(
2124 violation_codes(&summary),
2125 vec!["core.macro.unsafe_position"]
2126 );
2127 assert_eq!(
2128 summary.reports[0].violations[0].field.as_deref(),
2129 Some("url.host")
2130 );
2131 assert_eq!(summary.reports[0].violations[0].targets.len(), 1);
2132 assert_eq!(
2133 summary.reports[0].violations[0].targets[0].component,
2134 ViolationTargetComponent::Host
2135 );
2136 assert_eq!(
2137 summary.reports[0].violations[0].targets[0].value.as_deref(),
2138 Some("${HOST}")
2139 );
2140 }
2141
2142 #[test]
2143 fn detect_macro_spans_walks_utf8_char_boundaries() {
2144 let artifact = "https://example.com/event.png?cb=abc\u{FFFD}def&id=1";
2145 let spans = detect_macro_spans(artifact);
2146 assert!(spans.is_empty());
2147
2148 let broken = "https://example.com/ias.gif?campId={\u{FFFD}mpaign_cfid}&x=1";
2151 assert!(detect_macro_spans(broken).is_empty());
2152
2153 let with_macro = "https://example.com/event.png?cb=abc\u{FFFD}def&price=${AUCTION_PRICE}";
2154 let spans = detect_macro_spans(with_macro);
2155 assert_eq!(spans.len(), 1);
2156 assert_eq!(
2157 &with_macro[spans[0].start..spans[0].end],
2158 "${AUCTION_PRICE}"
2159 );
2160 }
2161
2162 fn directory_request(artifact: &str) -> ValidationRequest {
2163 ValidationRequest {
2164 artifact_kind: ArtifactKind::Url,
2165 artifact: artifact.to_string(),
2166 claimed_vendor: None,
2167 expansion_state: ExpansionState::Unknown,
2168 }
2169 }
2170
2171 #[test]
2172 fn the_directory_attributes_endpoints_no_rulepack_claims() {
2173 let summary = Engine::default()
2174 .validate(
2175 &directory_request("https://trc.taboola.com/actions?a=1"),
2176 &ValidationOptions::default(),
2177 )
2178 .unwrap();
2179
2180 let report = summary
2181 .reports
2182 .iter()
2183 .find(|report| report.plugin_id == DIRECTORY_ID)
2184 .expect("directory report");
2185 assert_eq!(report.detected_vendor.as_deref(), Some("taboola"));
2186 assert_eq!(report.violations[0].code, "directory.no_rulepack_coverage");
2187 assert_eq!(report.violations[0].severity, Severity::Info);
2188 assert!(report.is_ok(), "attribution must never fail an artifact");
2189 }
2190
2191 #[test]
2192 fn a_matching_rulepack_suppresses_the_directory() {
2193 let summary = Engine::default()
2194 .validate(
2195 &directory_request("https://www.facebook.com/tr?id=1234567890123456&ev=PageView"),
2196 &ValidationOptions::default(),
2197 )
2198 .unwrap();
2199
2200 assert!(
2201 summary
2202 .reports
2203 .iter()
2204 .all(|report| report.plugin_id != DIRECTORY_ID),
2205 "a vendor pack is better information than an attribution"
2206 );
2207 }
2208
2209 #[test]
2210 fn a_known_vendor_on_an_uncovered_endpoint_still_gets_attributed() {
2211 let summary = Engine::default()
2212 .validate(
2213 &directory_request("https://www.facebook.com/some/other/path"),
2214 &ValidationOptions::default(),
2215 )
2216 .unwrap();
2217
2218 let report = summary
2219 .reports
2220 .iter()
2221 .find(|report| report.plugin_id == DIRECTORY_ID)
2222 .expect("directory report");
2223 assert_eq!(report.detected_vendor.as_deref(), Some("meta"));
2224 assert!(
2225 report.violations[0].message.contains("`vendor/meta`"),
2226 "{}",
2227 report.violations[0].message
2228 );
2229 }
2230
2231 #[test]
2232 fn unknown_hosts_get_no_directory_report() {
2233 let summary = Engine::default()
2234 .validate(
2235 &directory_request("https://pixel.example.com/collect?id=1"),
2236 &ValidationOptions::default(),
2237 )
2238 .unwrap();
2239
2240 assert_eq!(summary.reports.len(), 1);
2241 assert_eq!(summary.reports[0].plugin_id, "core");
2242 }
2243
2244 #[test]
2245 fn a_directory_overlay_attributes_new_hosts() {
2246 let extra = VendorDirectory::from_json(
2247 r#"{
2248 "entries": [{
2249 "vendor": "acme",
2250 "display_name": "Acme",
2251 "category": "analytics",
2252 "hosts": ["px.acme.example"]
2253 }]
2254 }"#,
2255 )
2256 .expect("overlay");
2257 let mut engine = Engine::default();
2258 engine.merge_directory(extra).expect("merge");
2259
2260 let summary = engine
2261 .validate(
2262 &directory_request("https://px.acme.example/collect?id=1"),
2263 &ValidationOptions::default(),
2264 )
2265 .unwrap();
2266 let report = summary
2267 .reports
2268 .iter()
2269 .find(|report| report.plugin_id == DIRECTORY_ID)
2270 .expect("directory report");
2271 assert_eq!(report.detected_vendor.as_deref(), Some("acme"));
2272 assert!(report.is_ok());
2273 }
2274
2275 #[test]
2276 fn the_directory_honors_rulepack_toggles() {
2277 let engine = Engine::default();
2278 let request = directory_request("https://trc.taboola.com/actions?a=1");
2279
2280 let summary = engine
2281 .validate(
2282 &request,
2283 &ValidationOptions {
2284 except_rulepacks: vec![DIRECTORY_ID.to_string()],
2285 ..ValidationOptions::default()
2286 },
2287 )
2288 .unwrap();
2289 assert!(
2290 summary
2291 .reports
2292 .iter()
2293 .all(|report| report.plugin_id != DIRECTORY_ID)
2294 );
2295
2296 let summary = engine
2297 .validate(
2298 &request,
2299 &ValidationOptions {
2300 only_rulepacks: vec![DIRECTORY_ID.to_string()],
2301 ..ValidationOptions::default()
2302 },
2303 )
2304 .unwrap();
2305 assert_eq!(summary.reports.len(), 1);
2306 assert_eq!(summary.reports[0].plugin_id, DIRECTORY_ID);
2307
2308 let summary = engine
2309 .validate(
2310 &request,
2311 &ValidationOptions {
2312 only_rulepacks: vec!["core".to_string()],
2313 ..ValidationOptions::default()
2314 },
2315 )
2316 .unwrap();
2317 assert_eq!(summary.reports.len(), 1);
2318 assert_eq!(summary.reports[0].plugin_id, "core");
2319 }
2320
2321 #[test]
2322 fn an_engine_without_a_directory_attributes_nothing() {
2323 let mut engine = Engine::default();
2324 engine.set_directory(VendorDirectory::default());
2325
2326 let summary = engine
2327 .validate(
2328 &directory_request("https://trc.taboola.com/actions?a=1"),
2329 &ValidationOptions::default(),
2330 )
2331 .unwrap();
2332
2333 assert_eq!(summary.reports.len(), 1);
2334 assert_eq!(summary.reports[0].plugin_id, "core");
2335 }
2336}