1mod 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#[derive(Debug, Clone)]
29pub struct Cache {
30 root: PathBuf,
31}
32
33impl Cache {
34 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
112fn 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
125pub 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
134pub 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 (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}