1use std::time::Duration;
2
3use sofie_core::{Error, Result};
4
5use crate::target::{Target, normalized_host};
6
7const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
8const TOTAL_TIMEOUT: Duration = Duration::from_secs(30);
9const MAX_RESPONSE_BYTES: u64 = 4 * 1024 * 1024;
10const RETRIES: u32 = 2;
11
12#[derive(Debug, Default)]
14pub struct Vendor {
15 pub base_url: Option<String>,
16 pub hosts: Option<String>,
17 pub private: Option<bool>,
19 pub connect_timeout_ms: Option<u64>,
20 pub timeout_ms: Option<u64>,
21 pub max_response_bytes: Option<u64>,
22 pub retries: Option<u32>,
23 pub redirects: Option<u32>,
24}
25
26pub(crate) struct Allowed {
27 pub(crate) hosts: Vec<String>,
29 pub(crate) private: bool,
30}
31
32impl Allowed {
33 pub(crate) fn host(&self, host: &str) -> bool {
34 self.hosts.iter().any(|allowed| allowed == host)
35 }
36}
37
38#[derive(Clone, Copy, Debug)]
39pub(crate) struct Limits {
40 pub(crate) connect: Duration,
41 pub(crate) total: Duration,
42 pub(crate) response_bytes: usize,
43 pub(crate) retries: u32,
44 pub(crate) redirects: u32,
45}
46
47impl Vendor {
48 pub(crate) fn read(self, vendor: &str) -> Result<(Option<Target>, Allowed, Limits)> {
49 let private = self.private.unwrap_or(false);
50 let base = self
51 .base_url
52 .as_deref()
53 .map(|url| {
54 let base = Target::parse(url)?;
55 if base.path.query().is_some() {
56 return Err("it carries a query, which a path would be joined after".to_owned());
57 }
58 if !base.secure && !private {
59 return Err("it is not HTTPS, which only a PRIVATE vendor may skip".to_owned());
60 }
61 Ok(base)
62 })
63 .transpose()
64 .map_err(|problem| {
65 Error::internal_message(format!("{vendor}'s BASE_URL is refused: {problem}"))
66 })?;
67 let listed = self.hosts.as_deref().unwrap_or_default().split(',');
68 let mut hosts: Vec<String> = listed
69 .map(|host| normalized_host(host.trim()))
70 .filter(|host| !host.is_empty())
71 .collect();
72 hosts.extend(base.iter().map(|base| base.host.clone()));
73 let milliseconds =
74 |value: Option<u64>, default| value.map_or(default, Duration::from_millis);
75 let limits = Limits {
76 connect: milliseconds(self.connect_timeout_ms, CONNECT_TIMEOUT),
77 total: milliseconds(self.timeout_ms, TOTAL_TIMEOUT),
78 response_bytes: usize::try_from(self.max_response_bytes.unwrap_or(MAX_RESPONSE_BYTES))
79 .unwrap_or(usize::MAX),
80 retries: self.retries.unwrap_or(RETRIES),
81 redirects: self.redirects.unwrap_or(0),
82 };
83 let allowed = Allowed { hosts, private };
84 Ok((base, allowed, limits))
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 #[test]
93 fn a_base_url_with_a_query_is_refused() {
94 let settings = Vendor {
95 base_url: Some("https://api.vendor.test/v1?key=abc".to_owned()),
96 ..Vendor::default()
97 };
98
99 let Err(refused) = settings.read("vendor") else {
100 panic!("a base URL with a query is refused");
101 };
102
103 assert!(refused.to_string().contains("query"), "{refused}");
104 }
105
106 #[test]
107 fn a_plain_http_base_url_is_refused_unless_private() {
108 let settings = |private| Vendor {
109 base_url: Some("http://api.vendor.test/v1".to_owned()),
110 private,
111 ..Vendor::default()
112 };
113
114 let Err(refused) = settings(None).read("vendor") else {
115 panic!("a plain HTTP base URL is refused");
116 };
117
118 assert!(refused.to_string().contains("HTTPS"), "{refused}");
119 assert!(settings(Some(true)).read("vendor").is_ok());
120 }
121
122 #[test]
123 fn a_listed_ipv6_host_matches_with_or_without_brackets() {
124 let settings = Vendor {
125 hosts: Some("[::1], 2001:DB8::1".to_owned()),
126 ..Vendor::default()
127 };
128
129 let (_, allowed, _) = settings.read("vendor").unwrap();
130
131 assert!(allowed.host("::1"));
132 assert!(allowed.host("2001:db8::1"));
133 }
134}