1use std::time::Duration;
2
3use sofie_core::{Error, Result};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
7pub(crate) enum Rule {
8 SignIn,
9 Account,
11 AccountTotal,
13 SignUp,
14 Scan,
15 Probe,
17 Refused,
18 Malformed,
19 Throttled,
20 Protocol,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub(crate) struct Limit {
26 pub(crate) count: u32,
27 pub(crate) window: Duration,
28}
29
30impl Rule {
31 pub(crate) const ALL: [Self; 10] = [
32 Self::SignIn,
33 Self::Account,
34 Self::AccountTotal,
35 Self::SignUp,
36 Self::Scan,
37 Self::Probe,
38 Self::Refused,
39 Self::Malformed,
40 Self::Throttled,
41 Self::Protocol,
42 ];
43
44 #[must_use]
45 pub(crate) fn name(self) -> &'static str {
46 match self {
47 Self::SignIn => "sign_in",
48 Self::Account => "account",
49 Self::AccountTotal => "account_total",
50 Self::SignUp => "sign_up",
51 Self::Scan => "scan",
52 Self::Probe => "probe",
53 Self::Refused => "refused",
54 Self::Malformed => "malformed",
55 Self::Throttled => "throttled",
56 Self::Protocol => "protocol",
57 }
58 }
59
60 pub(crate) fn reason(self) -> &'static str {
61 match self {
62 Self::SignIn => "failed sign-ins from one caller",
63 Self::Account => "failed sign-ins to one account from one caller",
64 Self::AccountTotal => "failed sign-ins to one account from every caller",
65 Self::SignUp => "a burst of sign-ups from one caller",
66 Self::Scan => "requests for paths no route serves",
67 Self::Probe => "records not found on matched routes",
68 Self::Refused => "cross-site, unsigned or unpermitted requests",
69 Self::Malformed => "malformed or oversized bodies",
70 Self::Throttled => "requests repeated over a rate limit",
71 Self::Protocol => "connections failed on protocol errors",
72 }
73 }
74
75 pub(crate) fn named(name: &str) -> Option<Self> {
76 Self::ALL.into_iter().find(|rule| rule.name() == name)
77 }
78
79 fn default_limit(self) -> Limit {
81 let (count, seconds) = match self {
82 Self::SignIn => (20, 600),
83 Self::Account => (10, 900),
84 Self::AccountTotal => (100, 900),
85 Self::SignUp => (10, 3600),
86 Self::Scan | Self::Throttled => (30, 60),
87 Self::Probe | Self::Malformed => (20, 60),
88 Self::Refused => (10, 60),
89 Self::Protocol => (5, 60),
90 };
91 Limit {
92 count,
93 window: Duration::from_secs(seconds),
94 }
95 }
96}
97
98#[derive(Clone, Debug, Default, PartialEq, Eq)]
100pub struct Thresholds {
101 counts: Vec<(Option<String>, Rule, u32)>,
102}
103
104impl Thresholds {
105 pub fn parse(text: &str) -> Result<Self> {
106 let counts = text
107 .split(',')
108 .map(str::trim)
109 .filter(|entry| !entry.is_empty())
110 .map(threshold)
111 .collect::<Result<_>>()?;
112 Ok(Self { counts })
113 }
114
115 pub(crate) fn limit(&self, area: Option<&str>, rule: Rule) -> Limit {
116 let set = |wanted: Option<&str>| {
117 self.counts
118 .iter()
119 .find(|(area, named, _)| *named == rule && area.as_deref() == wanted)
120 .map(|(_, _, count)| *count)
121 };
122 let default = rule.default_limit();
123 let count = area
124 .and_then(|area| set(Some(area)))
125 .or_else(|| set(None))
126 .unwrap_or(default.count);
127 Limit { count, ..default }
128 }
129
130 pub(crate) fn check_areas(&self, areas: &[&str]) -> Result<()> {
131 match self
132 .counts
133 .iter()
134 .filter_map(|(area, ..)| area.as_deref())
135 .find(|area| !areas.contains(area))
136 {
137 Some(unknown) => Err(Error::internal_message(format!(
138 "shield threshold for area `{unknown}`, which the app does not have"
139 ))),
140 None => Ok(()),
141 }
142 }
143}
144
145fn threshold(entry: &str) -> Result<(Option<String>, Rule, u32)> {
146 let refused = || {
147 Error::internal_message(format!(
148 "shield threshold `{entry}` is not `rule=count` or `area.rule=count` with a count above 0"
149 ))
150 };
151 let (name, count) = entry.split_once('=').ok_or_else(refused)?;
152 let count: u32 = count.trim().parse().map_err(|_| refused())?;
153 let (area, rule) = match name.trim().rsplit_once('.') {
154 Some((area, rule)) => (Some(area.to_owned()), rule),
155 None => (None, name.trim()),
156 };
157 let rule = Rule::named(rule).ok_or_else(refused)?;
158 if count == 0 {
159 return Err(refused());
160 }
161 Ok((area, rule, count))
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn an_area_count_beats_the_general_one_which_beats_the_default() {
170 let thresholds = Thresholds::parse("scan=60, member.scan=90, probe = 5").unwrap();
171
172 assert_eq!(thresholds.limit(Some("member"), Rule::Scan).count, 90);
173 assert_eq!(thresholds.limit(Some("web"), Rule::Scan).count, 60);
174 assert_eq!(thresholds.limit(None, Rule::Probe).count, 5);
175 assert_eq!(
176 thresholds.limit(None, Rule::SignIn),
177 Rule::SignIn.default_limit()
178 );
179 }
180
181 #[test]
182 fn a_malformed_entry_or_an_unknown_area_is_refused() {
183 for entry in ["scan", "scan=0", "scan=-1", "scanning=3", "web.=3"] {
184 let error = Thresholds::parse(entry).unwrap_err();
185 assert!(error.to_string().contains(entry), "{error}");
186 }
187 let thresholds = Thresholds::parse("console.probe=3").unwrap();
188
189 assert!(thresholds.check_areas(&["console"]).is_ok());
190 assert!(thresholds.check_areas(&["web"]).is_err());
191 }
192}