use crate::discovery::Method;
use clap::Args;
use std::collections::BTreeSet;
pub(crate) const WRITE_VERBS: [&str; 4] = ["POST", "PATCH", "PUT", "DELETE"];
pub(crate) fn is_write_verb(verb: &str) -> bool {
WRITE_VERBS.contains(&verb)
}
#[derive(Args, Debug, Clone, Default)]
pub struct SafetyArgs {
#[arg(long)]
pub readonly: bool,
#[arg(long = "deny-service", value_name = "SERVICE")]
pub deny_service: Vec<String>,
#[arg(long = "deny-verb", value_name = "VERB")]
pub deny_verb: Vec<String>,
}
pub fn global_args() -> impl IntoIterator<Item = clap::Arg> {
use clap::Arg;
[
Arg::new("readonly")
.long("readonly")
.global(true)
.action(clap::ArgAction::SetTrue)
.help("Refuse every write method (POST/PATCH/PUT/DELETE); only reads run"),
Arg::new("deny_service")
.long("deny-service")
.global(true)
.value_name("SERVICE")
.action(clap::ArgAction::Append)
.help("Refuse every method of this service (repeatable)"),
Arg::new("deny_verb")
.long("deny-verb")
.global(true)
.value_name("VERB")
.action(clap::ArgAction::Append)
.help("Refuse this HTTP verb (repeatable)"),
]
}
#[derive(Debug, Clone, Default)]
pub struct SafetyProfile {
pub readonly: bool,
pub denied_services: BTreeSet<String>,
pub denied_verbs: BTreeSet<String>,
}
impl SafetyProfile {
pub const PERMISSIVE: SafetyProfile = SafetyProfile {
readonly: false,
denied_services: BTreeSet::new(),
denied_verbs: BTreeSet::new(),
};
pub fn new(
readonly: bool,
denied_services: impl IntoIterator<Item = String>,
denied_verbs: impl IntoIterator<Item = String>,
) -> Self {
SafetyProfile {
readonly,
denied_services: denied_services
.into_iter()
.map(|s| s.trim().to_ascii_lowercase())
.collect(),
denied_verbs: denied_verbs
.into_iter()
.map(|v| v.trim().to_ascii_uppercase())
.collect(),
}
}
pub fn from_args(args: &SafetyArgs) -> Self {
SafetyProfile::new(
args.readonly,
args.deny_service.iter().cloned(),
args.deny_verb.iter().cloned(),
)
}
pub(crate) fn check(&self, service_name: &str, method: &Method) -> Result<(), String> {
if !self.readonly && self.denied_services.is_empty() && self.denied_verbs.is_empty() {
return Ok(());
}
let id = format!("{}.{}", service_name, method.id);
let verb = method.http_method.to_ascii_uppercase();
if self.readonly && WRITE_VERBS.contains(&verb.as_str()) {
return Err(format!(
"`{id}` is a {verb} (write); --readonly refuses writes. \
Rerun without --readonly to allow writes."
));
}
if self
.denied_services
.iter()
.any(|s| s.eq_ignore_ascii_case(service_name))
{
return Err(format!(
"`{id}` belongs to service `{service_name}`, which --deny-service refuses. \
Rerun without --deny-service {service_name} to allow it."
));
}
if self.denied_verbs.contains(&verb) {
return Err(format!(
"`{id}` is a {verb}; --deny-verb {verb} refuses it. \
Rerun without --deny-verb {verb} to allow it."
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery;
#[test]
fn readonly_refuses_every_write_verb_and_allows_reads() {
let profile = SafetyProfile::new(true, [], []);
let (service, get) = discovery::resolve("gmail.users.messages.list").unwrap();
assert!(profile.check(&service.name, get).is_ok());
for id in [
"gmail.users.messages.send", "gmail.users.messages.trash", "gmail.users.labels.delete", ] {
let (service, method) = discovery::resolve(id).unwrap();
let err = profile.check(&service.name, method).unwrap_err();
assert!(err.contains("--readonly"), "{id}: {err}");
assert!(err.contains("Rerun without --readonly"), "{id}: {err}");
assert!(err.contains(&method.http_method), "{id}: {err}");
assert!(err.contains(id), "{id}: {err}");
}
}
#[test]
fn deny_service_refuses_only_that_service() {
let profile = SafetyProfile::new(false, ["gmail".to_owned()], []);
let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
let err = profile.check(&service.name, method).unwrap_err();
assert!(err.contains("gmail"), "{err}");
assert!(err.contains("--deny-service"), "{err}");
let (service, method) = discovery::resolve("drive.files.list").unwrap();
assert!(profile.check(&service.name, method).is_ok());
}
#[test]
fn deny_service_matches_case_insensitively() {
let profile = SafetyProfile::new(false, ["Gmail ".to_owned()], []);
let (service, method) = discovery::resolve("gmail.users.getProfile").unwrap();
assert!(profile.check(&service.name, method).is_err());
}
#[test]
fn deny_verb_refuses_only_that_verb() {
let profile = SafetyProfile::new(false, [], ["DELETE".to_owned()]);
let (service, method) = discovery::resolve("gmail.users.labels.delete").unwrap();
let err = profile.check(&service.name, method).unwrap_err();
assert!(err.contains("--deny-verb DELETE"), "{err}");
let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
assert!(profile.check(&service.name, method).is_ok());
let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
assert!(profile.check(&service.name, method).is_ok());
}
#[test]
fn deny_verb_is_normalized_to_upper_case() {
let profile = SafetyProfile::new(false, [], [" post ".to_owned()]);
let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
assert!(profile.check(&service.name, method).is_err());
}
#[test]
fn readonly_takes_precedence_over_the_other_rules() {
let profile = SafetyProfile::new(true, ["gmail".to_owned()], []);
let (service, method) = discovery::resolve("gmail.users.messages.send").unwrap();
let err = profile.check(&service.name, method).unwrap_err();
assert!(err.contains("--readonly"), "{err}");
assert!(!err.contains("--deny-service"), "{err}");
}
#[test]
fn permissive_is_free_of_every_restriction() {
for id in ["gmail.users.messages.send", "gmail.users.labels.delete"] {
let (service, method) = discovery::resolve(id).unwrap();
assert!(
SafetyProfile::PERMISSIVE
.check(&service.name, method)
.is_ok()
);
}
}
#[test]
fn from_args_matches_new_directly() {
let args = SafetyArgs {
readonly: true,
deny_service: vec![" Gmail ".into()],
deny_verb: vec!["post".into()],
};
let from_flags = SafetyProfile::from_args(&args);
let direct = SafetyProfile::new(true, [" Gmail ".into()], ["post".into()]);
assert_eq!(from_flags.readonly, direct.readonly);
assert_eq!(from_flags.denied_services, direct.denied_services);
assert_eq!(from_flags.denied_verbs, direct.denied_verbs);
}
}