Skip to main content

vastlint_core/
lib.rs

1//! # vastlint-core
2//!
3//! A zero-I/O VAST XML validation library. Takes a VAST, VMAP, or DAAST XML
4//! string and returns a structured [`ValidationResult`] listing every issue
5//! found, the detected document type and VAST version, and a summary of
6//! error/warning/info counts. The document type is decided by the root
7//! element: `<vmap:VMAP>` runs the VMAP 1.0 rules (including full VAST
8//! validation of inline `<vmap:VASTAdData>` ad data) and `<DAAST>` runs the
9//! DAAST 1.0 rules.
10//!
11//! The entire public surface is two functions and a handful of types:
12//!
13//! - [`validate`] -- validate with default settings (most callers want this)
14//! - [`validate_with_context`] -- validate with rule overrides or wrapper depth
15//! - [`fix`] -- fix deterministic issues and return repaired XML
16//! - [`fix_with_context`] -- fix with rule overrides or wrapper depth
17//! - [`inspect_document`] -- extract creative and wrapper metadata from one VAST XML document
18//! - [`all_rules`] -- list the full rule catalog
19//!
20//! # Performance — allocator recommendation
21//!
22//! `vastlint-core` builds an owned document tree on every call (one heap
23//! allocation per XML element, attribute, and text node). Under concurrent
24//! load the system allocator becomes a bottleneck because all threads compete
25//! for a shared free-list lock.
26//!
27//! Switching to [`mimalloc`](https://docs.rs/mimalloc) in your **binary**
28//! crate eliminates this contention and gives dramatically better throughput
29//! at high concurrency, especially for larger documents:
30//!
31//! ```toml
32//! # Cargo.toml (your binary, not a library crate)
33//! [dependencies]
34//! mimalloc = { version = "0.1", default-features = false }
35//! ```
36//!
37//! ```rust,ignore
38//! // src/main.rs
39//! use mimalloc::MiMalloc;
40//! #[global_allocator]
41//! static GLOBAL: MiMalloc = MiMalloc;
42//! ```
43//!
44//! Measured on Apple M4 (10 threads, production-realistic VAST tags):
45//!
46//! | Allocator | 17 KB tag | 44 KB tag |
47//! |---|---|---|
48//! | system (default) | 1,847 tags/s · 541 µs | 328 tags/s · 3,048 µs |
49//! | mimalloc | 15,760 tags/s · 63 µs | 2,635 tags/s · 380 µs |
50//!
51//! **mimalloc: ~8× throughput improvement on multi-threaded workloads.**
52//!
53//! > ⚠️ Do **not** set a global allocator in a library crate — it would
54//! > override the allocator for any host process that links you (Go, Python,
55//! > Ruby runtimes, etc.), which can cause heap corruption.
56//!
57//! # Quick start
58//!
59//! ```rust
60//! let xml = r#"<VAST version="2.0">
61//!   <Ad><InLine>
62//!     <AdSystem>Demo</AdSystem>
63//!     <AdTitle>Ad</AdTitle>
64//!     <Impression>https://t.example.com/imp</Impression>
65//!     <Creatives>
66//!       <Creative>
67//!         <Linear>
68//!           <Duration>00:00:15</Duration>
69//!           <MediaFiles>
70//!             <MediaFile delivery="progressive" type="video/mp4"
71//!                        width="640" height="360">
72//!               https://cdn.example.com/ad.mp4
73//!             </MediaFile>
74//!           </MediaFiles>
75//!         </Linear>
76//!       </Creative>
77//!     </Creatives>
78//!   </InLine></Ad>
79//! </VAST>"#;
80//!
81//! let result = vastlint_core::validate(xml);
82//! assert_eq!(result.summary.errors, 0);
83//! ```
84//!
85//! # Design constraints
86//!
87//! The library has no I/O, no logging, no global state, and no async runtime.
88//! It can be embedded in a CLI, HTTP server, WASM module, or FFI binding
89//! without pulling in any platform-specific dependencies.
90//!
91//! Three crate dependencies: `quick-xml` (XML parsing), `url` (RFC 3986),
92//! and `phf` (compile-time hash maps).
93
94mod detect;
95mod fix;
96mod inspect;
97mod parse;
98mod rules;
99mod summarize;
100
101pub use fix::{fix, fix_with_context, AppliedFix, FixResult};
102pub use inspect::{inspect_document, InspectAdType, InspectDocumentMeta, InspectMediaFile};
103
104use std::collections::HashMap;
105
106// ── Public types ─────────────────────────────────────────────────────────────
107
108/// The VAST version as declared in the `version` attribute or inferred from
109/// document structure.
110///
111/// Covers all versions published by IAB Tech Lab: 2.0 through 4.3.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum VastVersion {
114    V2_0,
115    V3_0,
116    V4_0,
117    V4_1,
118    V4_2,
119    V4_3,
120}
121
122impl VastVersion {
123    pub fn as_str(&self) -> &'static str {
124        match self {
125            VastVersion::V2_0 => "2.0",
126            VastVersion::V3_0 => "3.0",
127            VastVersion::V4_0 => "4.0",
128            VastVersion::V4_1 => "4.1",
129            VastVersion::V4_2 => "4.2",
130            VastVersion::V4_3 => "4.3",
131        }
132    }
133
134    /// Returns true if this version is 4.x or later.
135    pub fn is_v4(&self) -> bool {
136        matches!(
137            self,
138            VastVersion::V4_0 | VastVersion::V4_1 | VastVersion::V4_2 | VastVersion::V4_3
139        )
140    }
141
142    /// Returns true if this version is at least the given version.
143    pub fn at_least(&self, other: &VastVersion) -> bool {
144        self.ordinal() >= other.ordinal()
145    }
146
147    fn ordinal(&self) -> u8 {
148        match self {
149            VastVersion::V2_0 => 0,
150            VastVersion::V3_0 => 1,
151            VastVersion::V4_0 => 2,
152            VastVersion::V4_1 => 3,
153            VastVersion::V4_2 => 4,
154            VastVersion::V4_3 => 5,
155        }
156    }
157}
158
159/// The kind of IAB ad document that was validated.
160///
161/// vastlint dispatches on the root element: `<VAST>` documents run the VAST
162/// rule chain, `<vmap:VMAP>` documents run the VMAP 1.0 rules (including full
163/// VAST validation of any inline ad data), and `<DAAST>` documents run the
164/// DAAST 1.0 rules. Any other root is treated as (invalid) VAST.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum DocumentType {
167    Vast,
168    Vmap,
169    Daast,
170}
171
172impl DocumentType {
173    pub fn as_str(&self) -> &'static str {
174        match self {
175            DocumentType::Vast => "VAST",
176            DocumentType::Vmap => "VMAP",
177            DocumentType::Daast => "DAAST",
178        }
179    }
180}
181
182/// How the version was determined.
183///
184/// Version detection is a two-pass process: first the `version` attribute on
185/// the root `<VAST>` element is read (declared), then the document structure
186/// is scanned for version-specific elements (inferred). When both are
187/// available, consistency is checked and a mismatch produces a warning.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum DetectedVersion {
190    /// Version attribute was present and recognised.
191    Declared(VastVersion),
192    /// Version attribute was absent or unrecognised; version inferred from
193    /// document structure.
194    Inferred(VastVersion),
195    /// Both declared and inferred — may or may not agree.
196    DeclaredAndInferred {
197        declared: VastVersion,
198        inferred: VastVersion,
199        consistent: bool,
200    },
201    /// Could not determine version.
202    Unknown,
203}
204
205impl DetectedVersion {
206    /// Returns the best available version, preferring the declared value.
207    pub fn best(&self) -> Option<&VastVersion> {
208        match self {
209            DetectedVersion::Declared(v) => Some(v),
210            DetectedVersion::Inferred(v) => Some(v),
211            DetectedVersion::DeclaredAndInferred { declared, .. } => Some(declared),
212            DetectedVersion::Unknown => None,
213        }
214    }
215}
216
217/// Issue severity, based strictly on spec language.
218///
219/// Error   — spec says "must" or "required": the tag will likely fail to serve.
220/// Warning — spec says "should" or "recommended", or the feature is deprecated.
221/// Info    — advisory; not a spec violation but a known interoperability risk.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
223pub enum Severity {
224    Info,
225    Warning,
226    Error,
227}
228
229impl Severity {
230    pub fn as_str(&self) -> &'static str {
231        match self {
232            Severity::Error => "error",
233            Severity::Warning => "warning",
234            Severity::Info => "info",
235        }
236    }
237}
238
239/// A single validation finding.
240#[derive(Debug, Clone)]
241pub struct Issue {
242    /// Stable rule identifier, e.g. "VAST-2.0-root-version".
243    pub id: &'static str,
244    /// Effective severity after applying any caller overrides.
245    pub severity: Severity,
246    /// Human-readable message. Static string; no allocation on the hot path.
247    pub message: &'static str,
248    /// XPath-like location in the document, e.g. `/VAST/Ad\[0\]/InLine/AdSystem`.
249    /// None when the issue applies to the document as a whole.
250    pub path: Option<String>,
251    /// Short spec reference, e.g. "IAB VAST 4.1 §3.4.1".
252    pub spec_ref: &'static str,
253    /// 1-based line number of the element that triggered this issue.
254    /// None for document-level issues (e.g. parse errors, missing root).
255    pub line: Option<u32>,
256    /// 1-based column number (byte offset within the line) of the element.
257    /// None for document-level issues.
258    pub col: Option<u32>,
259}
260
261/// Counts of issues by severity.
262///
263/// Use [`Summary::is_valid`] to check whether the document passes validation.
264/// A document is valid when `errors == 0`, regardless of warning or info count.
265#[derive(Debug, Clone, Default)]
266pub struct Summary {
267    pub errors: usize,
268    pub warnings: usize,
269    pub infos: usize,
270}
271
272impl Summary {
273    pub fn is_valid(&self) -> bool {
274        self.errors == 0
275    }
276}
277
278/// The full result of validating a VAST document.
279///
280/// Contains the detected version, all issues found, and a summary with counts.
281/// The `issues` vector is ordered by document position (depth-first traversal).
282#[derive(Debug, Clone)]
283pub struct ValidationResult {
284    /// The kind of document that was validated (VAST, VMAP, or DAAST),
285    /// decided by the root element.
286    pub document_type: DocumentType,
287    /// The detected VAST version. Always `Unknown` for VMAP and DAAST
288    /// documents — their version attributes are checked by their own rules.
289    pub version: DetectedVersion,
290    pub issues: Vec<Issue>,
291    pub summary: Summary,
292}
293
294// ── Rule configuration ────────────────────────────────────────────────────────
295
296/// Per-rule severity override. Mirrors Severity but adds Off.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub enum RuleLevel {
299    Error,
300    Warning,
301    Info,
302    /// Rule does not run. Produces no Issue.
303    Off,
304}
305
306/// Context passed to validate_with_context. All fields have safe defaults.
307#[derive(Debug, Clone)]
308pub struct ValidationContext {
309    /// Current wrapper chain depth. 0 = this document is the root.
310    pub wrapper_depth: u8,
311    /// Maximum allowed wrapper depth. IAB VAST 4.x recommends 5.
312    pub max_wrapper_depth: u8,
313    /// Per-rule severity overrides keyed by rule ID.
314    /// None means "use all recommended defaults".
315    pub rule_overrides: Option<HashMap<&'static str, RuleLevel>>,
316    /// Override the VAST version used for validation, ignoring the version
317    /// attribute declared in the XML. None = auto-detect from the document
318    /// (default). Useful for validating templates or tags where the version
319    /// attribute is absent or incorrect.
320    pub forced_version: Option<VastVersion>,
321}
322
323impl Default for ValidationContext {
324    fn default() -> Self {
325        Self {
326            wrapper_depth: 0,
327            max_wrapper_depth: 5,
328            rule_overrides: None,
329            forced_version: None,
330        }
331    }
332}
333
334impl ValidationContext {
335    /// Resolve the effective level for a rule, applying any override.
336    /// Returns None when the rule should be silenced (Off).
337    pub(crate) fn resolve(&self, rule_id: &'static str, default: Severity) -> Option<Severity> {
338        match &self.rule_overrides {
339            None => Some(default),
340            Some(map) => match map.get(rule_id) {
341                None => Some(default),
342                Some(RuleLevel::Off) => None,
343                Some(RuleLevel::Error) => Some(Severity::Error),
344                Some(RuleLevel::Warning) => Some(Severity::Warning),
345                Some(RuleLevel::Info) => Some(Severity::Info),
346            },
347        }
348    }
349}
350
351// ── Entry points ──────────────────────────────────────────────────────────────
352
353/// Validate a VAST XML string using default settings.
354///
355/// This is the main entry point for most callers. It runs the full rule set
356/// against the document and returns a [`ValidationResult`] containing every
357/// issue found, a detected version, and a summary.
358///
359/// # Example
360///
361/// ```rust
362/// let xml = r#"<VAST version="4.1">
363///   <Ad id="1">
364///     <InLine>
365///       <AdSystem>Example</AdSystem>
366///       <AdTitle>Test Ad</AdTitle>
367///       <AdServingId>abc123</AdServingId>
368///       <Impression>https://track.example.com/imp</Impression>
369///       <Creatives>
370///         <Creative>
371///           <UniversalAdId idRegistry="ad-id.org">UID-001</UniversalAdId>
372///           <Linear>
373///             <Duration>00:00:30</Duration>
374///             <MediaFiles>
375///               <MediaFile delivery="progressive" type="video/mp4"
376///                          width="1920" height="1080">
377///                 https://cdn.example.com/ad.mp4
378///               </MediaFile>
379///             </MediaFiles>
380///           </Linear>
381///         </Creative>
382///       </Creatives>
383///     </InLine>
384///   </Ad>
385/// </VAST>"#;
386///
387/// let result = vastlint_core::validate(xml);
388/// assert!(result.summary.is_valid());
389/// // Info-level advisories (e.g. missing Mezzanine for CTV) may be present
390/// // but the document has no errors or warnings that affect validity.
391/// assert_eq!(result.summary.errors, 0);
392/// ```
393pub fn validate(input: &str) -> ValidationResult {
394    validate_with_context(input, ValidationContext::default())
395}
396
397/// Validate a VAST XML string with caller-supplied context.
398///
399/// Use this when you need to declare wrapper chain depth or override the
400/// severity of specific rules. For simple validation, prefer [`validate`].
401///
402/// # Wrapper chain depth
403///
404/// When following a wrapper chain, pass the current depth so the
405/// [`crate::Severity::Error`] rule for `VAST-2.0-wrapper-depth` fires at the
406/// right level:
407///
408/// ```rust
409/// use vastlint_core::{ValidationContext, validate_with_context};
410///
411/// let ctx = ValidationContext {
412///     wrapper_depth: 3,
413///     max_wrapper_depth: 5,
414///     ..Default::default()
415/// };
416/// let result = validate_with_context("<VAST/>", ctx);
417/// ```
418///
419/// # Rule overrides
420///
421/// Suppress or downgrade individual rules by passing a rule override map.
422/// Rule IDs are the stable identifiers from the [`all_rules`] catalog.
423///
424/// ```rust
425/// use std::collections::HashMap;
426/// use vastlint_core::{RuleLevel, ValidationContext, validate_with_context};
427///
428/// let mut overrides = HashMap::new();
429/// // Silence the HTTP-vs-HTTPS advisory for internal tooling.
430/// overrides.insert("VAST-2.0-mediafile-https", RuleLevel::Off);
431/// // Treat a missing version attribute as a hard error.
432/// overrides.insert("VAST-2.0-root-version", RuleLevel::Error);
433///
434/// let ctx = ValidationContext {
435///     rule_overrides: Some(overrides),
436///     ..Default::default()
437/// };
438/// let result = validate_with_context("<VAST/>", ctx);
439/// ```
440pub fn validate_with_context(input: &str, context: ValidationContext) -> ValidationResult {
441    let doc = parse::parse(input);
442    let document_type = detect::detect_document_type(&doc);
443    let version = match document_type {
444        // version attributes on VMAP/DAAST roots are validated by their own
445        // rule chains; DetectedVersion only describes VAST versions.
446        DocumentType::Vmap | DocumentType::Daast => DetectedVersion::Unknown,
447        DocumentType::Vast => match context.forced_version {
448            Some(v) => DetectedVersion::Declared(v),
449            None => detect::detect_version(&doc),
450        },
451    };
452    let mut issues = Vec::new();
453    rules::run(&doc, &version, &context, &mut issues);
454    let summary = summarize::summarize(&issues);
455    ValidationResult {
456        document_type,
457        version,
458        issues,
459        summary,
460    }
461}
462
463// ── Test helpers (integration tests only) ────────────────────────────────────
464
465/// Re-exports the internal parser for integration tests that need to verify
466/// the repaired XML round-trips without parse errors.
467#[doc(hidden)]
468pub fn _test_parse(xml: &str) -> parse::VastDocument {
469    parse::parse(xml)
470}
471
472/// The external standard or authority that a rule is derived from.
473///
474/// Mirrors the standards listed in the README. Use this to filter the catalog
475/// by authority level — e.g. alert hard on [`RuleSource::VastSpec`] violations
476/// while only logging [`RuleSource::Inferred`] advisories.
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub enum RuleSource {
479    /// IAB Tech Lab VAST spec normative prose (explicit §-references)
480    VastSpec,
481    /// IAB Tech Lab VAST published XSD schemas (structural and enum constraints)
482    VastXsd,
483    /// W3C XML 1.0 well-formedness
484    Xml,
485    /// RFC 3986 URI syntax
486    Rfc3986,
487    /// IANA Media Types registry
488    IanaMediaTypes,
489    /// ISO 4217 currency codes
490    Iso4217,
491    /// Ad-ID registry format
492    AdId,
493    /// vastlint heuristic — no single external spec authority
494    Inferred,
495    /// IAB Tech Lab SIMID spec normative prose
496    SimidSpec,
497    /// IAB Tech Lab VMAP 1.0.1 spec normative prose
498    VmapSpec,
499    /// IAB Tech Lab DAAST spec normative prose
500    DaastSpec,
501    /// IAB Tech Lab DAAST published XSD schema
502    DaastXsd,
503    /// Industry best practice derived from real-world ad serving patterns;
504    /// violation has a direct revenue or measurement impact.
505    IndustryBestPractice,
506}
507
508impl RuleSource {
509    /// Short stable string identifier, suitable for JSON output and display.
510    pub fn as_str(self) -> &'static str {
511        match self {
512            RuleSource::VastSpec => "VAST spec",
513            RuleSource::VastXsd => "VAST XSD",
514            RuleSource::Xml => "W3C XML 1.0",
515            RuleSource::Rfc3986 => "RFC 3986",
516            RuleSource::IanaMediaTypes => "IANA Media Types",
517            RuleSource::Iso4217 => "ISO 4217",
518            RuleSource::AdId => "Ad-ID",
519            RuleSource::Inferred => "inferred",
520            RuleSource::SimidSpec => "IAB SIMID",
521            RuleSource::VmapSpec => "IAB VMAP",
522            RuleSource::DaastSpec => "IAB DAAST",
523            RuleSource::DaastXsd => "DAAST XSD",
524            RuleSource::IndustryBestPractice => "revenue impact",
525        }
526    }
527}
528
529/// Metadata about a single rule, as exposed by the public catalog.
530///
531/// Marked `#[non_exhaustive]` so that adding fields in future minor releases
532/// does not break downstream code that reads (but never constructs) `RuleMeta`.
533#[non_exhaustive]
534pub struct RuleMeta {
535    pub id: &'static str,
536    pub default_severity: Severity,
537    pub description: &'static str,
538    /// The external standard this rule is derived from.
539    pub source: RuleSource,
540}
541
542impl RuleMeta {
543    /// Returns `true` when violating this rule has a direct revenue or
544    /// measurement impact — lost impressions, broken tracking, zero fill.
545    ///
546    /// This covers both rules whose [`source`](RuleMeta::source) is
547    /// [`RuleSource::IndustryBestPractice`] and rules whose source is an IAB
548    /// spec standard but whose real-world consequence is measurable revenue
549    /// loss (missing `<Impression>`, dead wrapper redirect, etc.).
550    pub fn revenue_impact(&self) -> bool {
551        matches!(
552            self.id,
553            // IndustryBestPractice-sourced rules
554            "VAST-2.0-mediafile-https"
555            | "VAST-2.0-tracking-https"
556            | "VAST-2.0-duplicate-impression"
557            | "VAST-4.1-mezzanine-recommended"
558            | "VAST-4.1-vpaid-in-interactive-context"
559            | "VAST-2.0-linear-tracking-quartiles"
560            // VastSpec-sourced rules with direct revenue consequence
561            | "VAST-2.0-inline-impression"
562            | "VAST-2.0-wrapper-impression"
563            | "VAST-2.0-wrapper-vastadtaguri"
564            | "VAST-2.0-url-empty"
565            | "VAST-4.1-vpaid-apiframework"
566            | "VAST-2.0-flash-mediafile"
567        )
568    }
569}
570
571/// Returns the full catalog of known rules in definition order.
572///
573/// Use this to power `vastlint rules` output or to validate config-file rule
574/// IDs before passing them into `ValidationContext.rule_overrides`.
575pub fn all_rules() -> &'static [RuleMeta] {
576    rules::CATALOG
577}