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, schain_path) =
213        if let Some(schain) = source.get("schain").and_then(Value::as_object) {
214            (schain, json_path(prefix, "source.schain"))
215        } else if let Some(schain) = source
216            .get("ext")
217            .and_then(Value::as_object)
218            .and_then(|ext| ext.get("schain"))
219            .and_then(Value::as_object)
220        {
221            (schain, json_path(prefix, "source.ext.schain"))
222        } else {
223            return Vec::new();
224        };
225
226    let Some(nodes) = schain.get("nodes").and_then(Value::as_array) else {
227        return Vec::new();
228    };
229
230    nodes
231        .iter()
232        .enumerate()
233        .filter_map(|(index, node)| {
234            let object = node.as_object()?;
235            let asi = string_field(object, "asi")?;
236            let sid = string_field(object, "sid")?;
237            if asi.is_empty() || sid.is_empty() {
238                return None;
239            }
240            let payment = !matches!(object.get("hp").and_then(integer_value), Some(0));
241            Some(ChainNode {
242                asi,
243                sid,
244                payment,
245                sid_path: format!("{schain_path}.nodes[{index}].sid"),
246                asi_path: format!("{schain_path}.nodes[{index}].asi"),
247            })
248        })
249        .collect()
250}
251
252fn publisher_domain(request: &Map<String, Value>, prefix: &str) -> Option<(String, String)> {
253    let site = request.get("site").and_then(Value::as_object).or_else(|| {
254        request
255            .get("context")
256            .and_then(Value::as_object)
257            .and_then(|context| context.get("site"))
258            .and_then(Value::as_object)
259    })?;
260    let domain = string_field(site, "domain")?;
261    if domain.is_empty() {
262        return None;
263    }
264    let path = if request.get("context").is_some() && request.get("site").is_none() {
265        json_path(prefix, "context.site.domain")
266    } else {
267        json_path(prefix, "site.domain")
268    };
269    Some((domain, path))
270}
271
272fn app_bundle(request: &Map<String, Value>, prefix: &str) -> Option<(String, String)> {
273    let app = request.get("app").and_then(Value::as_object).or_else(|| {
274        request
275            .get("context")
276            .and_then(Value::as_object)
277            .and_then(|context| context.get("app"))
278            .and_then(Value::as_object)
279    })?;
280    let bundle = string_field(app, "bundle")?;
281    if bundle.is_empty() {
282        return None;
283    }
284    let path = if request.get("context").is_some() && request.get("app").is_none() {
285        json_path(prefix, "context.app.bundle")
286    } else {
287        json_path(prefix, "app.bundle")
288    };
289    Some((bundle, path))
290}
291
292fn check_sellers_json(cache: &Cache, node: &ChainNode, issues: &mut Vec<Issue>) {
293    if !node.payment {
294        return;
295    }
296    match cache.sellers_json(&node.asi) {
297        Lookup::Missing => issues.push(issue(
298            "openrtb.resolve.sellers_json_unavailable",
299            Severity::Warning,
300            format!(
301                "No sellers.json for {} is in the cache; the hop cannot be authorised.",
302                node.asi
303            ),
304            node.asi_path.clone(),
305        )),
306        Lookup::Unparseable => issues.push(issue(
307            "openrtb.resolve.sellers_json_unparseable",
308            Severity::Warning,
309            format!(
310                "The cached sellers.json for {} is not usable, so sid {} was not checked.",
311                node.asi, node.sid
312            ),
313            node.asi_path.clone(),
314        )),
315        Lookup::Present(sellers) => {
316            if !sellers.contains_sid(&node.sid) {
317                issues.push(issue(
318                    "openrtb.resolve.sid_not_in_sellers",
319                    Severity::Error,
320                    format!(
321                        "sid \"{}\" is not a seller_id in {}'s sellers.json.",
322                        node.sid, node.asi
323                    ),
324                    node.sid_path.clone(),
325                ));
326            }
327        }
328    }
329}
330
331fn check_authorization_file(
332    lookup: Lookup<AdsTxt>,
333    nodes: &[ChainNode],
334    publisher_path: &str,
335    unavailable_id: &'static str,
336    unauthorized_id: &'static str,
337    file_label: &str,
338    issues: &mut Vec<Issue>,
339) {
340    let Some(first) = nodes.iter().find(|node| node.payment) else {
341        return;
342    };
343
344    match lookup {
345        Lookup::Missing => issues.push(issue(
346            unavailable_id,
347            Severity::Warning,
348            format!("No {file_label} for this publisher is in the cache."),
349            String::from(publisher_path),
350        )),
351        Lookup::Unparseable => issues.push(issue(
352            unavailable_id,
353            Severity::Warning,
354            format!("The cached {file_label} for this publisher is not usable."),
355            String::from(publisher_path),
356        )),
357        Lookup::Present(file) => {
358            if !file.authorizes(&first.asi, &first.sid) {
359                issues.push(issue(
360                    unauthorized_id,
361                    Severity::Error,
362                    format!(
363                        "The first payment hop ({} / {}) is not listed as DIRECT or RESELLER in this publisher's {file_label}.",
364                        first.asi, first.sid
365                    ),
366                    first.sid_path.clone(),
367                ));
368            }
369        }
370    }
371}
372
373fn string_field(object: &Map<String, Value>, name: &str) -> Option<String> {
374    match object.get(name)? {
375        Value::String(value) => Some(value.clone()),
376        Value::Number(value) => Some(value.to_string()),
377        _ => None,
378    }
379}
380
381fn integer_value(value: &Value) -> Option<i64> {
382    value.as_i64().or_else(|| {
383        value
384            .as_u64()
385            .and_then(|integer| i64::try_from(integer).ok())
386    })
387}
388
389fn issue(id: &'static str, severity: Severity, message: String, path: String) -> Issue {
390    Issue::new(id, severity, message, Some(path))
391}