vastlint-core 0.10.1

VAST XML validator, SIMID linter, OMID validator, and VPAID detector. IAB VAST 2.0–4.4, 212 rules, pure Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
//! CTV Ad Portfolio rules (VAST 4.4 draft).
//!
//! The IAB Tech Lab CTV Ad Portfolio standardises six streaming formats: Pause,
//! Screensaver, Overlay, Squeezeback, In-Scene and Menu. Five of the six are
//! delivered through VAST `<NonLinearAds>`; Menu is transacted through the
//! OpenRTB Native object and never reaches these rules.
//!
//! Two artifacts shipped, at different maturity levels, and this module treats
//! them differently:
//!
//! - The signaling guidance (`Signaling-Implementation-Guidelines.md`) is
//!   **final** as of 2026-07-22. Rules derived from it carry
//!   [`RuleSource::CtvAdPortfolio`].
//! - `vast_4.4.xsd` is a **working-group draft** by its own annotation. Rules
//!   derived only from it stay at warning or info, never error, because the
//!   schema may still change.
//!
//! The one exception is structural malformation that is wrong under any
//! reading: a `<plcmt>` payload of `"seven"` is not a draft-versus-final
//! question, and a QR position of `"120"` violates the only type the element
//! has ever had.
//!
//! Version gate: 4.x, not 4.4. Every VAST example in the final guidance
//! declares `version="4.2"` while using the new content model, so gating on
//! 4.4 would skip the ecosystem's real traffic. See
//! `specs/vast_4.4_reference.md`.

use super::emit;
use crate::parse::{Node, VastDocument};
use crate::{DetectedVersion, Issue, Severity, ValidationContext, VastVersion};

/// AdCOM signal names that may appear as `<Extension ext="adcom">` payloads.
const ADCOM_SIGNALS: [&str; 4] = ["plcmt", "pos", "playbackmethod", "attr"];

/// AdCOM Plcmt Subtypes (Video). 1-4 predate the CTV Ad Portfolio; 5-9 are
/// Pause, Screensaver, Overlay, Squeezeback and In-Scene respectively.
const PLCMT_MAX: i64 = 9;

/// AdCOM Playback Methods. 1-7 predate the CTV Ad Portfolio; 8-11 are the
/// Pause and Screensaver sound-on/sound-off pairs.
const PLAYBACKMETHOD_MAX: i64 = 11;

/// AdCOM Placement Positions. The guidance's per-format tables reach 17 for
/// Squeezeback layouts. 0 remains "unknown".
const POS_MAX: i64 = 17;

/// AdCOM Creative Attributes added for the CTV Ad Portfolio: 21 Static Visual,
/// 22 Limited Motion (Cinemagraph), 23 Full-Motion Video.
const MOTION_ATTRS: std::ops::RangeInclusive<i64> = 21..=23;

pub fn check(
    doc: &VastDocument,
    version: &DetectedVersion,
    ctx: &ValidationContext,
    issues: &mut Vec<Issue>,
) {
    let Some(vast) = doc.vast_root() else { return };
    let Some(v) = version.best() else { return };
    if !v.is_v4() {
        return;
    }

    if matches!(v, VastVersion::V4_4) {
        emit(
            ctx,
            issues,
            "VAST-4.4-version-attribute",
            Severity::Info,
            "Document declares VAST 4.4, which is a working-group draft rather than a published spec. IAB's own CTV Ad Portfolio examples declare 4.2",
            Some("/VAST@version".to_string()),
            "IAB vast_4.4.xsd (draft annotation)",
            Some(vast),
        );
    }

    for (ad_idx, ad) in vast.children_named("Ad").enumerate() {
        let ad_path = format!("/VAST/Ad[{}]", ad_idx);

        for container in ["InLine", "Wrapper"] {
            let Some(node) = ad.child(container) else {
                continue;
            };
            let node_path = format!("{}/{}", ad_path, container);

            if let Some(extensions) = node.child("Extensions") {
                check_adcom_extensions(
                    extensions,
                    &format!("{}/Extensions", node_path),
                    ctx,
                    issues,
                );
            }

            let Some(creatives) = node.child("Creatives") else {
                continue;
            };
            for (ci, creative) in creatives.children_named("Creative").enumerate() {
                let creative_path = format!("{}/Creatives/Creative[{}]", node_path, ci);
                check_creative(creative, &creative_path, ctx, issues);
            }
        }
    }
}

fn check_creative(creative: &Node, path: &str, ctx: &ValidationContext, issues: &mut Vec<Issue>) {
    if let Some(nl_ads) = creative.child("NonLinearAds") {
        let nl_ads_path = format!("{}/NonLinearAds", path);
        for (i, nl) in nl_ads.children_named("NonLinear").enumerate() {
            check_non_linear(
                nl,
                &format!("{}/NonLinear[{}]", nl_ads_path, i),
                ctx,
                issues,
            );
        }
    }

    if let Some(creative_exts) = creative.child("CreativeExtensions") {
        let exts_path = format!("{}/CreativeExtensions", path);
        for (i, ext) in creative_exts
            .children_named("CreativeExtension")
            .enumerate()
        {
            check_qr_creative_extension(
                ext,
                &format!("{}/CreativeExtension[{}]", exts_path, i),
                ctx,
                issues,
            );
        }
    }
}

// ── NonLinear delivery ────────────────────────────────────────────────────────

fn check_non_linear(nl: &Node, path: &str, ctx: &ValidationContext, issues: &mut Vec<Issue>) {
    let Some(media_files) = nl.child("MediaFiles") else {
        // No <MediaFiles> means this is a classic NonLinear using
        // StaticResource/IFrameResource/HTMLResource. Nothing here applies.
        return;
    };

    let has_media_file = media_files.has_child("MediaFile");
    let has_interactive = media_files.has_child("InteractiveCreativeFile");
    let has_classic_resource = nl.has_child("StaticResource")
        || nl.has_child("IFrameResource")
        || nl.has_child("HTMLResource");

    // VAST-4.4-nonlinear-no-renderable-asset
    // Guidance §Fallback Media: "If the SIMID file cannot be executed and a
    // ready-to-render <MediaFile> is available, the player may render the
    // fallback media file. If neither the interactive file nor a fallback media
    // file can be rendered, the player should fire the relevant VAST error URI."
    // A NonLinear carrying only an InteractiveCreativeFile renders nothing on
    // any player without SIMID support, which on CTV is most of them.
    if has_interactive && !has_media_file && !has_classic_resource {
        emit(
            ctx,
            issues,
            "VAST-4.4-nonlinear-no-renderable-asset",
            Severity::Warning,
            "<NonLinear> carries an <InteractiveCreativeFile> but no renderable fallback: players without SIMID support have nothing to render and will fire the error URI",
            Some(format!("{}/MediaFiles", path)),
            "IAB CTV Ad Portfolio §Secure Interactive Ad Units (Fallback Media)",
            Some(media_files),
        );
    }

    if !has_media_file && !has_interactive && !has_classic_resource {
        emit(
            ctx,
            issues,
            "VAST-4.4-nonlinear-mediafiles-empty",
            Severity::Error,
            "<NonLinear> has a <MediaFiles> container with no <MediaFile> or <InteractiveCreativeFile> and no static resource; the ad has no asset to render",
            Some(format!("{}/MediaFiles", path)),
            "IAB CTV Ad Portfolio §Signaling the Five Non-Linear CTV Formats",
            Some(media_files),
        );
    }

    // VAST-4.4-nonlinear-simid-iframe
    // Guidance §Secure Interactive Ad Units: "The prior pattern of using
    // <IFrameResource apiFramework="SIMID"> is not recommended for CTV Ad
    // Portfolio NonLinear ads."
    for iframe in nl.children_named("IFrameResource") {
        if iframe
            .attr("apiFramework")
            .is_some_and(|f| f.eq_ignore_ascii_case("SIMID"))
        {
            emit(
                ctx,
                issues,
                "VAST-4.4-nonlinear-simid-iframe",
                Severity::Info,
                "<IFrameResource apiFramework=\"SIMID\"> is the superseded pattern. CTV Ad Portfolio NonLinear ads should declare SIMID as <InteractiveCreativeFile apiFramework=\"SIMID\"> inside <MediaFiles>",
                Some(format!("{}/IFrameResource", path)),
                "IAB CTV Ad Portfolio §Secure Interactive Ad Units",
                Some(iframe),
            );
        }
    }

    // VAST-4.4-nonlinear-video-no-duration
    // Guidance §Handling Duration: duration is optional for static image
    // creative where it is not known at response time, but quartile and
    // overlayViewDuration tracking only work when <Duration> is present. A
    // video MediaFile without a Duration silently loses that measurement.
    let has_video_media_file = media_files.children_named("MediaFile").any(|mf| {
        mf.attr("type")
            .is_some_and(|t| t.trim().to_ascii_lowercase().starts_with("video/"))
    });
    if has_video_media_file && !nl.has_child("Duration") {
        emit(
            ctx,
            issues,
            "VAST-4.4-nonlinear-video-no-duration",
            Severity::Warning,
            "<NonLinear> delivers a video <MediaFile> but declares no <Duration>: quartile and overlayViewDuration tracking cannot fire without it",
            Some(path.to_string()),
            "IAB CTV Ad Portfolio §Handling Duration",
            Some(nl),
        );
    }
}

// ── AdCOM signal round-trip via <Extension> ───────────────────────────────────

fn check_adcom_extensions(
    extensions: &Node,
    path: &str,
    ctx: &ValidationContext,
    issues: &mut Vec<Issue>,
) {
    for (i, ext) in extensions.children_named("Extension").enumerate() {
        let ext_path = format!("{}/Extension[{}]", path, i);

        // Only Extensions explicitly marked as AdCOM payloads are in scope. A
        // vendor Extension that happens to contain a <pos> child is none of our
        // business.
        let is_adcom = ext
            .attr("ext")
            .is_some_and(|e| e.eq_ignore_ascii_case("adcom"))
            || ADCOM_SIGNALS
                .iter()
                .any(|s| ext.children_named(s).next().is_some());
        if !is_adcom {
            continue;
        }

        let declared_type = ext.attr("type").map(str::trim);

        // VAST-4.4-adcom-extension-unknown-signal
        if let Some(t) = declared_type {
            if !ADCOM_SIGNALS.contains(&t) {
                emit(
                    ctx,
                    issues,
                    "VAST-4.4-adcom-extension-unknown-signal",
                    Severity::Warning,
                    "<Extension ext=\"adcom\"> declares a type that is not an AdCOM signal. Expected plcmt, pos, playbackmethod or attr",
                    Some(format!("{}@type", ext_path)),
                    "IAB CTV Ad Portfolio §Purpose of VAST ext",
                    Some(ext),
                );
            }
        }

        for signal in ADCOM_SIGNALS {
            for payload in ext.children_named(signal) {
                let payload_path = format!("{}/{}", ext_path, signal);

                // VAST-4.4-adcom-extension-type-mismatch
                if let Some(t) = declared_type {
                    if ADCOM_SIGNALS.contains(&t) && t != signal {
                        emit(
                            ctx,
                            issues,
                            "VAST-4.4-adcom-extension-type-mismatch",
                            Severity::Warning,
                            "<Extension> declares one AdCOM signal in its type attribute but carries a different one as its payload; downstream stitchers key off type",
                            Some(payload_path.clone()),
                            "IAB CTV Ad Portfolio §Purpose of VAST ext",
                            Some(payload),
                        );
                    }
                }

                check_adcom_value(signal, payload, &payload_path, ctx, issues);
            }
        }
    }
}

fn check_adcom_value(
    signal: &str,
    payload: &Node,
    path: &str,
    ctx: &ValidationContext,
    issues: &mut Vec<Issue>,
) {
    let raw = payload.text.trim();

    // VAST-4.4-adcom-signal-not-integer
    // Error regardless of draft status: every AdCOM enumeration is an integer,
    // and this is malformed under any reading of any version.
    let Ok(value) = raw.parse::<i64>() else {
        emit(
            ctx,
            issues,
            "VAST-4.4-adcom-signal-not-integer",
            Severity::Error,
            "AdCOM signal payload in <Extension> is not an integer. plcmt, pos, playbackmethod and attr are all numeric enumerations",
            Some(path.to_string()),
            "IAB AdCOM 1.0 enumerated lists",
            Some(payload),
        );
        return;
    };

    match signal {
        "plcmt" => {
            if !(1..=PLCMT_MAX).contains(&value) {
                emit(
                    ctx,
                    issues,
                    "VAST-4.4-adcom-plcmt-value",
                    Severity::Warning,
                    "AdCOM plcmt outside the known Plcmt Subtypes (Video) range 1-9. CTV Ad Portfolio uses 5 Pause, 6 Screensaver, 7 Overlay, 8 Squeezeback, 9 In-Scene",
                    Some(path.to_string()),
                    "IAB AdCOM List: Plcmt Subtypes - Video",
                    Some(payload),
                );
            }
        }
        "playbackmethod" => {
            if !(1..=PLAYBACKMETHOD_MAX).contains(&value) {
                emit(
                    ctx,
                    issues,
                    "VAST-4.4-adcom-playbackmethod-value",
                    Severity::Warning,
                    "AdCOM playbackmethod outside the known Playback Methods range 1-11. CTV Ad Portfolio adds 8/9 for Pause and 10/11 for Screensaver",
                    Some(path.to_string()),
                    "IAB AdCOM List: Playback Methods",
                    Some(payload),
                );
            }
        }
        "pos" => {
            if !(0..=POS_MAX).contains(&value) {
                emit(
                    ctx,
                    issues,
                    "VAST-4.4-adcom-pos-value",
                    Severity::Warning,
                    "AdCOM pos outside the known Placement Positions range 0-17",
                    Some(path.to_string()),
                    "IAB AdCOM List: Placement Positions",
                    Some(payload),
                );
            }
        }
        "attr" if !MOTION_ATTRS.contains(&value) => emit(
            ctx,
            issues,
            "VAST-4.4-adcom-attr-not-motion",
            Severity::Info,
            "AdCOM attr round-tripped into VAST is not one of the CTV Ad Portfolio motion attributes (21 Static Visual, 22 Limited Motion, 23 Full-Motion Video); publishers validate the rendered experience against these",
            Some(path.to_string()),
            "IAB CTV Ad Portfolio §Declaring Creative Experience with battr and attr",
            Some(payload),
        ),
        _ => {}
    }
}

// ── QR code CreativeExtension ─────────────────────────────────────────────────

fn check_qr_creative_extension(
    ext: &Node,
    path: &str,
    ctx: &ValidationContext,
    issues: &mut Vec<Issue>,
) {
    let position = ext.child("QrCodePosition");
    let size = ext.child("QrCodeSize");
    let scan_url = ext.child("QrCodeScanUrl");

    if position.is_none() && size.is_none() && scan_url.is_none() {
        return;
    }

    // VAST-4.4-qrcode-position-percent
    // Unlike <Icon>, whose xPosition/yPosition accept a bare pixel integer,
    // QrCodePosition is typed vastPercent_type in every revision of the schema
    // that has ever defined it. A bare integer here is malformed, not a draft
    // ambiguity.
    if let Some(pos) = position {
        for attr in ["xPosition", "yPosition"] {
            match pos.attr(attr) {
                None => emit(
                    ctx,
                    issues,
                    "VAST-4.4-qrcode-position-attrs",
                    Severity::Error,
                    "<QrCodePosition> requires both xPosition and yPosition",
                    Some(format!("{}/QrCodePosition@{}", path, attr)),
                    "IAB vast_4.4.xsd vastQrCodePosition_type",
                    Some(pos),
                ),
                Some(value) if !is_percent(value) => emit(
                    ctx,
                    issues,
                    "VAST-4.4-qrcode-position-percent",
                    Severity::Error,
                    "<QrCodePosition> coordinates must be percentages. Unlike <Icon>, bare pixel values are not valid here",
                    Some(format!("{}/QrCodePosition@{}", path, attr)),
                    "IAB vast_4.4.xsd vastPercent_type",
                    Some(pos),
                ),
                Some(_) => {}
            }
        }
    }

    if let Some(sz) = size {
        match sz.attr("size") {
            None => emit(
                ctx,
                issues,
                "VAST-4.4-qrcode-size-attr",
                Severity::Error,
                "<QrCodeSize> requires a size attribute",
                Some(format!("{}/QrCodeSize", path)),
                "IAB vast_4.4.xsd vastQrCodeSize_type",
                Some(sz),
            ),
            Some(value) if !is_percent(value) => emit(
                ctx,
                issues,
                "VAST-4.4-qrcode-size-percent",
                Severity::Error,
                "<QrCodeSize> size must be a percentage",
                Some(format!("{}/QrCodeSize@size", path)),
                "IAB vast_4.4.xsd vastPercent_type",
                Some(sz),
            ),
            Some(_) => {}
        }
    }

    // VAST-4.4-qrcode-missing-scan-url
    // Geometry without a destination tells the platform where to draw a QR code
    // it has no URL for.
    if scan_url.is_none() && (position.is_some() || size.is_some()) {
        emit(
            ctx,
            issues,
            "VAST-4.4-qrcode-missing-scan-url",
            Severity::Warning,
            "<CreativeExtension> declares QR code geometry but no <QrCodeScanUrl>: the platform has position and size for a destination it does not know",
            Some(path.to_string()),
            "IAB CTV Ad Portfolio §QR Code Signaling",
            Some(ext),
        );
    }
}

/// Match `vastPercent_type`: `\d+(\.\d+)?%`.
fn is_percent(value: &str) -> bool {
    let v = value.trim();
    let Some(number) = v.strip_suffix('%') else {
        return false;
    };
    if number.is_empty() {
        return false;
    }
    match number.split_once('.') {
        None => number.bytes().all(|b| b.is_ascii_digit()),
        Some((int, frac)) => {
            !int.is_empty()
                && !frac.is_empty()
                && int.bytes().all(|b| b.is_ascii_digit())
                && frac.bytes().all(|b| b.is_ascii_digit())
        }
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::is_percent;

    #[test]
    fn percent_accepts_spec_forms() {
        assert!(is_percent("0%"));
        assert!(is_percent("15%"));
        assert!(is_percent("100%"));
        assert!(is_percent("12.5%"));
        assert!(is_percent(" 70% "));
    }

    #[test]
    fn percent_rejects_pixels_and_junk() {
        assert!(!is_percent("120"));
        assert!(!is_percent("120px"));
        assert!(!is_percent("%"));
        assert!(!is_percent("12.%"));
        assert!(!is_percent(".5%"));
        assert!(!is_percent("-5%"));
        assert!(!is_percent(""));
    }
}