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, 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}