use super::emit;
use crate::parse::{Node, VastDocument};
use crate::{DetectedVersion, Issue, Severity, ValidationContext, VastVersion};
static KNOWN_MACROS: phf::Set<&'static str> = phf::phf_set! {
"ADCATEGORIES",
"ADCOUNT",
"ADPLAYHEAD",
"ADSERVINGID",
"ADTYPE",
"APIFRAMEWORKS",
"APPBUNDLE",
"ASSETURI",
"BLOCKEDADCATEGORIES",
"BREAKMAXADLENGTH",
"BREAKMAXADS",
"BREAKMAXDURATION",
"BREAKMINADLENGTH",
"BREAKMINDURATION",
"BREAKPOSITION",
"CACHEBUSTING",
"CLICKPOS",
"CLIENTUA",
"CONTENTID",
"CONTENTPLAYHEAD",
"CONTENTURI",
"DEVICEIP",
"DEVICEUA",
"DOMAIN",
"ERRORCODE",
"EXTENSIONS",
"GDPR",
"GDPRCONSENT",
"IFA",
"IFATYPE",
"INVENTORYSTATE",
"LATLONG",
"LIMITADTRACKING",
"MEDIAMIME",
"MEDIAPLAYHEAD",
"OMIDPARTNER",
"PAGEURL",
"PLACEMENTTYPE",
"PLAYERCAPABILITIES",
"PLAYERSIZE",
"PLAYERSTATE",
"PODSEQUENCE",
"REASON",
"REGULATIONS",
"SERVERSIDE",
"SERVERUA",
"TIMESTAMP",
"TRANSACTIONID",
"UNIVERSALADID",
"VASTVERSIONS",
"VERIFICATIONVENDORS",
};
const URL_ELEMENTS: &[&str] = &[
"Impression",
"Error",
"ClickThrough",
"ClickTracking",
"CustomClick",
"IconClickThrough",
"IconClickTracking",
"IconViewTracking",
"NonLinearClickThrough",
"NonLinearClickTracking",
"CompanionClickThrough",
"CompanionClickTracking",
"Viewable",
"NotViewable",
"ViewUndetermined",
"VASTAdTagURI",
"MediaFile",
"Tracking",
"Mezzanine",
"JavaScriptResource",
"ExecutableResource",
"InteractiveCreativeFile",
"StaticResource",
"IFrameResource",
"ClosedCaptionFile",
];
fn is_illegal_uri_char(c: char) -> bool {
matches!(
c,
' ' | '"' | '<' | '>' | '\\' | '^' | '`' | '{' | '}' | '|'
) || c.is_control()
}
pub fn check(
doc: &VastDocument,
version: &DetectedVersion,
ctx: &ValidationContext,
issues: &mut Vec<Issue>,
) {
let Some(vast) = doc.vast_root() else { return };
let v4_1_plus = version
.best()
.map(|v| v.at_least(&VastVersion::V4_1))
.unwrap_or(false);
for (ad_idx, ad) in vast.children_named("Ad").enumerate() {
let ad_path = format!("/VAST/Ad[{}]", ad_idx);
if let Some(inline) = ad.child("InLine") {
walk(
inline,
&format!("{}/InLine", ad_path),
v4_1_plus,
ctx,
issues,
);
}
if let Some(wrapper) = ad.child("Wrapper") {
walk(
wrapper,
&format!("{}/Wrapper", ad_path),
v4_1_plus,
ctx,
issues,
);
}
}
for (ei, err) in vast.children_named("Error").enumerate() {
if !err.text.is_empty() {
scan_text(
&err.text,
&format!("/VAST/Error[{}]", ei),
err,
v4_1_plus,
true,
false,
ctx,
issues,
);
}
}
}
fn walk(
node: &Node,
path: &str,
v4_1_plus: bool,
ctx: &ValidationContext,
issues: &mut Vec<Issue>,
) {
if URL_ELEMENTS.contains(&node.name.as_str()) && !node.text.is_empty() {
let is_error_ctx = node.name == "Error";
let is_verif_ctx =
node.name == "Tracking" && node.attr("event") == Some("verificationNotExecuted");
scan_text(
&node.text,
path,
node,
v4_1_plus,
is_error_ctx,
is_verif_ctx,
ctx,
issues,
);
}
for (i, child) in node.children.iter().enumerate() {
walk(
child,
&format!("{}/{}[{}]", path, child.name, i),
v4_1_plus,
ctx,
issues,
);
}
}
const MAX_MACRO_LEN: usize = 48;
fn is_macro_token(token: &[u8]) -> bool {
!token.is_empty()
&& token
.iter()
.all(|c| c.is_ascii_alphanumeric() || *c == b'_')
&& token.iter().any(|c| c.is_ascii_alphabetic())
}
#[allow(clippy::too_many_arguments)]
fn scan_text(
text: &str,
path: &str,
node: &Node,
v4_1_plus: bool,
is_error_ctx: bool,
is_verif_ctx: bool,
ctx: &ValidationContext,
issues: &mut Vec<Issue>,
) {
let mut has_macro = false;
let mut unknown = false;
let mut lowercase = false;
let mut deprecated = false;
let mut wrong_context = false;
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'[' {
i += 1;
continue;
}
let limit = (i + 1 + MAX_MACRO_LEN + 1).min(bytes.len());
let close = bytes[i + 1..limit].iter().position(|&c| c == b']');
let Some(rel) = close else {
i += 1;
continue;
};
let token_bytes = &bytes[i + 1..i + 1 + rel];
if !is_macro_token(token_bytes) {
i += 1;
continue;
}
has_macro = true;
let token = std::str::from_utf8(token_bytes).unwrap_or("");
let upper = token.to_ascii_uppercase();
if !KNOWN_MACROS.contains(upper.as_str()) {
unknown = true;
} else if token != upper {
lowercase = true;
} else {
if v4_1_plus && (upper == "CONTENTPLAYHEAD" || upper == "MEDIAPLAYHEAD") {
deprecated = true;
}
if upper == "ERRORCODE" && !is_error_ctx {
wrong_context = true;
}
if upper == "REASON" && !is_verif_ctx {
wrong_context = true;
}
}
i += 1 + rel + 1;
}
if unknown {
emit(
ctx,
issues,
"VAST-2.0-macro-unknown",
Severity::Warning,
"URL contains a [MACRO] that is not a recognised IAB VAST macro — check for a typo, or disable this rule if it is a vendor-specific macro",
Some(path.to_owned()),
"IAB VAST 4.2 §6",
Some(node),
);
}
if lowercase {
emit(
ctx,
issues,
"VAST-2.0-macro-lowercase",
Severity::Warning,
"Recognised macro is not uppercase — players match macro names case-sensitively and will not substitute it",
Some(path.to_owned()),
"IAB VAST 4.2 §6",
Some(node),
);
}
if deprecated {
emit(
ctx,
issues,
"VAST-4.1-macro-deprecated",
Severity::Info,
"[CONTENTPLAYHEAD] and [MEDIAPLAYHEAD] are deprecated as of VAST 4.1 — use [ADPLAYHEAD]",
Some(path.to_owned()),
"IAB VAST 4.1 §6",
Some(node),
);
}
if wrong_context {
emit(
ctx,
issues,
"VAST-2.0-macro-wrong-context",
Severity::Info,
"Context-restricted macro used where it has no defined value ([ERRORCODE] only in <Error>, [REASON] only in verificationNotExecuted tracking)",
Some(path.to_owned()),
"IAB VAST 4.2 §6",
Some(node),
);
}
if has_macro && text.chars().any(is_illegal_uri_char) {
emit(
ctx,
issues,
"VAST-2.0-macro-uri-unencoded",
Severity::Warning,
"Macro-bearing URL contains characters that must be percent-encoded per RFC 3986 — substitution may produce a malformed URL",
Some(path.to_owned()),
"RFC 3986 §2.1",
Some(node),
);
}
}