Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

## 0.14.3 (2026-10-20)

### Fixed

- Accept protobuf native request and response objects (`request_native` and `adm_native`, including their camelCase spellings) in `proto-json`. Validate native assets and boolean flags, reject conflicting oneof alternatives, and retain required-asset checks in paired validation.
- Warn when `site.domain` contains URL syntax or whitespace. Full page URLs belong in `site.page`.

## 0.14.2 (2026-10-20)

### Changed
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ exclude = ["fuzz"]
resolver = "2"

[workspace.package]
version = "0.14.2"
version = "0.14.3"
edition = "2021"
rust-version = "1.74"
authors = ["Aleks <aleks@vastlint.org>"]
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ cat request.json | rtblint validate --stdin
<dir>/app-ads/<app.bundle>/app-ads.txt
```

Protobuf JSON also supports the IAB schema's native object alternatives:
`imp.native.request_native` (`requestNative`) and `bid.adm_native`
(`admNative`). Their native flags use booleans. The string alternatives
`request` and `adm` keep their JSON-encoded Native Ads markup. Supply one
alternative per oneof. Native assets remain validated and paired against
required request assets. See the [IAB protobuf schema](https://github.com/IABTechLab/openrtb-proto-v2/blob/master/openrtb-core/src/main/protobuf/openrtb.proto).

Nothing is fetched; populate the cache yourself. `--batch` lints one JSON object per line from a file or stdin. `--summary` adds rule-frequency totals for a captured stream (`--summary bids.ndjson` for the histogram alone). See [ARTF](#artf) for `--type artf-request` and `--type artf-response`.

Exit codes: 0 valid, 1 validation errors, 2 usage or I/O error.
Expand Down
10 changes: 10 additions & 0 deletions crates/rtblint-core/src/dialect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ pub enum Dialect {
}

impl Dialect {
/// Object alternatives declared by the IAB protobuf native oneofs.
pub(crate) fn native_field(self, object: &str, field: &str) -> bool {
self == Self::ProtoJson
&& matches!(
(object, field),
("Native", "request_native" | "requestNative")
| ("Bid", "adm_native" | "admNative")
)
}

pub fn as_str(self) -> &'static str {
match self {
Self::SpecJson => "spec-json",
Expand Down
146 changes: 146 additions & 0 deletions crates/rtblint-core/src/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::{Issue, Severity};

const SECTION_REQUEST: &str = "Native Ads 1.2 §4";
const SECTION_RESPONSE: &str = "Native Ads 1.2 §5";
const SECTION_PROTO: &str = "https://github.com/IABTechLab/openrtb-proto-v2/blob/master/openrtb-core/src/main/protobuf/openrtb.proto";
const VENDOR_RANGE: i64 = 500;

const DATA_TYPES: &[i64] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
Expand All @@ -22,6 +23,151 @@ const EVENT_METHODS: &[i64] = &[1, 2];

const ASSET_KEYS: [&str; 4] = ["title", "img", "video", "data"];

/// IAB openrtb-proto-v2 openrtb.proto: Native.request_oneof and Bid.adm_oneof.
pub(crate) fn proto_alternative(
object: &Map<String, Value>,
request: bool,
) -> Option<(&'static str, &Value)> {
let names = if request {
["request_native", "requestNative"]
} else {
["adm_native", "admNative"]
};
names.into_iter().find_map(|name| {
object
.get(name)
.filter(|value| !value.is_null())
.map(|value| (name, value))
})
}

/// Native protobuf flags are bools. Normalize only those fields for the
/// shared semantic checks and the required-asset index. Extensions stay opaque.
pub(crate) fn normalize_proto_markup(root: &mut Map<String, Value>, request: bool) {
if request {
for field in ["aurlsupport", "durlsupport", "privacy"] {
normalize_proto_flag(root.get_mut(field));
}
}
if let Some(assets) = root.get_mut("assets").and_then(Value::as_array_mut) {
for asset in assets.iter_mut().filter_map(Value::as_object_mut) {
normalize_proto_flag(asset.get_mut("required"));
}
}
}

fn normalize_proto_flag(value: Option<&mut Value>) {
if let Some(value) = value {
if let Some(flag) = value.as_bool() {
*value = Value::from(i64::from(flag));
}
}
}

pub(crate) fn validate_proto_alternative(
object_name: &str,
object: &Map<String, Value>,
field: &str,
value: &Value,
path: &str,
require_asset_id: bool,
issues: &mut Vec<Issue>,
) {
if value.is_null() {
return;
}
let request = object_name == "Native";
let section = if request {
SECTION_REQUEST
} else {
SECTION_RESPONSE
};
let string_field = if request { "request" } else { "adm" };
let other_name = match field {
"request_native" => "requestNative",
"requestNative" => "request_native",
"adm_native" => "admNative",
_ => "adm_native",
};
if [string_field, other_name]
.iter()
.any(|name| object.get(*name).is_some_and(|other| !other.is_null()))
{
issues.push(issue(
"openrtb.dialect.oneof_conflict",
Severity::Error,
format!("The {string_field} protobuf oneof accepts only one alternative and one field spelling."),
path.to_owned(),
SECTION_PROTO,
));
}
let Some(mut root) = value.as_object().cloned() else {
issues.push(issue(
"openrtb.type.mismatch",
Severity::Error,
format!("{field} must be a protobuf native object."),
path.to_owned(),
section,
));
return;
};
if request {
for field in ["aurlsupport", "durlsupport", "privacy"] {
validate_proto_flag(root.get(field), &join_path(path, field), issues);
}
}
if let Some(assets) = root.get("assets").and_then(Value::as_array) {
for (index, asset) in assets.iter().enumerate() {
validate_proto_flag(
asset.get("required"),
&format!("{path}.assets[{index}].required"),
issues,
);
}
}
normalize_proto_markup(&mut root, request);
if request {
let ver = root
.get("ver")
.and_then(Value::as_str)
.or_else(|| object.get("ver").and_then(Value::as_str));
validate_markup_request(&root, path, ver, require_asset_id, issues);
} else {
validate_markup_response(&root, path, issues);
if object
.get("mtype")
.and_then(Value::as_i64)
.is_some_and(|mtype| mtype != 4)
{
issues.push(issue(
"openrtb.bid.adm.media_type_mismatch",
Severity::Error,
String::from(
"Protobuf native markup requires native mtype 4 when mtype is supplied.",
),
path.to_owned(),
section,
));
}
}
}

fn validate_proto_flag(value: Option<&Value>, path: &str, issues: &mut Vec<Issue>) {
if let Some(value) = value.filter(|value| !value.is_null() && !value.is_boolean()) {
issues.push(issue(
if value.is_number() {
"openrtb.dialect.integer_for_bool"
} else {
"openrtb.type.mismatch"
},
Severity::Error,
String::from("This native protobuf flag must be true or false in proto-json."),
path.to_owned(),
SECTION_PROTO,
));
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NativeAssetKind {
Title,
Expand Down
44 changes: 40 additions & 4 deletions crates/rtblint-core/src/pair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,15 @@ pub(crate) fn validate_bid_response_against_request(
return finalize_result(result.issues);
}

let context = RequestContext::index(profile, request);
cross_validate(version, profile, &context, response, &mut result.issues);
let context = RequestContext::index(dialect, profile, request);
cross_validate(
version,
dialect,
profile,
&context,
response,
&mut result.issues,
);
crate::profile::push_profile_pair(profile, request, response, &mut result.issues);
finalize_result(result.issues)
}
Expand Down Expand Up @@ -111,7 +118,7 @@ struct ImpContext<'a> {
}

impl<'a> RequestContext<'a> {
fn index(profile: Profile, request: &'a Map<String, Value>) -> Self {
fn index(dialect: Dialect, profile: Profile, request: &'a Map<String, Value>) -> Self {
let mut imps = HashMap::new();
for imp in request
.get("imp")
Expand All @@ -137,7 +144,19 @@ impl<'a> RequestContext<'a> {

let native_assets = imp
.get("native")
.and_then(|value| crate::profile::compat::native_request(profile, value))
.and_then(|value| {
if dialect == Dialect::ProtoJson {
if let Some((_, source)) = value
.as_object()
.and_then(|object| native::proto_alternative(object, true))
{
let mut root = source.as_object()?.clone();
native::normalize_proto_markup(&mut root, true);
return Some(root);
}
}
crate::profile::compat::native_request(profile, value)
})
.map(|request| native::index_markup_request(&request))
.unwrap_or_default();

Expand Down Expand Up @@ -179,6 +198,7 @@ fn string_list(value: Option<&Value>) -> Option<Vec<&str>> {
/// Pass two: cross-checks over the already-validated response.
fn cross_validate(
version: OpenRtbVersion,
dialect: Dialect,
profile: Profile,
context: &RequestContext<'_>,
response: &Map<String, Value>,
Expand Down Expand Up @@ -273,6 +293,7 @@ fn cross_validate(
};
let bid_path = format!("seatbid[{seatbid_index}].bid[{bid_index}]");
cross_validate_bid(
dialect,
profile,
context,
bid,
Expand All @@ -285,6 +306,7 @@ fn cross_validate(
}

fn cross_validate_bid(
dialect: Dialect,
profile: Profile,
context: &RequestContext<'_>,
bid: &Map<String, Value>,
Expand All @@ -310,6 +332,19 @@ fn cross_validate_bid(
return;
};

if dialect == Dialect::ProtoJson && imp.native {
if let Some((field, source)) = native::proto_alternative(bid, false) {
if let Some(response) = source.as_object() {
native::validate_response_against_request(
&imp.native_assets,
response,
&format!("{bid_path}.{field}"),
issues,
);
}
}
}

if let Some(mtype) = bid.get("mtype").and_then(integer_value) {
let (offered, media_label) = match mtype {
1 => (imp.banner, "banner"),
Expand Down Expand Up @@ -389,6 +424,7 @@ fn cross_validate_bid(
.and_then(Value::as_str)
.is_some_and(|markup| !markup.trim().is_empty())
&& crate::profile::compat::adm_native(profile)
&& !(dialect == Dialect::ProtoJson && native::proto_alternative(bid, false).is_some())
{
if let Some(response) = crate::profile::compat::native_bid(profile, bid) {
native::validate_response_against_request(
Expand Down
Loading
Loading