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}