Skip to main content

pixellint_core/
lib.rs

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
27/// The vendor endpoint directory compiled into the crate.
28pub const BUILTIN_VENDOR_DIRECTORY: &str = include_str!("../rulepacks/directory.json");
29
30/// First-party vendor rulepacks compiled into the crate, as (id, manifest JSON).
31///
32/// Every pack here is a declarative manifest, the same format users write for
33/// their own packs. Nothing about a first-party pack is privileged.
34pub 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    /// A JSON request body, as the conversion APIs carry their events.
543    #[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    /// A field inside a JSON request body, named by its path.
605    BodyField,
606    /// The request body as a whole.
607    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    /// Vendor slug the pack covers, when it covers one vendor's endpoints.
639    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    /// An engine with the `core` pack and every first-party vendor pack
716    /// registered. Built-in manifests are compiled in tests, so a malformed one
717    /// is a build-time bug rather than a runtime surprise.
718    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    /// Replaces the vendor directory. An empty directory disables endpoint
742    /// attribution entirely.
743    pub fn set_directory(&mut self, directory: VendorDirectory) {
744        self.directory = directory;
745    }
746
747    /// The vendor directory this engine attributes endpoints with.
748    pub fn directory(&self) -> &VendorDirectory {
749        &self.directory
750    }
751
752    /// Adds overlay entries to the current directory. Duplicate hosts fail
753    /// the merge, so a community file cannot steal a first-party attribution.
754    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    /// Compiles a declarative rulepack manifest and registers it.
767    pub fn register_manifest_json(&mut self, json: &str) -> Result<(), ManifestError> {
768        self.register(ManifestRulePack::from_json(json)?);
769        Ok(())
770    }
771
772    /// Compiles a declarative rulepack manifest from disk and registers it.
773    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        // The directory is not a plugin, so it never takes part in plugin
818        // selection. Asking for it alone is legitimate and yields no plugins.
819        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    /// Attributes an endpoint no rulepack claimed.
863    ///
864    /// This runs only when nothing else detected a vendor: a matching vendor
865    /// pack is strictly better information than a directory hit.
866    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                // An unstated kind that opens like a document is read as one, so
1019                // a pasted payload still gets its syntax checked.
1020                ArtifactKind::Unknown if json::JsonDocument::looks_like_json(artifact) => {
1021                    validate_json_artifact(artifact, &mut violations);
1022                }
1023                _ => {}
1024            }
1025        }
1026
1027        // `core` is vendor-neutral: it never claims to have detected a vendor,
1028        // even when the caller says which one they expect.
1029        ValidationReport {
1030            plugin_id: self.metadata.id.clone(),
1031            detected_vendor: None,
1032            violations,
1033        }
1034    }
1035}
1036
1037/// A body that does not parse cannot be contracted by any vendor pack, so the
1038/// syntax error is the whole finding and it is worth saying precisely.
1039fn 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, &macro_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, &macro_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        // Step by Unicode scalar so a non-ASCII byte (a replacement char in a
1432        // D1 sample, a decoded UTF-8 query) cannot land `index` inside a char.
1433        index += remainder.chars().next().map(|c| c.len_utf8()).unwrap_or(1);
1434    }
1435
1436    spans
1437}
1438
1439/// Macro delimiters recognized by the generic ad-tech macro scanner, in match order.
1440const 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        // Broken vendor macro `{` + replacement char + `name}` as seen on
2149        // Amazon VFW IAS wrappers after D1 storage.
2150        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}