1use crate::discovery::Method;
16use clap::Args;
17use std::collections::BTreeSet;
18
19pub(crate) const WRITE_VERBS: [&str; 4] = ["POST", "PATCH", "PUT", "DELETE"];
23
24pub(crate) fn is_write_verb(verb: &str) -> bool {
27 WRITE_VERBS.contains(&verb)
28}
29
30#[derive(Args, Debug, Clone, Default)]
35pub struct SafetyArgs {
36 #[arg(long)]
38 pub readonly: bool,
39
40 #[arg(long = "deny-service", value_name = "SERVICE")]
42 pub deny_service: Vec<String>,
43
44 #[arg(long = "deny-verb", value_name = "VERB")]
46 pub deny_verb: Vec<String>,
47}
48
49pub fn global_args() -> impl IntoIterator<Item = clap::Arg> {
58 use clap::Arg;
59 [
64 Arg::new("readonly")
65 .long("readonly")
66 .global(true)
67 .action(clap::ArgAction::SetTrue)
68 .help("Refuse every write method (POST/PATCH/PUT/DELETE); only reads run"),
69 Arg::new("deny_service")
70 .long("deny-service")
71 .global(true)
72 .value_name("SERVICE")
73 .action(clap::ArgAction::Append)
74 .help("Refuse every method of this service (repeatable)"),
75 Arg::new("deny_verb")
76 .long("deny-verb")
77 .global(true)
78 .value_name("VERB")
79 .action(clap::ArgAction::Append)
80 .help("Refuse this HTTP verb (repeatable)"),
81 ]
82}
83
84#[derive(Debug, Clone, Default)]
88pub struct SafetyProfile {
89 pub readonly: bool,
91 pub denied_services: BTreeSet<String>,
93 pub denied_verbs: BTreeSet<String>,
95}
96
97impl SafetyProfile {
98 pub const PERMISSIVE: SafetyProfile = SafetyProfile {
101 readonly: false,
102 denied_services: BTreeSet::new(),
103 denied_verbs: BTreeSet::new(),
104 };
105
106 pub fn new(
111 readonly: bool,
112 denied_services: impl IntoIterator<Item = String>,
113 denied_verbs: impl IntoIterator<Item = String>,
114 ) -> Self {
115 SafetyProfile {
116 readonly,
117 denied_services: denied_services
118 .into_iter()
119 .map(|s| s.trim().to_ascii_lowercase())
120 .collect(),
121 denied_verbs: denied_verbs
122 .into_iter()
123 .map(|v| v.trim().to_ascii_uppercase())
124 .collect(),
125 }
126 }
127
128 pub fn from_args(args: &SafetyArgs) -> Self {
130 SafetyProfile::new(
131 args.readonly,
132 args.deny_service.iter().cloned(),
133 args.deny_verb.iter().cloned(),
134 )
135 }
136
137 pub(crate) fn check(&self, service_name: &str, method: &Method) -> Result<(), String> {
143 if !self.readonly && self.denied_services.is_empty() && self.denied_verbs.is_empty() {
147 return Ok(());
148 }
149 let id = format!("{}.{}", service_name, method.id);
153 let verb = method.http_method.to_ascii_uppercase();
154
155 if self.readonly && WRITE_VERBS.contains(&verb.as_str()) {
156 return Err(format!(
157 "`{id}` is a {verb} (write); --readonly refuses writes. \
158 Rerun without --readonly to allow writes."
159 ));
160 }
161 if self
162 .denied_services
163 .iter()
164 .any(|s| s.eq_ignore_ascii_case(service_name))
165 {
166 return Err(format!(
167 "`{id}` belongs to service `{service_name}`, which --deny-service refuses. \
168 Rerun without --deny-service {service_name} to allow it."
169 ));
170 }
171 if self.denied_verbs.contains(&verb) {
172 return Err(format!(
173 "`{id}` is a {verb}; --deny-verb {verb} refuses it. \
174 Rerun without --deny-verb {verb} to allow it."
175 ));
176 }
177 Ok(())
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::discovery;
185
186 #[test]
187 fn readonly_refuses_every_write_verb_and_allows_reads() {
188 let profile = SafetyProfile::new(true, [], []);
189
190 let (service, get) = discovery::resolve("gmail.users.messages.list").unwrap();
191 assert!(profile.check(&service.name, get).is_ok());
192
193 for id in [
194 "gmail.users.messages.send", "gmail.users.messages.trash", "gmail.users.labels.delete", ] {
198 let (service, method) = discovery::resolve(id).unwrap();
199 let err = profile.check(&service.name, method).unwrap_err();
200 assert!(err.contains("--readonly"), "{id}: {err}");
201 assert!(err.contains("Rerun without --readonly"), "{id}: {err}");
202 assert!(err.contains(&method.http_method), "{id}: {err}");
203 assert!(err.contains(id), "{id}: {err}");
204 }
205 }
206
207 #[test]
208 fn deny_service_refuses_only_that_service() {
209 let profile = SafetyProfile::new(false, ["gmail".to_owned()], []);
210
211 let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
212 let err = profile.check(&service.name, method).unwrap_err();
213 assert!(err.contains("gmail"), "{err}");
214 assert!(err.contains("--deny-service"), "{err}");
215
216 let (service, method) = discovery::resolve("drive.files.list").unwrap();
218 assert!(profile.check(&service.name, method).is_ok());
219 }
220
221 #[test]
222 fn deny_service_matches_case_insensitively() {
223 let profile = SafetyProfile::new(false, ["Gmail ".to_owned()], []);
224 let (service, method) = discovery::resolve("gmail.users.getProfile").unwrap();
225 assert!(profile.check(&service.name, method).is_err());
226 }
227
228 #[test]
229 fn deny_verb_refuses_only_that_verb() {
230 let profile = SafetyProfile::new(false, [], ["DELETE".to_owned()]);
231
232 let (service, method) = discovery::resolve("gmail.users.labels.delete").unwrap();
233 let err = profile.check(&service.name, method).unwrap_err();
234 assert!(err.contains("--deny-verb DELETE"), "{err}");
235
236 let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
238 assert!(profile.check(&service.name, method).is_ok());
239 let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
240 assert!(profile.check(&service.name, method).is_ok());
241 }
242
243 #[test]
244 fn deny_verb_is_normalized_to_upper_case() {
245 let profile = SafetyProfile::new(false, [], [" post ".to_owned()]);
246 let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
247 assert!(profile.check(&service.name, method).is_err());
248 }
249
250 #[test]
251 fn readonly_takes_precedence_over_the_other_rules() {
252 let profile = SafetyProfile::new(true, ["gmail".to_owned()], []);
255 let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
256 let err = profile.check(&service.name, method).unwrap_err();
257 assert!(err.contains("--readonly"), "{err}");
258 assert!(!err.contains("--deny-service"), "{err}");
259 }
260
261 #[test]
262 fn permissive_is_free_of_every_restriction() {
263 for id in ["gmail.users.messages.send", "gmail.users.labels.delete"] {
264 let (service, method) = discovery::resolve(id).unwrap();
265 assert!(
266 SafetyProfile::PERMISSIVE
267 .check(&service.name, method)
268 .is_ok()
269 );
270 }
271 }
272
273 #[test]
274 fn from_args_matches_new_directly() {
275 let args = SafetyArgs {
276 readonly: true,
277 deny_service: vec![" Gmail ".into()],
278 deny_verb: vec!["post".into()],
279 };
280 let from_flags = SafetyProfile::from_args(&args);
281 let direct = SafetyProfile::new(true, [" Gmail ".into()], ["post".into()]);
282 assert_eq!(from_flags.readonly, direct.readonly);
283 assert_eq!(from_flags.denied_services, direct.denied_services);
284 assert_eq!(from_flags.denied_verbs, direct.denied_verbs);
285 }
286}