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