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 readonly() -> Self {
108 SafetyProfile {
109 readonly: true,
110 ..SafetyProfile::default()
111 }
112 }
113
114 pub fn new(
119 readonly: bool,
120 denied_services: impl IntoIterator<Item = String>,
121 denied_verbs: impl IntoIterator<Item = String>,
122 ) -> Self {
123 SafetyProfile {
124 readonly,
125 denied_services: denied_services
126 .into_iter()
127 .map(|s| s.trim().to_ascii_lowercase())
128 .collect(),
129 denied_verbs: denied_verbs
130 .into_iter()
131 .map(|v| v.trim().to_ascii_uppercase())
132 .collect(),
133 }
134 }
135
136 pub fn from_args(args: &SafetyArgs) -> Self {
138 SafetyProfile::new(
139 args.readonly,
140 args.deny_service.iter().cloned(),
141 args.deny_verb.iter().cloned(),
142 )
143 }
144
145 pub(crate) fn check(&self, service_name: &str, method: &Method) -> Result<(), String> {
151 if !self.readonly && self.denied_services.is_empty() && self.denied_verbs.is_empty() {
155 return Ok(());
156 }
157 let id = format!("{}.{}", service_name, method.id);
161 let verb = method.http_method.to_ascii_uppercase();
162
163 if self.readonly && WRITE_VERBS.contains(&verb.as_str()) {
164 return Err(format!(
165 "`{id}` is a {verb} (write); --readonly refuses writes. \
166 Rerun without --readonly to allow writes."
167 ));
168 }
169 if self
170 .denied_services
171 .iter()
172 .any(|s| s.eq_ignore_ascii_case(service_name))
173 {
174 return Err(format!(
175 "`{id}` belongs to service `{service_name}`, which --deny-service refuses. \
176 Rerun without --deny-service {service_name} to allow it."
177 ));
178 }
179 if self.denied_verbs.contains(&verb) {
180 return Err(format!(
181 "`{id}` is a {verb}; --deny-verb {verb} refuses it. \
182 Rerun without --deny-verb {verb} to allow it."
183 ));
184 }
185 Ok(())
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::discovery;
193
194 #[test]
195 fn readonly_refuses_every_write_verb_and_allows_reads() {
196 let profile = SafetyProfile::readonly();
197
198 let (service, get) = discovery::resolve("gmail.users.messages.list").unwrap();
199 assert!(profile.check(&service.name, get).is_ok());
200
201 for id in [
202 "gmail.users.messages.send", "gmail.users.messages.trash", "gmail.users.labels.delete", ] {
206 let (service, method) = discovery::resolve(id).unwrap();
207 let err = profile.check(&service.name, method).unwrap_err();
208 assert!(err.contains("--readonly"), "{id}: {err}");
209 assert!(err.contains("Rerun without --readonly"), "{id}: {err}");
210 assert!(err.contains(&method.http_method), "{id}: {err}");
211 assert!(err.contains(id), "{id}: {err}");
212 }
213 }
214
215 #[test]
216 fn deny_service_refuses_only_that_service() {
217 let profile = SafetyProfile::new(false, ["gmail".to_owned()], []);
218
219 let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
220 let err = profile.check(&service.name, method).unwrap_err();
221 assert!(err.contains("gmail"), "{err}");
222 assert!(err.contains("--deny-service"), "{err}");
223
224 let (service, method) = discovery::resolve("drive.files.list").unwrap();
226 assert!(profile.check(&service.name, method).is_ok());
227 }
228
229 #[test]
230 fn deny_service_matches_case_insensitively() {
231 let profile = SafetyProfile::new(false, ["Gmail ".to_owned()], []);
232 let (service, method) = discovery::resolve("gmail.users.getProfile").unwrap();
233 assert!(profile.check(&service.name, method).is_err());
234 }
235
236 #[test]
237 fn deny_verb_refuses_only_that_verb() {
238 let profile = SafetyProfile::new(false, [], ["DELETE".to_owned()]);
239
240 let (service, method) = discovery::resolve("gmail.users.labels.delete").unwrap();
241 let err = profile.check(&service.name, method).unwrap_err();
242 assert!(err.contains("--deny-verb DELETE"), "{err}");
243
244 let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
246 assert!(profile.check(&service.name, method).is_ok());
247 let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
248 assert!(profile.check(&service.name, method).is_ok());
249 }
250
251 #[test]
252 fn deny_verb_is_normalized_to_upper_case() {
253 let profile = SafetyProfile::new(false, [], [" post ".to_owned()]);
254 let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
255 assert!(profile.check(&service.name, method).is_err());
256 }
257
258 #[test]
259 fn readonly_takes_precedence_over_the_other_rules() {
260 let profile = SafetyProfile::new(true, ["gmail".to_owned()], []);
263 let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
264 let err = profile.check(&service.name, method).unwrap_err();
265 assert!(err.contains("--readonly"), "{err}");
266 assert!(!err.contains("--deny-service"), "{err}");
267 }
268
269 #[test]
270 fn permissive_is_free_of_every_restriction() {
271 for id in ["gmail.users.messages.send", "gmail.users.labels.delete"] {
272 let (service, method) = discovery::resolve(id).unwrap();
273 assert!(
274 SafetyProfile::PERMISSIVE
275 .check(&service.name, method)
276 .is_ok()
277 );
278 }
279 }
280
281 #[test]
282 fn from_args_matches_new_directly() {
283 let args = SafetyArgs {
284 readonly: true,
285 deny_service: vec![" Gmail ".into()],
286 deny_verb: vec!["post".into()],
287 };
288 let from_flags = SafetyProfile::from_args(&args);
289 let direct = SafetyProfile::new(true, [" Gmail ".into()], ["post".into()]);
290 assert_eq!(from_flags.readonly, direct.readonly);
291 assert_eq!(from_flags.denied_services, direct.denied_services);
292 assert_eq!(from_flags.denied_verbs, direct.denied_verbs);
293 }
294}