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