Skip to main content

rtblint_resolve/
lib.rs

1//! Opt-in sellers.json / ads.txt resolution against a local cache.
2//!
3//! The OpenRTB core stays a pure function of the payload. This crate reads a
4//! directory of already-fetched sellers.json, ads.txt, and app-ads.txt files
5//! and reports whether each SupplyChain hop and the publisher's authorization
6//! file agree with the request.
7
8mod parse;
9
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use serde_json::{Map, Value};
14
15use rtblint_core::{Issue, Severity, ValidationResult};
16
17use parse::{parse_ads_txt, parse_sellers_json, AdsTxt, SellersJson};
18
19/// On-disk reference database. Layout:
20///
21/// ```text
22/// <root>/sellers/<asi>/sellers.json
23/// <root>/ads/<site.domain>/ads.txt
24/// <root>/app-ads/<app.bundle>/app-ads.txt
25/// ```
26///
27/// Files are read on demand. Nothing is fetched from the network.
28#[derive(Debug, Clone)]
29pub struct Cache {
30    root: PathBuf,
31}
32
33impl Cache {
34    /// Opens an existing cache directory. Does not read files until a lookup.
35    pub fn open(root: impl Into<PathBuf>) -> Result<Self, String> {
36        let root = root.into();
37        if !root.is_dir() {
38            return Err(format!(
39                "supply-chain cache {} is not a directory",
40                root.display()
41            ));
42        }
43        Ok(Self { root })
44    }
45
46    fn sellers_json(&self, asi: &str) -> Lookup<SellersJson> {
47        let Some(key) = cache_key(asi) else {
48            return Lookup::Missing;
49        };
50        read_parsed(
51            self.root.join("sellers").join(key).join("sellers.json"),
52            |raw| parse_sellers_json(raw).map_err(|_| ()),
53        )
54    }
55
56    fn ads_txt(&self, domain: &str) -> Lookup<AdsTxt> {
57        lookup_ads_txt(&self.root.join("ads"), domain, "ads.txt")
58    }
59
60    fn app_ads_txt(&self, bundle: &str) -> Lookup<AdsTxt> {
61        let Some(key) = cache_key(bundle) else {
62            return Lookup::Missing;
63        };
64        read_parsed(
65            self.root.join("app-ads").join(key).join("app-ads.txt"),
66            |raw| Ok(parse_ads_txt(raw)),
67        )
68    }
69}
70
71enum Lookup<T> {
72    Missing,
73    Unparseable,
74    Present(T),
75}
76
77fn lookup_ads_txt(dir: &Path, domain: &str, file_name: &str) -> Lookup<AdsTxt> {
78    let mut keys = Vec::new();
79    if let Some(exact) = cache_key(domain) {
80        keys.push(exact.clone());
81        if let Some(stripped) = exact.strip_prefix("www.") {
82            if !stripped.is_empty() {
83                keys.push(String::from(stripped));
84            }
85        }
86    }
87    let mut saw_unparseable = false;
88    for key in keys {
89        match read_parsed(dir.join(key).join(file_name), |raw| Ok(parse_ads_txt(raw))) {
90            Lookup::Present(parsed) => return Lookup::Present(parsed),
91            Lookup::Unparseable => saw_unparseable = true,
92            Lookup::Missing => {}
93        }
94    }
95    if saw_unparseable {
96        Lookup::Unparseable
97    } else {
98        Lookup::Missing
99    }
100}
101
102fn read_parsed<T>(path: PathBuf, parse: impl FnOnce(&str) -> Result<T, ()>) -> Lookup<T> {
103    match fs::read_to_string(&path) {
104        Err(_) => Lookup::Missing,
105        Ok(raw) => match parse(&raw) {
106            Ok(parsed) => Lookup::Present(parsed),
107            Err(()) => Lookup::Unparseable,
108        },
109    }
110}
111
112/// Directory names are the lookup key (asi, site.domain, app.bundle). Reject
113/// anything that could walk out of the cache root.
114fn cache_key(raw: &str) -> Option<String> {
115    let trimmed = raw.trim();
116    if trimmed.is_empty() || trimmed == "." || trimmed == ".." {
117        return None;
118    }
119    if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains('\0') {
120        return None;
121    }
122    Some(trimmed.to_ascii_lowercase())
123}
124
125/// Appends resolution findings onto a core result and clears `valid` when any
126/// of them are errors.
127pub fn merge_into(result: &mut ValidationResult, extra: Vec<Issue>) {
128    if extra.iter().any(|issue| issue.severity == Severity::Error) {
129        result.valid = false;
130    }
131    result.issues.extend(extra);
132}
133
134/// Walks SupplyChain hops and the publisher's ads.txt / app-ads.txt against
135/// `cache`. Unknown JSON yields no findings; the core already reported that.
136pub fn resolve_bid_request(input: &str, cache: &Cache) -> Vec<Issue> {
137    let Ok(value) = serde_json::from_str::<Value>(input) else {
138        return Vec::new();
139    };
140    let (request, path_prefix) = request_view(&value);
141    let Some(request) = request.as_object() else {
142        return Vec::new();
143    };
144
145    let mut issues = Vec::new();
146    let nodes = supply_chain_nodes(request, path_prefix);
147    for node in &nodes {
148        check_sellers_json(cache, node, &mut issues);
149    }
150
151    if let Some((domain, domain_path)) = publisher_domain(request, path_prefix) {
152        check_authorization_file(
153            cache.ads_txt(&domain),
154            &nodes,
155            &domain_path,
156            "openrtb.resolve.ads_txt_unavailable",
157            "openrtb.resolve.ads_txt_unauthorized",
158            "ads.txt",
159            &mut issues,
160        );
161    }
162
163    if let Some((bundle, bundle_path)) = app_bundle(request, path_prefix) {
164        check_authorization_file(
165            cache.app_ads_txt(&bundle),
166            &nodes,
167            &bundle_path,
168            "openrtb.resolve.app_ads_txt_unavailable",
169            "openrtb.resolve.app_ads_txt_unauthorized",
170            "app-ads.txt",
171            &mut issues,
172        );
173    }
174
175    issues
176}
177
178struct ChainNode {
179    asi: String,
180    sid: String,
181    payment: bool,
182    sid_path: String,
183    asi_path: String,
184}
185
186fn request_view(value: &Value) -> (&Value, &str) {
187    if let Some(request) = value
188        .get("openrtb")
189        .and_then(Value::as_object)
190        .and_then(|envelope| envelope.get("request"))
191    {
192        // concat so the finding-id scraper does not treat this as a rule id.
193        (request, concat!("openrtb", ".request"))
194    } else {
195        (value, "")
196    }
197}
198
199fn json_path(prefix: &str, tail: &str) -> String {
200    if prefix.is_empty() {
201        String::from(tail)
202    } else {
203        format!("{prefix}.{tail}")
204    }
205}
206
207fn supply_chain_nodes(request: &Map<String, Value>, prefix: &str) -> Vec<ChainNode> {
208    let Some(source) = request.get("source").and_then(Value::as_object) else {
209        return Vec::new();
210    };
211
212    let schain_path;
213    let schain = if let Some(schain) = source.get("schain").and_then(Value::as_object) {
214        schain_path = json_path(prefix, "source.schain");
215        schain
216    } else if let Some(schain) = source
217        .get("ext")
218        .and_then(Value::as_object)
219        .and_then(|ext| ext.get("schain"))
220        .and_then(Value::as_object)
221    {
222        schain_path = json_path(prefix, "source.ext.schain");
223        schain
224    } else {
225        return Vec::new();
226    };
227
228    let Some(nodes) = schain.get("nodes").and_then(Value::as_array) else {
229        return Vec::new();
230    };
231
232    nodes
233        .iter()
234        .enumerate()
235        .filter_map(|(index, node)| {
236            let object = node.as_object()?;
237            let asi = string_field(object, "asi")?;
238            let sid = string_field(object, "sid")?;
239            if asi.is_empty() || sid.is_empty() {
240                return None;
241            }
242            let payment = !matches!(object.get("hp").and_then(integer_value), Some(0));
243            Some(ChainNode {
244                asi,
245                sid,
246                payment,
247                sid_path: format!("{}.nodes[{}].sid", schain_path, index),
248                asi_path: format!("{}.nodes[{}].asi", schain_path, index),
249            })
250        })
251        .collect()
252}
253
254fn publisher_domain(request: &Map<String, Value>, prefix: &str) -> Option<(String, String)> {
255    let site = request.get("site").and_then(Value::as_object).or_else(|| {
256        request
257            .get("context")
258            .and_then(Value::as_object)
259            .and_then(|context| context.get("site"))
260            .and_then(Value::as_object)
261    })?;
262    let domain = string_field(site, "domain")?;
263    if domain.is_empty() {
264        return None;
265    }
266    let path = if request.get("context").is_some() && request.get("site").is_none() {
267        json_path(prefix, "context.site.domain")
268    } else {
269        json_path(prefix, "site.domain")
270    };
271    Some((domain, path))
272}
273
274fn app_bundle(request: &Map<String, Value>, prefix: &str) -> Option<(String, String)> {
275    let app = request.get("app").and_then(Value::as_object).or_else(|| {
276        request
277            .get("context")
278            .and_then(Value::as_object)
279            .and_then(|context| context.get("app"))
280            .and_then(Value::as_object)
281    })?;
282    let bundle = string_field(app, "bundle")?;
283    if bundle.is_empty() {
284        return None;
285    }
286    let path = if request.get("context").is_some() && request.get("app").is_none() {
287        json_path(prefix, "context.app.bundle")
288    } else {
289        json_path(prefix, "app.bundle")
290    };
291    Some((bundle, path))
292}
293
294fn check_sellers_json(cache: &Cache, node: &ChainNode, issues: &mut Vec<Issue>) {
295    if !node.payment {
296        return;
297    }
298    match cache.sellers_json(&node.asi) {
299        Lookup::Missing => issues.push(issue(
300            "openrtb.resolve.sellers_json_unavailable",
301            Severity::Warning,
302            format!(
303                "No sellers.json for {} is in the cache; the hop cannot be authorised.",
304                node.asi
305            ),
306            node.asi_path.clone(),
307        )),
308        Lookup::Unparseable => issues.push(issue(
309            "openrtb.resolve.sellers_json_unparseable",
310            Severity::Warning,
311            format!(
312                "The cached sellers.json for {} is not usable, so sid {} was not checked.",
313                node.asi, node.sid
314            ),
315            node.asi_path.clone(),
316        )),
317        Lookup::Present(sellers) => {
318            if !sellers.contains_sid(&node.sid) {
319                issues.push(issue(
320                    "openrtb.resolve.sid_not_in_sellers",
321                    Severity::Error,
322                    format!(
323                        "sid \"{}\" is not a seller_id in {}'s sellers.json.",
324                        node.sid, node.asi
325                    ),
326                    node.sid_path.clone(),
327                ));
328            }
329        }
330    }
331}
332
333fn check_authorization_file(
334    lookup: Lookup<AdsTxt>,
335    nodes: &[ChainNode],
336    publisher_path: &str,
337    unavailable_id: &'static str,
338    unauthorized_id: &'static str,
339    file_label: &str,
340    issues: &mut Vec<Issue>,
341) {
342    let Some(first) = nodes.iter().find(|node| node.payment) else {
343        return;
344    };
345
346    match lookup {
347        Lookup::Missing => issues.push(issue(
348            unavailable_id,
349            Severity::Warning,
350            format!("No {file_label} for this publisher is in the cache."),
351            String::from(publisher_path),
352        )),
353        Lookup::Unparseable => issues.push(issue(
354            unavailable_id,
355            Severity::Warning,
356            format!("The cached {file_label} for this publisher is not usable."),
357            String::from(publisher_path),
358        )),
359        Lookup::Present(file) => {
360            if !file.authorizes(&first.asi, &first.sid) {
361                issues.push(issue(
362                    unauthorized_id,
363                    Severity::Error,
364                    format!(
365                        "The first payment hop ({} / {}) is not listed as DIRECT or RESELLER in this publisher's {file_label}.",
366                        first.asi, first.sid
367                    ),
368                    first.sid_path.clone(),
369                ));
370            }
371        }
372    }
373}
374
375fn string_field(object: &Map<String, Value>, name: &str) -> Option<String> {
376    match object.get(name)? {
377        Value::String(value) => Some(value.clone()),
378        Value::Number(value) => Some(value.to_string()),
379        _ => None,
380    }
381}
382
383fn integer_value(value: &Value) -> Option<i64> {
384    value.as_i64().or_else(|| {
385        value
386            .as_u64()
387            .and_then(|integer| i64::try_from(integer).ok())
388    })
389}
390
391fn issue(id: &'static str, severity: Severity, message: String, path: String) -> Issue {
392    Issue::new(id, severity, message, Some(path))
393}