diff --git a/rule-validator/src/lib.rs b/rule-validator/src/lib.rs index a7a9d5d48..8c9491eb9 100644 --- a/rule-validator/src/lib.rs +++ b/rule-validator/src/lib.rs @@ -1,3 +1,14 @@ +//! Laravel-style validation rules for game-server startup environment +//! variables ("egg" variables). Rule names follow Laravel 13.x conventions; +//! inputs from Pterodactyl (Laravel 10.x) and Pelican (Laravel 13.x) panels +//! are both supported. See for the +//! upstream rule reference. +//! +//! Two entry points: [`validate_rules`] checks that a slice of rule strings +//! parses without error (used as a `garde` custom validator on egg-variable +//! definitions), while [`Validator::new`] + [`Validator::validate`] runs the +//! full rule set against actual data. + use std::collections::HashMap; mod rules; diff --git a/rule-validator/src/rules.rs b/rule-validator/src/rules.rs deleted file mode 100644 index 2b1ddcc65..000000000 --- a/rule-validator/src/rules.rs +++ /dev/null @@ -1,1970 +0,0 @@ -use super::{ParseValidationRule, ValidateRule, Validator}; - -pub fn parse_validation_rule( - rule: &str, -) -> Result, compact_str::CompactString> { - let mut rule_parts = rule.splitn(2, ':'); - let rule_name = rule_parts.next().ok_or("invalid rule format".to_string())?; - let rule_args: Vec = rule_parts - .next() - .map(|args| { - args.split(',') - .map(compact_str::CompactString::from) - .collect() - }) - .unwrap_or_default(); - - match rule_name { - "accepted" => Accepted::parse_rule(&rule_args), - "accepted_if" => AcceptedIf::parse_rule(&rule_args), - "alpha" => Alpha::parse_rule(&rule_args), - "alpha_dash" => AlphaDash::parse_rule(&rule_args), - "alpha_num" => AlphaNum::parse_rule(&rule_args), - "ascii" => Ascii::parse_rule(&rule_args), - "between" => Between::parse_rule(&rule_args), - "boolean" => Boolean::parse_rule(&rule_args), - "confirmed" => Confirmed::parse_rule(&rule_args), - "date" => Date::parse_rule(&rule_args), - "date_format" => DateFormat::parse_rule(&rule_args), - "declined" => Declined::parse_rule(&rule_args), - "declined_if" => DeclinedIf::parse_rule(&rule_args), - "different" => Different::parse_rule(&rule_args), - "digits" => Digits::parse_rule(&rule_args), - "digits_between" => DigitsBetween::parse_rule(&rule_args), - "doesnt_start_with" => DoesntStartWith::parse_rule(&rule_args), - "doesnt_end_with" => DoesntEndWith::parse_rule(&rule_args), - "ends_with" => EndsWith::parse_rule(&rule_args), - "gt" => Gt::parse_rule(&rule_args), - "gte" => Gte::parse_rule(&rule_args), - "hex_color" => HexColor::parse_rule(&rule_args), - "in" => In::parse_rule(&rule_args), - "integer" | "int" => Integer::parse_rule(&rule_args), - "ip" => Ip::parse_rule(&rule_args), - "ipv4" => Ipv4::parse_rule(&rule_args), - "ipv6" => Ipv6::parse_rule(&rule_args), - "json" => Json::parse_rule(&rule_args), - "lt" => Lt::parse_rule(&rule_args), - "lte" => Lte::parse_rule(&rule_args), - "lowercase" => Lowercase::parse_rule(&rule_args), - "mac_address" => MacAddress::parse_rule(&rule_args), - "max" => Max::parse_rule(&rule_args), - "max_digits" => MaxDigits::parse_rule(&rule_args), - "min" => Min::parse_rule(&rule_args), - "min_digits" => MinDigits::parse_rule(&rule_args), - "multiple_of" => MultipleOf::parse_rule(&rule_args), - "not_in" => NotIn::parse_rule(&rule_args), - "not_regex" => NotRegex::parse_rule(&rule_args), - "nullable" => Nullable::parse_rule(&rule_args), - "numeric" | "num" => Numeric::parse_rule(&rule_args), - "regex" => Regex::parse_rule(&rule_args), - "required" => Required::parse_rule(&rule_args), - "required_if" => RequiredIf::parse_rule(&rule_args), - "required_if_accepted" => RequiredIfAccepted::parse_rule(&rule_args), - "required_if_declined" => RequiredIfDeclined::parse_rule(&rule_args), - "same" => Same::parse_rule(&rule_args), - "size" => Size::parse_rule(&rule_args), - "starts_with" => StartsWith::parse_rule(&rule_args), - "string" | "str" => StringRule::parse_rule(&rule_args), - "timezone" => Timezone::parse_rule(&rule_args), - "uppercase" => Uppercase::parse_rule(&rule_args), - "url" => Url::parse_rule(&rule_args), - "uuid" => Uuid::parse_rule(&rule_args), - rule => Err(compact_str::format_compact!( - "unknown or unsupported validation rule: {rule}" - )), - } -} - -pub struct Accepted; - -impl ParseValidationRule for Accepted { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Accepted)) - } -} - -impl ValidateRule for Accepted { - fn label(&self) -> &'static str { - "accepted" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - match data.data.get(key).copied() { - Some("true") | Some("1") | Some("yes") | Some("on") => Ok(false), - _ => Err("value must be 'true', '1', 'yes', or 'on'".into()), - } - } -} - -pub struct AcceptedIf { - keys: Vec<(compact_str::CompactString, compact_str::CompactString)>, -} - -impl ParseValidationRule for AcceptedIf { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() < 2 { - return Err("accepted_if requires a key and value to check".into()); - } - - let mut keys = Vec::new(); - for i in (0..rule.len()).step_by(2) { - if i + 1 < rule.len() { - keys.push((rule[i].clone(), rule[i + 1].clone())); - } else { - return Err("accepted_if requires an even number of arguments".into()); - } - } - - Ok(Box::new(AcceptedIf { keys })) - } -} - -impl ValidateRule for AcceptedIf { - fn label(&self) -> &'static str { - "accepted_if" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - for (check_key, check_value) in &self.keys { - if let Some(value) = data.data.get(check_key.as_str()) - && value == check_value - { - match data.data.get(key).copied() { - Some("true") | Some("1") | Some("yes") | Some("on") => return Ok(false), - _ => { - return Err("must be 'true', '1', 'yes', or 'on'".into()); - } - } - } - } - - Ok(false) - } -} - -pub struct Alpha { - only_ascii: bool, -} - -impl ParseValidationRule for Alpha { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - let only_ascii = rule.first().is_some_and(|s| s == "ascii"); - - Ok(Box::new(Alpha { only_ascii })) - } -} - -impl ValidateRule for Alpha { - fn label(&self) -> &'static str { - "alpha" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - if self.only_ascii { - if value.chars().all(|c| c.is_ascii_alphabetic()) { - return Ok(false); - } - } else if value.chars().all(|c| c.is_alphabetic()) { - return Ok(false); - } - } - - Err("must contain only alphabetic characters".into()) - } -} - -pub struct AlphaDash { - only_ascii: bool, -} - -impl ParseValidationRule for AlphaDash { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - let only_ascii = rule.first().is_some_and(|s| s == "ascii"); - - Ok(Box::new(AlphaDash { only_ascii })) - } -} - -impl ValidateRule for AlphaDash { - fn label(&self) -> &'static str { - "alpha_dash" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - if self.only_ascii { - if value - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - { - return Ok(false); - } - } else if value - .chars() - .all(|c| c.is_alphanumeric() || c == '-' || c == '_') - { - return Ok(false); - } - } - - Err("must contain only alphanumeric characters, dashes, or underscores".into()) - } -} - -pub struct AlphaNum { - only_ascii: bool, -} - -impl ParseValidationRule for AlphaNum { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - let only_ascii = rule.first().is_some_and(|s| s == "ascii"); - - Ok(Box::new(AlphaNum { only_ascii })) - } -} - -impl ValidateRule for AlphaNum { - fn label(&self) -> &'static str { - "alpha_num" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - if self.only_ascii { - if value.chars().all(|c| c.is_ascii_alphanumeric()) { - return Ok(false); - } - } else if value.chars().all(|c| c.is_alphanumeric()) { - return Ok(false); - } - } - - Err("must contain only alphanumeric characters".into()) - } -} - -pub struct Ascii; - -impl ParseValidationRule for Ascii { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Ascii)) - } -} - -impl ValidateRule for Ascii { - fn label(&self) -> &'static str { - "ascii" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.is_ascii() - { - return Ok(false); - } - - Err("must contain only ASCII characters".into()) - } -} - -pub struct Between { - min: f64, - max: f64, -} - -impl ParseValidationRule for Between { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 2 { - return Err("between requires two numeric values".into()); - } - - let min = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid minimum value"))?; - let max = rule[1] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid maximum value"))?; - - Ok(Box::new(Between { min, max })) - } -} - -impl ValidateRule for Between { - fn label(&self) -> &'static str { - "between" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - if let Ok(num) = value.parse::() { - if num >= self.min && num <= self.max { - return Ok(false); - } - } else if value.len() >= self.min as usize && value.len() <= self.max as usize { - return Ok(false); - } - } - - Err(compact_str::format_compact!( - "must be between {} and {}", - self.min, - self.max - )) - } -} - -pub struct Boolean; - -impl ParseValidationRule for Boolean { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Boolean)) - } -} - -impl ValidateRule for Boolean { - fn label(&self) -> &'static str { - "boolean" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - match data.data.get(key).copied() { - Some("true") | Some("1") | Some("yes") | Some("on") | Some("false") | Some("0") - | Some("no") | Some("off") => Ok(false), - _ => Err("must be a boolean (true/false, 1/0, yes/no, on/off)".into()), - } - } -} - -pub struct Confirmed; - -impl ParseValidationRule for Confirmed { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Confirmed)) - } -} - -impl ValidateRule for Confirmed { - fn label(&self) -> &'static str { - "confirmed" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - let confirm_key = format!("{}{}", key, "_confirmation"); - if let Some(value) = data.data.get(key) - && let Some(confirm_value) = data.data.get(confirm_key.as_str()) - && value == confirm_value - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "does not match confirmation field '{confirm_key}'" - )) - } -} - -pub struct Date; - -impl ParseValidationRule for Date { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Date)) - } -} - -impl ValidateRule for Date { - fn label(&self) -> &'static str { - "date" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.parse::().is_ok() - { - return Ok(false); - } - - Err("must be a valid date".into()) - } -} - -pub struct DateFormat { - format: compact_str::CompactString, -} - -impl ParseValidationRule for DateFormat { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.is_empty() { - return Err("date_format requires a format string".into()); - } - - let format = rule[0].clone(); - Ok(Box::new(DateFormat { format })) - } -} - -impl ValidateRule for DateFormat { - fn label(&self) -> &'static str { - "date_format" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && chrono::NaiveDate::parse_from_str(value, &self.format).is_ok() - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "must match the format '{}'", - self.format - )) - } -} - -pub struct Declined; - -impl ParseValidationRule for Declined { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Declined)) - } -} - -impl ValidateRule for Declined { - fn label(&self) -> &'static str { - "declined" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - match data.data.get(key).copied() { - Some("false") | Some("0") | Some("no") | Some("off") => Ok(false), - _ => Err("value must be 'false', '0', 'no', or 'off'".into()), - } - } -} - -pub struct DeclinedIf { - keys: Vec<(compact_str::CompactString, compact_str::CompactString)>, -} - -impl ParseValidationRule for DeclinedIf { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() < 2 { - return Err("declined_if requires a key and value to check".into()); - } - - let mut keys = Vec::new(); - for i in (0..rule.len()).step_by(2) { - if i + 1 < rule.len() { - keys.push((rule[i].clone(), rule[i + 1].clone())); - } else { - return Err("declined_if requires an even number of arguments".into()); - } - } - - Ok(Box::new(DeclinedIf { keys })) - } -} - -impl ValidateRule for DeclinedIf { - fn label(&self) -> &'static str { - "declined_if" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - for (check_key, check_value) in &self.keys { - if let Some(value) = data.data.get(check_key.as_str()) - && value == check_value - { - match data.data.get(key).copied() { - Some("false") | Some("0") | Some("no") | Some("off") => return Ok(false), - _ => { - return Err("must be 'false', '0', 'no', or 'off'".into()); - } - } - } - } - - Ok(false) - } -} - -pub struct Different { - other_key: compact_str::CompactString, -} - -impl ParseValidationRule for Different { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("different requires one key to compare against".into()); - } - - Ok(Box::new(Different { - other_key: rule[0].clone(), - })) - } -} - -impl ValidateRule for Different { - fn label(&self) -> &'static str { - "different" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && let Some(other_value) = data.data.get(self.other_key.as_str()) - && value != other_value - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "must be different from '{}'", - self.other_key - )) - } -} - -pub struct Digits { - length: usize, -} - -impl ParseValidationRule for Digits { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("digits requires one numeric value for length".into()); - } - - let length = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid length value"))?; - - Ok(Box::new(Digits { length })) - } -} - -impl ValidateRule for Digits { - fn label(&self) -> &'static str { - "digits" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.chars().all(|c| c.is_ascii_digit()) - && value.len() == self.length - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "must contain exactly {} digits", - self.length - )) - } -} - -pub struct DigitsBetween { - minimum: usize, - maximum: usize, -} - -impl ParseValidationRule for DigitsBetween { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 2 { - return Err("digits_between requires two numeric values".into()); - } - - let minimum = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid minimum value"))?; - let maximum = rule[1] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid maximum value"))?; - - Ok(Box::new(DigitsBetween { minimum, maximum })) - } -} - -impl ValidateRule for DigitsBetween { - fn label(&self) -> &'static str { - "digits_between" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.chars().all(|c| c.is_ascii_digit()) - { - let len = value.len(); - if len >= self.minimum && len <= self.maximum { - return Ok(false); - } - } - - Err(compact_str::format_compact!( - "must contain between {} and {} digits", - self.minimum, - self.maximum - )) - } -} - -pub struct DoesntStartWith { - prefixes: Vec, -} - -impl ParseValidationRule for DoesntStartWith { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.is_empty() { - return Err("doesnt_start_with requires at least one prefix".into()); - } - - Ok(Box::new(DoesntStartWith { - prefixes: rule.to_vec(), - })) - } -} - -impl ValidateRule for DoesntStartWith { - fn label(&self) -> &'static str { - "doesnt_start_with" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - for prefix in &self.prefixes { - if value.starts_with(&**prefix) { - return Err(compact_str::format_compact!( - "must not start with '{prefix}'" - )); - } - } - } - - Ok(false) - } -} - -pub struct DoesntEndWith { - suffixes: Vec, -} - -impl ParseValidationRule for DoesntEndWith { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.is_empty() { - return Err("doesnt_end_with requires at least one suffix".into()); - } - - Ok(Box::new(DoesntEndWith { - suffixes: rule.to_vec(), - })) - } -} - -impl ValidateRule for DoesntEndWith { - fn label(&self) -> &'static str { - "doesnt_end_with" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - for suffix in &self.suffixes { - if value.ends_with(&**suffix) { - return Err(compact_str::format_compact!("must not end with '{suffix}'")); - } - } - } - - Ok(false) - } -} - -pub struct EndsWith { - suffixes: Vec, -} - -impl ParseValidationRule for EndsWith { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.is_empty() { - return Err("ends_with requires at least one suffix".into()); - } - - Ok(Box::new(EndsWith { - suffixes: rule.to_vec(), - })) - } -} - -impl ValidateRule for EndsWith { - fn label(&self) -> &'static str { - "ends_with" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - for suffix in &self.suffixes { - if value.ends_with(&**suffix) { - return Err(compact_str::format_compact!("must not end with '{suffix}'")); - } - } - } - - Ok(false) - } -} - -pub struct Gt { - value: f64, -} - -impl ParseValidationRule for Gt { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("gt requires one numeric value".into()); - } - - let value = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid value for gt"))?; - - Ok(Box::new(Gt { value })) - } -} - -impl ValidateRule for Gt { - fn label(&self) -> &'static str { - "gt" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - if let Ok(num) = value.parse::() { - if num > self.value { - return Ok(false); - } - } else if value.len() > self.value as usize { - return Ok(false); - } - } - - Err(compact_str::format_compact!( - "must be greater than {}", - self.value - )) - } -} - -pub struct Gte { - value: f64, -} - -impl ParseValidationRule for Gte { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("gte requires one numeric value".into()); - } - - let value = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid value for gte"))?; - - Ok(Box::new(Gte { value })) - } -} - -impl ValidateRule for Gte { - fn label(&self) -> &'static str { - "gte" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - if let Ok(num) = value.parse::() { - if num >= self.value { - return Ok(false); - } - } else if value.len() >= self.value as usize { - return Ok(false); - } - } - - Err(compact_str::format_compact!( - "must be greater than or equal to {}", - self.value - )) - } -} - -pub struct HexColor; - -impl ParseValidationRule for HexColor { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(HexColor)) - } -} - -impl ValidateRule for HexColor { - fn label(&self) -> &'static str { - "hex_color" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.starts_with('#') - && value.len() == 7 - && value[1..].chars().all(|c| c.is_ascii_hexdigit()) - { - return Ok(false); - } - - Err("must be a valid hex color code".into()) - } -} - -pub struct In { - options: Vec, -} - -impl ParseValidationRule for In { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.is_empty() { - return Err("in requires at least one option".into()); - } - - Ok(Box::new(In { - options: rule.to_vec(), - })) - } -} - -impl ValidateRule for In { - fn label(&self) -> &'static str { - "in" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key).copied() - && self.options.iter().any(|option| option == value) - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "must be one of: {}", - self.options.join(", ") - )) - } -} - -pub struct Integer; - -impl ParseValidationRule for Integer { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Integer)) - } -} - -impl ValidateRule for Integer { - fn label(&self) -> &'static str { - "integer" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.parse::().is_ok() - { - return Ok(false); - } - - Err("must be a valid integer".into()) - } -} - -pub struct Ip; - -impl ParseValidationRule for Ip { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Ip)) - } -} - -impl ValidateRule for Ip { - fn label(&self) -> &'static str { - "ip" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.parse::().is_ok() - { - return Ok(false); - } - - Err("must be a valid IP address".into()) - } -} - -pub struct Ipv4; - -impl ParseValidationRule for Ipv4 { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Ipv4)) - } -} - -impl ValidateRule for Ipv4 { - fn label(&self) -> &'static str { - "ipv4" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.parse::().is_ok() - { - return Ok(false); - } - - Err("must be a valid IPv4 address".into()) - } -} - -pub struct Ipv6; - -impl ParseValidationRule for Ipv6 { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Ipv6)) - } -} - -impl ValidateRule for Ipv6 { - fn label(&self) -> &'static str { - "ipv6" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.parse::().is_ok() - { - return Ok(false); - } - - Err("must be a valid IPv6 address".into()) - } -} - -pub struct Json; - -impl ParseValidationRule for Json { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Json)) - } -} - -impl ValidateRule for Json { - fn label(&self) -> &'static str { - "json" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && serde_json::from_str::(value).is_ok() - { - return Ok(false); - } - - Err("must be valid JSON".into()) - } -} - -pub struct Lt { - value: f64, -} - -impl ParseValidationRule for Lt { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("lt requires one numeric value".into()); - } - - let value = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid value for lt"))?; - - Ok(Box::new(Lt { value })) - } -} - -impl ValidateRule for Lt { - fn label(&self) -> &'static str { - "lt" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - if let Ok(num) = value.parse::() { - if num < self.value { - return Ok(false); - } - } else if value.len() < self.value as usize { - return Ok(false); - } - } - - Err(compact_str::format_compact!( - "must be less than {}", - self.value - )) - } -} - -pub struct Lte { - value: f64, -} - -impl ParseValidationRule for Lte { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("lte requires one numeric value".into()); - } - - let value = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid value for lte"))?; - - Ok(Box::new(Lte { value })) - } -} - -impl ValidateRule for Lte { - fn label(&self) -> &'static str { - "lte" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - if let Ok(num) = value.parse::() { - if num <= self.value { - return Ok(false); - } - } else if value.len() <= self.value as usize { - return Ok(false); - } - } - - Err(compact_str::format_compact!( - "must be less than or equal to {}", - self.value - )) - } -} - -pub struct Lowercase; - -impl ParseValidationRule for Lowercase { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Lowercase)) - } -} - -impl ValidateRule for Lowercase { - fn label(&self) -> &'static str { - "lowercase" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.chars().all(|c| c.is_lowercase()) - { - return Ok(false); - } - - Err("must be lowercase".into()) - } -} - -pub struct MacAddress; - -impl ParseValidationRule for MacAddress { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(MacAddress)) - } -} - -impl ValidateRule for MacAddress { - fn label(&self) -> &'static str { - "mac_address" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - let parts: Vec<&str> = value.split(':').collect(); - if parts.len() == 6 - && parts - .iter() - .all(|part| part.len() == 2 && part.chars().all(|c| c.is_ascii_hexdigit())) - { - return Ok(false); - } - } - - Err("must be a valid MAC address".into()) - } -} - -pub struct Max { - value: f64, -} - -impl ParseValidationRule for Max { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("max requires one numeric value".into()); - } - - let value = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid value for max"))?; - - Ok(Box::new(Max { value })) - } -} - -impl ValidateRule for Max { - fn label(&self) -> &'static str { - "max" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - if !data.has_rule(key, "string") - && let Ok(num) = value.parse::() - { - if num <= self.value { - return Ok(false); - } - } else if value.len() <= self.value as usize { - return Ok(false); - } - } - - Err(compact_str::format_compact!( - "must be less than or equal to {}", - self.value - )) - } -} - -pub struct MaxDigits { - value: usize, -} - -impl ParseValidationRule for MaxDigits { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("max_digits requires one numeric value".into()); - } - - let value = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid value for max_digits"))?; - - Ok(Box::new(MaxDigits { value })) - } -} - -impl ValidateRule for MaxDigits { - fn label(&self) -> &'static str { - "max_digits" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.chars().all(|c| c.is_ascii_digit()) - && value.len() <= self.value - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "must contain at most {} digits", - self.value - )) - } -} - -pub struct Min { - value: f64, -} - -impl ParseValidationRule for Min { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("min requires one numeric value".into()); - } - - let value = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid value for min"))?; - - Ok(Box::new(Min { value })) - } -} - -impl ValidateRule for Min { - fn label(&self) -> &'static str { - "min" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - if !data.has_rule(key, "string") - && let Ok(num) = value.parse::() - { - if num >= self.value { - return Ok(false); - } - } else if value.len() >= self.value as usize { - return Ok(false); - } - } - - Err(compact_str::format_compact!( - "must be greater than or equal to {}", - self.value - )) - } -} - -pub struct MinDigits { - value: usize, -} - -impl ParseValidationRule for MinDigits { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("min_digits requires one numeric value".into()); - } - - let value = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid value for min_digits"))?; - - Ok(Box::new(MinDigits { value })) - } -} - -impl ValidateRule for MinDigits { - fn label(&self) -> &'static str { - "min_digits" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.chars().all(|c| c.is_ascii_digit()) - && value.len() >= self.value - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "must contain at least {} digits", - self.value - )) - } -} - -pub struct MultipleOf { - value: f64, -} - -impl ParseValidationRule for MultipleOf { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("multiple_of requires one numeric value".into()); - } - - let value = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid value for multiple_of"))?; - - Ok(Box::new(MultipleOf { value })) - } -} - -impl ValidateRule for MultipleOf { - fn label(&self) -> &'static str { - "multiple_of" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && let Ok(num) = value.parse::() - && num % self.value == 0.0 - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "must be a multiple of {}", - self.value - )) - } -} - -pub struct NotIn { - options: Vec, -} - -impl ParseValidationRule for NotIn { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.is_empty() { - return Err("not_in requires at least one option".into()); - } - - Ok(Box::new(NotIn { - options: rule.to_vec(), - })) - } -} - -impl ValidateRule for NotIn { - fn label(&self) -> &'static str { - "not_in" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key).copied() - && !self.options.iter().any(|option| option == value) - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "must not be one of: {}", - self.options.join(", ") - )) - } -} - -pub struct NotRegex { - pattern: regex::Regex, -} - -impl ParseValidationRule for NotRegex { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.is_empty() { - return Err("not_regex requires a regex pattern".into()); - } - - let pattern = rule[0].trim_matches('/').to_string(); - let regex = regex::Regex::new(&pattern) - .map_err(|_| compact_str::CompactString::const_new("invalid regex pattern"))?; - - Ok(Box::new(NotRegex { pattern: regex })) - } -} - -impl ValidateRule for NotRegex { - fn label(&self) -> &'static str { - "not_regex" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && !self.pattern.is_match(value) - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "must not match the regex pattern '{}'", - self.pattern - )) - } -} - -pub struct Nullable; - -impl ParseValidationRule for Nullable { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Nullable)) - } -} - -impl ValidateRule for Nullable { - fn label(&self) -> &'static str { - "nullable" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key).copied() - && (value.is_empty() || value == "null") - { - return Ok(true); - } - - Ok(false) - } -} - -pub struct Numeric; - -impl ParseValidationRule for Numeric { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Numeric)) - } -} - -impl ValidateRule for Numeric { - fn label(&self) -> &'static str { - "numeric" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value - .chars() - .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+') - { - return Ok(false); - } - - Err("must be a valid numeric value".into()) - } -} - -pub struct Regex { - pattern: regex::Regex, -} - -impl ParseValidationRule for Regex { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.is_empty() { - return Err("regex requires a regex pattern".into()); - } - - let pattern = rule[0].trim_matches('/').to_string(); - let regex = regex::Regex::new(&pattern) - .map_err(|_| compact_str::CompactString::const_new("invalid regex pattern"))?; - - Ok(Box::new(Regex { pattern: regex })) - } -} - -impl ValidateRule for Regex { - fn label(&self) -> &'static str { - "regex" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && self.pattern.is_match(value) - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "must match the regex pattern '{}'", - self.pattern - )) - } -} - -pub struct Required; - -impl ParseValidationRule for Required { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Required)) - } -} - -impl ValidateRule for Required { - fn label(&self) -> &'static str { - "required" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && !value.is_empty() - { - return Ok(false); - } - - Err("is required and cannot be empty".into()) - } -} - -pub struct RequiredIf { - keys: Vec<(compact_str::CompactString, compact_str::CompactString)>, -} - -impl ParseValidationRule for RequiredIf { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() < 2 { - return Err("required_if requires a key and value to check".into()); - } - - let mut keys = Vec::new(); - for i in (0..rule.len()).step_by(2) { - if i + 1 < rule.len() { - keys.push((rule[i].clone(), rule[i + 1].clone())); - } else { - return Err("required_if requires an even number of arguments".into()); - } - } - - Ok(Box::new(RequiredIf { keys })) - } -} - -impl ValidateRule for RequiredIf { - fn label(&self) -> &'static str { - "required_if" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - for (check_key, check_value) in &self.keys { - if let Some(value) = data.data.get(check_key.as_str()).copied() - && value == check_value - { - if let Some(field_value) = data.data.get(key) - && !field_value.is_empty() - { - return Ok(false); - } - - return Err(compact_str::format_compact!( - "is required when '{check_key}' is '{check_value}'" - )); - } - } - - Ok(true) - } -} - -pub struct RequiredIfAccepted { - other_key: compact_str::CompactString, -} - -impl ParseValidationRule for RequiredIfAccepted { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("required_if_accepted requires one key to check".into()); - } - - Ok(Box::new(RequiredIfAccepted { - other_key: rule[0].clone(), - })) - } -} - -impl ValidateRule for RequiredIfAccepted { - fn label(&self) -> &'static str { - "required_if_accepted" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(self.other_key.as_str()).copied() - && (value == "true" || value == "1" || value == "yes" || value == "on") - { - if let Some(field_value) = data.data.get(key) - && !field_value.is_empty() - { - return Ok(false); - } - - return Err(compact_str::format_compact!( - "is required when '{}' is accepted", - self.other_key - )); - } - - Ok(true) - } -} - -pub struct RequiredIfDeclined { - other_key: compact_str::CompactString, -} - -impl ParseValidationRule for RequiredIfDeclined { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("required_if_declined requires one key to check".into()); - } - - Ok(Box::new(RequiredIfDeclined { - other_key: rule[0].clone(), - })) - } -} - -impl ValidateRule for RequiredIfDeclined { - fn label(&self) -> &'static str { - "required_if_declined" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(self.other_key.as_str()).copied() - && (value == "false" || value == "0" || value == "no" || value == "off") - { - if let Some(field_value) = data.data.get(key) - && !field_value.is_empty() - { - return Ok(false); - } - - return Err(compact_str::format_compact!( - "is required when '{}' is declined", - self.other_key - )); - } - - Ok(true) - } -} - -pub struct Same { - other_key: compact_str::CompactString, -} - -impl ParseValidationRule for Same { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("same requires one key to compare against".into()); - } - - Ok(Box::new(Same { - other_key: rule[0].clone(), - })) - } -} - -impl ValidateRule for Same { - fn label(&self) -> &'static str { - "same" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && let Some(other_value) = data.data.get(self.other_key.as_str()) - && value == other_value - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "must be the same as '{}'", - self.other_key - )) - } -} - -pub struct Size { - size: f64, -} - -impl ParseValidationRule for Size { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.len() != 1 { - return Err("size requires one numeric value".into()); - } - - let size = rule[0] - .parse::() - .map_err(|_| compact_str::CompactString::const_new("invalid value for size"))?; - - Ok(Box::new(Size { size })) - } -} - -impl ValidateRule for Size { - fn label(&self) -> &'static str { - "size" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - if let Ok(num) = value.parse::() { - if num == self.size { - return Ok(false); - } - } else if value.len() == self.size as usize { - return Ok(false); - } - } - - Err(compact_str::format_compact!( - "must be equal to {}", - self.size - )) - } -} - -pub struct StartsWith { - prefixes: Vec, -} - -impl ParseValidationRule for StartsWith { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - if rule.is_empty() { - return Err("starts_with requires at least one prefix".into()); - } - - Ok(Box::new(StartsWith { - prefixes: rule.to_vec(), - })) - } -} - -impl ValidateRule for StartsWith { - fn label(&self) -> &'static str { - "starts_with" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) { - for prefix in &self.prefixes { - if value.starts_with(&**prefix) { - return Ok(false); - } - } - } - - Err(compact_str::format_compact!( - "must start with one of: {}", - self.prefixes.join(", ") - )) - } -} - -pub struct StringRule; - -impl ParseValidationRule for StringRule { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(StringRule)) - } -} - -impl ValidateRule for StringRule { - fn label(&self) -> &'static str { - "string" - } - - fn validate(&self, _key: &str, _data: &Validator) -> Result { - Ok(false) - } -} - -pub struct Timezone; - -impl ParseValidationRule for Timezone { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Timezone)) - } -} - -impl ValidateRule for Timezone { - fn label(&self) -> &'static str { - "timezone" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key).copied() - && (value.parse::().is_ok() - || value.parse::().is_ok()) - { - return Ok(false); - } - - Err("must be a valid timezone".into()) - } -} - -pub struct Uppercase; - -impl ParseValidationRule for Uppercase { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Uppercase)) - } -} - -impl ValidateRule for Uppercase { - fn label(&self) -> &'static str { - "uppercase" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && value.chars().all(|c| c.is_uppercase()) - { - return Ok(false); - } - - Err("must be uppercase".into()) - } -} - -pub struct Url { - protocols: Vec, -} - -impl ParseValidationRule for Url { - fn parse_rule( - rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Url { - protocols: rule.to_vec(), - })) - } -} - -impl ValidateRule for Url { - fn label(&self) -> &'static str { - "url" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && let Ok(url) = reqwest::Url::parse(value) - && (self.protocols.is_empty() || self.protocols.contains(&url.scheme().into())) - { - return Ok(false); - } - - Err(compact_str::format_compact!( - "must be a valid URL with one of the following protocols: {}", - self.protocols.join(", ") - )) - } -} - -pub struct Uuid; - -impl ParseValidationRule for Uuid { - fn parse_rule( - _rule: &[compact_str::CompactString], - ) -> Result, compact_str::CompactString> { - Ok(Box::new(Uuid)) - } -} - -impl ValidateRule for Uuid { - fn label(&self) -> &'static str { - "uuid" - } - - fn validate(&self, key: &str, data: &Validator) -> Result { - if let Some(value) = data.data.get(key) - && uuid::Uuid::parse_str(value).is_ok() - { - return Ok(false); - } - - Err("must be a valid UUID".into()) - } -} diff --git a/rule-validator/src/rules/comparison.rs b/rule-validator/src/rules/comparison.rs new file mode 100644 index 000000000..c40f28e13 --- /dev/null +++ b/rule-validator/src/rules/comparison.rs @@ -0,0 +1,451 @@ +use crate::{ParseValidationRule, ValidateRule, Validator}; + +/// Decides numeric-vs-character-length comparison for `min`/`max`/`between`/`size`. +/// If the field has an explicit `string` rule, always use char length. Otherwise +/// try to parse as a number; if that succeeds, use numeric semantics; if not, +/// fall back to char length. +enum SizeMode { + Numeric(f64), + Length(usize), +} + +fn measure(value: &str, key: &str, data: &Validator) -> SizeMode { + if !data.has_rule(key, "string") + && let Ok(num) = value.parse::() + { + SizeMode::Numeric(num) + } else { + SizeMode::Length(value.chars().count()) + } +} + +/// `max:N` — numeric `<= N` if the value parses as a number and no `string` +/// rule is set; otherwise character length `<= N`. +pub struct Max { + value: f64, +} + +impl ParseValidationRule for Max { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("max requires one numeric value".into()); + } + + let value = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for max"))?; + + Ok(Box::new(Max { value })) + } +} + +impl ValidateRule for Max { + fn label(&self) -> &'static str { + "max" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + let ok = match measure(value, key, data) { + SizeMode::Numeric(num) => num <= self.value, + SizeMode::Length(len) => (len as f64) <= self.value, + }; + if ok { + return Ok(false); + } + } + + Err(compact_str::format_compact!( + "must be less than or equal to {}", + self.value + )) + } +} + +/// `min:N` — analogous to [`Max`]; numeric or char-length based on rule context. +pub struct Min { + value: f64, +} + +impl ParseValidationRule for Min { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("min requires one numeric value".into()); + } + + let value = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for min"))?; + + Ok(Box::new(Min { value })) + } +} + +impl ValidateRule for Min { + fn label(&self) -> &'static str { + "min" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + let ok = match measure(value, key, data) { + SizeMode::Numeric(num) => num >= self.value, + SizeMode::Length(len) => (len as f64) >= self.value, + }; + if ok { + return Ok(false); + } + } + + Err(compact_str::format_compact!( + "must be greater than or equal to {}", + self.value + )) + } +} + +pub struct Between { + min: f64, + max: f64, +} + +impl ParseValidationRule for Between { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 2 { + return Err("between requires two numeric values".into()); + } + + let min = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid minimum value"))?; + let max = rule[1] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid maximum value"))?; + + Ok(Box::new(Between { min, max })) + } +} + +impl ValidateRule for Between { + fn label(&self) -> &'static str { + "between" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + let ok = match measure(value, key, data) { + SizeMode::Numeric(num) => num >= self.min && num <= self.max, + SizeMode::Length(len) => { + let len = len as f64; + len >= self.min && len <= self.max + } + }; + if ok { + return Ok(false); + } + } + + Err(compact_str::format_compact!( + "must be between {} and {}", + self.min, + self.max + )) + } +} + +pub struct Size { + size: f64, +} + +impl ParseValidationRule for Size { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("size requires one numeric value".into()); + } + + let size = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for size"))?; + + Ok(Box::new(Size { size })) + } +} + +impl ValidateRule for Size { + fn label(&self) -> &'static str { + "size" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + let ok = match measure(value, key, data) { + SizeMode::Numeric(num) => num == self.size, + SizeMode::Length(len) => (len as f64) == self.size, + }; + if ok { + return Ok(false); + } + } + + Err(compact_str::format_compact!( + "must be equal to {}", + self.size + )) + } +} + +pub struct Gt { + value: f64, +} + +impl ParseValidationRule for Gt { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("gt requires one numeric value".into()); + } + + let value = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for gt"))?; + + Ok(Box::new(Gt { value })) + } +} + +impl ValidateRule for Gt { + fn label(&self) -> &'static str { + "gt" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + let ok = match measure(value, key, data) { + SizeMode::Numeric(num) => num > self.value, + SizeMode::Length(len) => (len as f64) > self.value, + }; + if ok { + return Ok(false); + } + } + + Err(compact_str::format_compact!( + "must be greater than {}", + self.value + )) + } +} + +pub struct Gte { + value: f64, +} + +impl ParseValidationRule for Gte { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("gte requires one numeric value".into()); + } + + let value = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for gte"))?; + + Ok(Box::new(Gte { value })) + } +} + +impl ValidateRule for Gte { + fn label(&self) -> &'static str { + "gte" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + let ok = match measure(value, key, data) { + SizeMode::Numeric(num) => num >= self.value, + SizeMode::Length(len) => (len as f64) >= self.value, + }; + if ok { + return Ok(false); + } + } + + Err(compact_str::format_compact!( + "must be greater than or equal to {}", + self.value + )) + } +} + +pub struct Lt { + value: f64, +} + +impl ParseValidationRule for Lt { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("lt requires one numeric value".into()); + } + + let value = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for lt"))?; + + Ok(Box::new(Lt { value })) + } +} + +impl ValidateRule for Lt { + fn label(&self) -> &'static str { + "lt" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + let ok = match measure(value, key, data) { + SizeMode::Numeric(num) => num < self.value, + SizeMode::Length(len) => (len as f64) < self.value, + }; + if ok { + return Ok(false); + } + } + + Err(compact_str::format_compact!( + "must be less than {}", + self.value + )) + } +} + +pub struct Lte { + value: f64, +} + +impl ParseValidationRule for Lte { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("lte requires one numeric value".into()); + } + + let value = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for lte"))?; + + Ok(Box::new(Lte { value })) + } +} + +impl ValidateRule for Lte { + fn label(&self) -> &'static str { + "lte" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + let ok = match measure(value, key, data) { + SizeMode::Numeric(num) => num <= self.value, + SizeMode::Length(len) => (len as f64) <= self.value, + }; + if ok { + return Ok(false); + } + } + + Err(compact_str::format_compact!( + "must be less than or equal to {}", + self.value + )) + } +} + +pub struct Same { + other_key: compact_str::CompactString, +} + +impl ParseValidationRule for Same { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("same requires one key to compare against".into()); + } + + Ok(Box::new(Same { + other_key: rule[0].clone(), + })) + } +} + +impl ValidateRule for Same { + fn label(&self) -> &'static str { + "same" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && let Some(other_value) = data.data.get(self.other_key.as_str()) + && value == other_value + { + return Ok(false); + } + + Err(compact_str::format_compact!( + "must be the same as '{}'", + self.other_key + )) + } +} + +pub struct Different { + other_key: compact_str::CompactString, +} + +impl ParseValidationRule for Different { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("different requires one key to compare against".into()); + } + + Ok(Box::new(Different { + other_key: rule[0].clone(), + })) + } +} + +impl ValidateRule for Different { + fn label(&self) -> &'static str { + "different" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && let Some(other_value) = data.data.get(self.other_key.as_str()) + && value != other_value + { + return Ok(false); + } + + Err(compact_str::format_compact!( + "must be different from '{}'", + self.other_key + )) + } +} diff --git a/rule-validator/src/rules/compat.rs b/rule-validator/src/rules/compat.rs new file mode 100644 index 000000000..02a7e7365 --- /dev/null +++ b/rule-validator/src/rules/compat.rs @@ -0,0 +1,26 @@ +// Array-shaped Laravel rules. Our scalar env-var data model has no arrays, so +// these are accepted for import compatibility but enforce nothing. +no_op_rule!(Array, "array"); +no_op_rule!(List, "list"); +no_op_rule!(Distinct, "distinct"); +no_op_rule!(InArray, "in_array"); +no_op_rule!(InArrayKeys, "in_array_keys"); +no_op_rule!(Contains, "contains"); +no_op_rule!(DoesntContain, "doesnt_contain"); +no_op_rule!(RequiredArrayKeys, "required_array_keys"); + +// Input-shaping rules — Laravel removes fields from validated data based on +// these. We just validate one field's value, so they're informational only. +no_op_rule!(Exclude, "exclude"); +no_op_rule!(ExcludeIf, "exclude_if"); +no_op_rule!(ExcludeUnless, "exclude_unless"); +no_op_rule!(ExcludeWith, "exclude_with"); +no_op_rule!(ExcludeWithout, "exclude_without"); + +// Rarely-used conditional prohibition variants. +no_op_rule!(ProhibitedIfAccepted, "prohibited_if_accepted"); +no_op_rule!(ProhibitedIfDeclined, "prohibited_if_declined"); + +// Low-value date comparison; use `after_or_equal` + `before_or_equal` for the +// same effect with the date rules in `date.rs`. +no_op_rule!(DateEquals, "date_equals"); diff --git a/rule-validator/src/rules/conditional.rs b/rule-validator/src/rules/conditional.rs new file mode 100644 index 000000000..e315ec7b1 --- /dev/null +++ b/rule-validator/src/rules/conditional.rs @@ -0,0 +1,587 @@ +use crate::{ParseValidationRule, ValidateRule, Validator}; + +fn is_present_and_nonempty(value: Option<&&str>) -> bool { + matches!(value, Some(v) if !v.is_empty() && **v != *"null") +} + +fn is_accepted(value: &str) -> bool { + matches!(value, "true" | "1" | "yes" | "on") +} + +fn is_declined(value: &str) -> bool { + matches!(value, "false" | "0" | "no" | "off") +} + +fn parse_key_value_pairs( + rule: &[compact_str::CompactString], + rule_name: &str, +) -> Result, compact_str::CompactString> +{ + if rule.len() < 2 { + return Err(compact_str::format_compact!( + "{rule_name} requires a key and value to check" + )); + } + + let mut keys = Vec::new(); + for i in (0..rule.len()).step_by(2) { + if i + 1 < rule.len() { + keys.push((rule[i].clone(), rule[i + 1].clone())); + } else { + return Err(compact_str::format_compact!( + "{rule_name} requires an even number of arguments" + )); + } + } + Ok(keys) +} + +pub struct AcceptedIf { + keys: Vec<(compact_str::CompactString, compact_str::CompactString)>, +} + +impl ParseValidationRule for AcceptedIf { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(AcceptedIf { + keys: parse_key_value_pairs(rule, "accepted_if")?, + })) + } +} + +impl ValidateRule for AcceptedIf { + fn label(&self) -> &'static str { + "accepted_if" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + for (check_key, check_value) in &self.keys { + if let Some(value) = data.data.get(check_key.as_str()) + && value == check_value + { + match data.data.get(key).copied() { + Some(v) if is_accepted(v) => return Ok(false), + _ => return Err("must be 'true', '1', 'yes', or 'on'".into()), + } + } + } + Ok(false) + } +} + +pub struct DeclinedIf { + keys: Vec<(compact_str::CompactString, compact_str::CompactString)>, +} + +impl ParseValidationRule for DeclinedIf { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(DeclinedIf { + keys: parse_key_value_pairs(rule, "declined_if")?, + })) + } +} + +impl ValidateRule for DeclinedIf { + fn label(&self) -> &'static str { + "declined_if" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + for (check_key, check_value) in &self.keys { + if let Some(value) = data.data.get(check_key.as_str()) + && value == check_value + { + match data.data.get(key).copied() { + Some(v) if is_declined(v) => return Ok(false), + _ => return Err("must be 'false', '0', 'no', or 'off'".into()), + } + } + } + Ok(false) + } +} + +pub struct RequiredIf { + keys: Vec<(compact_str::CompactString, compact_str::CompactString)>, +} + +impl ParseValidationRule for RequiredIf { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(RequiredIf { + keys: parse_key_value_pairs(rule, "required_if")?, + })) + } +} + +impl ValidateRule for RequiredIf { + fn label(&self) -> &'static str { + "required_if" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + for (check_key, check_value) in &self.keys { + if let Some(value) = data.data.get(check_key.as_str()).copied() + && value == check_value + { + if is_present_and_nonempty(data.data.get(key)) { + return Ok(false); + } + return Err(compact_str::format_compact!( + "is required when '{check_key}' is '{check_value}'" + )); + } + } + Ok(true) + } +} + +pub struct RequiredUnless { + keys: Vec<(compact_str::CompactString, compact_str::CompactString)>, +} + +impl ParseValidationRule for RequiredUnless { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(RequiredUnless { + keys: parse_key_value_pairs(rule, "required_unless")?, + })) + } +} + +impl ValidateRule for RequiredUnless { + fn label(&self) -> &'static str { + "required_unless" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + // Required UNLESS any (key, value) pair matches. + for (check_key, check_value) in &self.keys { + if let Some(value) = data.data.get(check_key.as_str()).copied() + && value == check_value + { + return Ok(true); + } + } + + if is_present_and_nonempty(data.data.get(key)) { + Ok(false) + } else { + let pairs = self + .keys + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", "); + Err(compact_str::format_compact!( + "is required unless one of [{pairs}]" + )) + } + } +} + +pub struct RequiredIfAccepted { + other_key: compact_str::CompactString, +} + +impl ParseValidationRule for RequiredIfAccepted { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("required_if_accepted requires one key to check".into()); + } + Ok(Box::new(RequiredIfAccepted { + other_key: rule[0].clone(), + })) + } +} + +impl ValidateRule for RequiredIfAccepted { + fn label(&self) -> &'static str { + "required_if_accepted" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(self.other_key.as_str()).copied() + && is_accepted(value) + { + if is_present_and_nonempty(data.data.get(key)) { + return Ok(false); + } + return Err(compact_str::format_compact!( + "is required when '{}' is accepted", + self.other_key + )); + } + Ok(true) + } +} + +pub struct RequiredIfDeclined { + other_key: compact_str::CompactString, +} + +impl ParseValidationRule for RequiredIfDeclined { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("required_if_declined requires one key to check".into()); + } + Ok(Box::new(RequiredIfDeclined { + other_key: rule[0].clone(), + })) + } +} + +impl ValidateRule for RequiredIfDeclined { + fn label(&self) -> &'static str { + "required_if_declined" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(self.other_key.as_str()).copied() + && is_declined(value) + { + if is_present_and_nonempty(data.data.get(key)) { + return Ok(false); + } + return Err(compact_str::format_compact!( + "is required when '{}' is declined", + self.other_key + )); + } + Ok(true) + } +} + +/// `required_with:f1,f2,...` — required if ANY listed field is present and non-empty. +pub struct RequiredWith { + others: Vec, +} + +/// `required_with_all:f1,f2,...` — required if ALL listed fields are present and non-empty. +pub struct RequiredWithAll { + others: Vec, +} + +/// `required_without:f1,f2,...` — required if ANY listed field is absent or empty. +pub struct RequiredWithout { + others: Vec, +} + +/// `required_without_all:f1,f2,...` — required if ALL listed fields are absent or empty. +pub struct RequiredWithoutAll { + others: Vec, +} + +fn parse_field_list( + rule: &[compact_str::CompactString], + rule_name: &str, +) -> Result, compact_str::CompactString> { + if rule.is_empty() { + return Err(compact_str::format_compact!( + "{rule_name} requires at least one field name" + )); + } + Ok(rule.to_vec()) +} + +impl ParseValidationRule for RequiredWith { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(RequiredWith { + others: parse_field_list(rule, "required_with")?, + })) + } +} + +impl ValidateRule for RequiredWith { + fn label(&self) -> &'static str { + "required_with" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + let any_present = self + .others + .iter() + .any(|f| is_present_and_nonempty(data.data.get(f.as_str()))); + + if !any_present { + return Ok(true); + } + + if is_present_and_nonempty(data.data.get(key)) { + Ok(false) + } else { + Err(compact_str::format_compact!( + "is required when any of [{}] is present", + self.others.join(", ") + )) + } + } +} + +impl ParseValidationRule for RequiredWithAll { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(RequiredWithAll { + others: parse_field_list(rule, "required_with_all")?, + })) + } +} + +impl ValidateRule for RequiredWithAll { + fn label(&self) -> &'static str { + "required_with_all" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + let all_present = self + .others + .iter() + .all(|f| is_present_and_nonempty(data.data.get(f.as_str()))); + + if !all_present { + return Ok(true); + } + + if is_present_and_nonempty(data.data.get(key)) { + Ok(false) + } else { + Err(compact_str::format_compact!( + "is required when all of [{}] are present", + self.others.join(", ") + )) + } + } +} + +impl ParseValidationRule for RequiredWithout { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(RequiredWithout { + others: parse_field_list(rule, "required_without")?, + })) + } +} + +impl ValidateRule for RequiredWithout { + fn label(&self) -> &'static str { + "required_without" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + let any_absent = self + .others + .iter() + .any(|f| !is_present_and_nonempty(data.data.get(f.as_str()))); + + if !any_absent { + return Ok(true); + } + + if is_present_and_nonempty(data.data.get(key)) { + Ok(false) + } else { + Err(compact_str::format_compact!( + "is required when any of [{}] is absent", + self.others.join(", ") + )) + } + } +} + +impl ParseValidationRule for RequiredWithoutAll { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(RequiredWithoutAll { + others: parse_field_list(rule, "required_without_all")?, + })) + } +} + +impl ValidateRule for RequiredWithoutAll { + fn label(&self) -> &'static str { + "required_without_all" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + let all_absent = self + .others + .iter() + .all(|f| !is_present_and_nonempty(data.data.get(f.as_str()))); + + if !all_absent { + return Ok(true); + } + + if is_present_and_nonempty(data.data.get(key)) { + Ok(false) + } else { + Err(compact_str::format_compact!( + "is required when all of [{}] are absent", + self.others.join(", ") + )) + } + } +} + +/// `prohibited` — field must be absent or empty. +pub struct Prohibited; + +impl ParseValidationRule for Prohibited { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Prohibited)) + } +} + +impl ValidateRule for Prohibited { + fn label(&self) -> &'static str { + "prohibited" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if !is_present_and_nonempty(data.data.get(key)) { + return Ok(false); + } + Err("is prohibited and must be empty".into()) + } +} + +pub struct ProhibitedIf { + keys: Vec<(compact_str::CompactString, compact_str::CompactString)>, +} + +impl ParseValidationRule for ProhibitedIf { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(ProhibitedIf { + keys: parse_key_value_pairs(rule, "prohibited_if")?, + })) + } +} + +impl ValidateRule for ProhibitedIf { + fn label(&self) -> &'static str { + "prohibited_if" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + for (check_key, check_value) in &self.keys { + if let Some(value) = data.data.get(check_key.as_str()).copied() + && value == check_value + { + if is_present_and_nonempty(data.data.get(key)) { + return Err(compact_str::format_compact!( + "is prohibited when '{check_key}' is '{check_value}'" + )); + } + return Ok(false); + } + } + Ok(false) + } +} + +pub struct ProhibitedUnless { + keys: Vec<(compact_str::CompactString, compact_str::CompactString)>, +} + +impl ParseValidationRule for ProhibitedUnless { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(ProhibitedUnless { + keys: parse_key_value_pairs(rule, "prohibited_unless")?, + })) + } +} + +impl ValidateRule for ProhibitedUnless { + fn label(&self) -> &'static str { + "prohibited_unless" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + // Prohibited UNLESS any (key, value) pair matches. + for (check_key, check_value) in &self.keys { + if let Some(value) = data.data.get(check_key.as_str()).copied() + && value == check_value + { + return Ok(false); + } + } + + if is_present_and_nonempty(data.data.get(key)) { + let pairs = self + .keys + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", "); + Err(compact_str::format_compact!( + "is prohibited unless one of [{pairs}]" + )) + } else { + Ok(false) + } + } +} + +/// `prohibits:f1,f2,...` — if this field is present and non-empty, none of the +/// listed fields may be present and non-empty. +pub struct Prohibits { + others: Vec, +} + +impl ParseValidationRule for Prohibits { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Prohibits { + others: parse_field_list(rule, "prohibits")?, + })) + } +} + +impl ValidateRule for Prohibits { + fn label(&self) -> &'static str { + "prohibits" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if !is_present_and_nonempty(data.data.get(key)) { + return Ok(false); + } + + let conflicting: Vec<&str> = self + .others + .iter() + .filter(|f| is_present_and_nonempty(data.data.get(f.as_str()))) + .map(|f| f.as_str()) + .collect(); + + if conflicting.is_empty() { + Ok(false) + } else { + Err(compact_str::format_compact!( + "presence prohibits [{}]", + conflicting.join(", ") + )) + } + } +} diff --git a/rule-validator/src/rules/date.rs b/rule-validator/src/rules/date.rs new file mode 100644 index 000000000..a3414a8b1 --- /dev/null +++ b/rule-validator/src/rules/date.rs @@ -0,0 +1,128 @@ +use crate::{ParseValidationRule, ValidateRule, Validator}; + +fn parse_date_or_field( + arg: &str, + data: &Validator, +) -> Option { + // Try as a literal date first. + if let Ok(d) = arg.parse::() { + return Some(d); + } + // Then as a reference to another field. + data.data + .get(arg) + .and_then(|v| v.parse::().ok()) +} + +pub struct Date; + +impl ParseValidationRule for Date { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Date)) + } +} + +impl ValidateRule for Date { + fn label(&self) -> &'static str { + "date" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.parse::().is_ok() + { + return Ok(false); + } + + Err("must be a valid date".into()) + } +} + +pub struct DateFormat { + format: compact_str::CompactString, +} + +impl ParseValidationRule for DateFormat { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.is_empty() { + return Err("date_format requires a format string".into()); + } + + let format = rule[0].clone(); + Ok(Box::new(DateFormat { format })) + } +} + +impl ValidateRule for DateFormat { + fn label(&self) -> &'static str { + "date_format" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && chrono::NaiveDate::parse_from_str(value, &self.format).is_ok() + { + return Ok(false); + } + + Err(compact_str::format_compact!( + "must match the format '{}'", + self.format + )) + } +} + +macro_rules! date_compare_rule { + ($name:ident, $label:literal, $op:tt, $err:literal) => { + pub struct $name { + other: compact_str::CompactString, + } + + impl ParseValidationRule for $name { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err(concat!($label, " requires one date or field reference").into()); + } + Ok(Box::new($name { + other: rule[0].clone(), + })) + } + } + + impl ValidateRule for $name { + fn label(&self) -> &'static str { + $label + } + + fn validate( + &self, + key: &str, + data: &Validator, + ) -> Result { + if let Some(value) = data.data.get(key) + && let Ok(this_date) = value.parse::() + && let Some(other_date) = parse_date_or_field(&self.other, data) + && this_date $op other_date + { + return Ok(false); + } + + Err(compact_str::format_compact!( + concat!("must be ", $err, " '{}'"), + self.other + )) + } + } + }; +} + +date_compare_rule!(After, "after", >, "after"); +date_compare_rule!(AfterOrEqual, "after_or_equal", >=, "after or equal to"); +date_compare_rule!(Before, "before", <, "before"); +date_compare_rule!(BeforeOrEqual, "before_or_equal", <=, "before or equal to"); diff --git a/rule-validator/src/rules/format.rs b/rule-validator/src/rules/format.rs new file mode 100644 index 000000000..d01f10ebf --- /dev/null +++ b/rule-validator/src/rules/format.rs @@ -0,0 +1,511 @@ +use crate::{ParseValidationRule, ValidateRule, Validator}; + +pub struct Url { + protocols: Vec, +} + +impl ParseValidationRule for Url { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Url { + protocols: rule.to_vec(), + })) + } +} + +impl ValidateRule for Url { + fn label(&self) -> &'static str { + "url" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && let Ok(url) = reqwest::Url::parse(value) + && (self.protocols.is_empty() || self.protocols.contains(&url.scheme().into())) + { + return Ok(false); + } + + if self.protocols.is_empty() { + Err("must be a valid URL".into()) + } else { + Err(compact_str::format_compact!( + "must be a valid URL with one of the following protocols: {}", + self.protocols.join(", ") + )) + } + } +} + +pub struct Uuid; + +impl ParseValidationRule for Uuid { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Uuid)) + } +} + +impl ValidateRule for Uuid { + fn label(&self) -> &'static str { + "uuid" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && uuid::Uuid::parse_str(value).is_ok() + { + return Ok(false); + } + + Err("must be a valid UUID".into()) + } +} + +/// `ulid` — 26 characters in Crockford base32 (0-9, A-Z minus I, L, O, U). +pub struct Ulid; + +const CROCKFORD_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +fn is_crockford_base32_char(c: char) -> bool { + let upper = c.to_ascii_uppercase() as u8; + CROCKFORD_ALPHABET.contains(&upper) +} + +impl ParseValidationRule for Ulid { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Ulid)) + } +} + +impl ValidateRule for Ulid { + fn label(&self) -> &'static str { + "ulid" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.len() == 26 + && value.chars().all(is_crockford_base32_char) + { + return Ok(false); + } + + Err("must be a valid ULID".into()) + } +} + +pub struct Json; + +impl ParseValidationRule for Json { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Json)) + } +} + +impl ValidateRule for Json { + fn label(&self) -> &'static str { + "json" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && serde_json::from_str::(value).is_ok() + { + return Ok(false); + } + + Err("must be valid JSON".into()) + } +} + +pub struct Regex { + pattern: regex::Regex, +} + +impl ParseValidationRule for Regex { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.is_empty() { + return Err("regex requires a regex pattern".into()); + } + + let pattern = rule[0].trim_matches('/').to_string(); + let regex = regex::Regex::new(&pattern) + .map_err(|_| compact_str::CompactString::const_new("invalid regex pattern"))?; + + Ok(Box::new(Regex { pattern: regex })) + } +} + +impl ValidateRule for Regex { + fn label(&self) -> &'static str { + "regex" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && self.pattern.is_match(value) + { + return Ok(false); + } + + Err(compact_str::format_compact!( + "must match the regex pattern '{}'", + self.pattern + )) + } +} + +pub struct NotRegex { + pattern: regex::Regex, +} + +impl ParseValidationRule for NotRegex { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.is_empty() { + return Err("not_regex requires a regex pattern".into()); + } + + let pattern = rule[0].trim_matches('/').to_string(); + let regex = regex::Regex::new(&pattern) + .map_err(|_| compact_str::CompactString::const_new("invalid regex pattern"))?; + + Ok(Box::new(NotRegex { pattern: regex })) + } +} + +impl ValidateRule for NotRegex { + fn label(&self) -> &'static str { + "not_regex" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && !self.pattern.is_match(value) + { + return Ok(false); + } + + Err(compact_str::format_compact!( + "must not match the regex pattern '{}'", + self.pattern + )) + } +} + +pub struct Ip; + +impl ParseValidationRule for Ip { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Ip)) + } +} + +impl ValidateRule for Ip { + fn label(&self) -> &'static str { + "ip" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.parse::().is_ok() + { + return Ok(false); + } + + Err("must be a valid IP address".into()) + } +} + +pub struct Ipv4; + +impl ParseValidationRule for Ipv4 { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Ipv4)) + } +} + +impl ValidateRule for Ipv4 { + fn label(&self) -> &'static str { + "ipv4" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.parse::().is_ok() + { + return Ok(false); + } + + Err("must be a valid IPv4 address".into()) + } +} + +pub struct Ipv6; + +impl ParseValidationRule for Ipv6 { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Ipv6)) + } +} + +impl ValidateRule for Ipv6 { + fn label(&self) -> &'static str { + "ipv6" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.parse::().is_ok() + { + return Ok(false); + } + + Err("must be a valid IPv6 address".into()) + } +} + +pub struct MacAddress; + +impl ParseValidationRule for MacAddress { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(MacAddress)) + } +} + +impl ValidateRule for MacAddress { + fn label(&self) -> &'static str { + "mac_address" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + let parts: Vec<&str> = value.split(':').collect(); + if parts.len() == 6 + && parts + .iter() + .all(|part| part.len() == 2 && part.chars().all(|c| c.is_ascii_hexdigit())) + { + return Ok(false); + } + } + + Err("must be a valid MAC address".into()) + } +} + +pub struct HexColor; + +impl ParseValidationRule for HexColor { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(HexColor)) + } +} + +impl ValidateRule for HexColor { + fn label(&self) -> &'static str { + "hex_color" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && let Some(rest) = value.strip_prefix('#') + && matches!(rest.len(), 3 | 4 | 6 | 8) + && rest.chars().all(|c| c.is_ascii_hexdigit()) + { + return Ok(false); + } + + Err("must be a valid hex color code".into()) + } +} + +pub struct Timezone; + +impl ParseValidationRule for Timezone { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Timezone)) + } +} + +impl ValidateRule for Timezone { + fn label(&self) -> &'static str { + "timezone" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key).copied() + && (value.parse::().is_ok() + || value.parse::().is_ok()) + { + return Ok(false); + } + + Err("must be a valid timezone".into()) + } +} + +pub struct In { + options: Vec, +} + +impl ParseValidationRule for In { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.is_empty() { + return Err("in requires at least one option".into()); + } + + Ok(Box::new(In { + options: rule.to_vec(), + })) + } +} + +impl ValidateRule for In { + fn label(&self) -> &'static str { + "in" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key).copied() + && self.options.iter().any(|option| option == value) + { + return Ok(false); + } + + Err(compact_str::format_compact!( + "must be one of: {}", + self.options.join(", ") + )) + } +} + +pub struct NotIn { + options: Vec, +} + +impl ParseValidationRule for NotIn { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.is_empty() { + return Err("not_in requires at least one option".into()); + } + + Ok(Box::new(NotIn { + options: rule.to_vec(), + })) + } +} + +impl ValidateRule for NotIn { + fn label(&self) -> &'static str { + "not_in" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key).copied() + && !self.options.iter().any(|option| option == value) + { + return Ok(false); + } + + Err(compact_str::format_compact!( + "must not be one of: {}", + self.options.join(", ") + )) + } +} + +pub struct Boolean; + +impl ParseValidationRule for Boolean { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Boolean)) + } +} + +impl ValidateRule for Boolean { + fn label(&self) -> &'static str { + "boolean" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + match data.data.get(key).copied() { + Some("true" | "1" | "yes" | "on" | "false" | "0" | "no" | "off") => Ok(false), + _ => Err("must be a boolean (true/false, 1/0, yes/no, on/off)".into()), + } + } +} + +pub struct Accepted; + +impl ParseValidationRule for Accepted { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Accepted)) + } +} + +impl ValidateRule for Accepted { + fn label(&self) -> &'static str { + "accepted" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + match data.data.get(key).copied() { + Some("true" | "1" | "yes" | "on") => Ok(false), + _ => Err("value must be 'true', '1', 'yes', or 'on'".into()), + } + } +} + +pub struct Declined; + +impl ParseValidationRule for Declined { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Declined)) + } +} + +impl ValidateRule for Declined { + fn label(&self) -> &'static str { + "declined" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + match data.data.get(key).copied() { + Some("false" | "0" | "no" | "off") => Ok(false), + _ => Err("value must be 'false', '0', 'no', or 'off'".into()), + } + } +} diff --git a/rule-validator/src/rules/macros.rs b/rule-validator/src/rules/macros.rs new file mode 100644 index 000000000..2491b408c --- /dev/null +++ b/rule-validator/src/rules/macros.rs @@ -0,0 +1,66 @@ +/// Parse-and-accept rule that has no functional effect. +/// +/// Used for Laravel rules that don't apply to scalar env-var validation +/// (e.g. `array`, `list`, `bail`, `sometimes`, `present`, `missing`). +/// The rule parses any args, accepts any value, and never errors. +macro_rules! no_op_rule { + ($name:ident, $label:literal) => { + pub struct $name; + + impl $crate::ParseValidationRule for $name { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new($name)) + } + } + + impl $crate::ValidateRule for $name { + fn label(&self) -> &'static str { + $label + } + + fn validate( + &self, + _key: &str, + _data: &$crate::Validator, + ) -> Result { + Ok(false) + } + } + }; +} + +/// Hard-rejected rule that returns a clear parse error for Laravel rules we +/// cannot honor on scalar env-var data (file/image/exists/etc.). +macro_rules! unsupported_rule { + ($name:ident, $label:literal) => { + pub struct $name; + + impl $crate::ParseValidationRule for $name { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Err(compact_str::format_compact!( + "{}: unsupported in env-variable validator", + $label + )) + } + } + + impl $crate::ValidateRule for $name { + fn label(&self) -> &'static str { + $label + } + + fn validate( + &self, + _key: &str, + _data: &$crate::Validator, + ) -> Result { + Ok(false) + } + } + }; +} + diff --git a/rule-validator/src/rules/mod.rs b/rule-validator/src/rules/mod.rs new file mode 100644 index 000000000..6ff8c152f --- /dev/null +++ b/rule-validator/src/rules/mod.rs @@ -0,0 +1,172 @@ +use super::{ParseValidationRule, ValidateRule}; + +#[macro_use] +mod macros; + +pub mod comparison; +pub mod compat; +pub mod conditional; +pub mod date; +pub mod format; +pub mod numeric; +pub mod presence; +pub mod string; +pub mod unsupported; + +pub fn parse_validation_rule( + rule: &str, +) -> Result, compact_str::CompactString> { + let mut rule_parts = rule.splitn(2, ':'); + let rule_name = rule_parts.next().ok_or("invalid rule format")?; + let raw_args = rule_parts.next().unwrap_or(""); + + // regex / not_regex / date_format patterns may legitimately contain `,` + // (e.g. `regex:/^[0-9,]+$/`), so pass the whole arg string through as a + // single token rather than splitting it. + let rule_args: Vec = if raw_args.is_empty() { + Vec::new() + } else if matches!(rule_name, "regex" | "not_regex" | "date_format") { + vec![compact_str::CompactString::from(raw_args)] + } else { + raw_args + .split(',') + .map(compact_str::CompactString::from) + .collect() + }; + + match rule_name { + // presence.rs + "required" => presence::Required::parse_rule(&rule_args), + "nullable" => presence::Nullable::parse_rule(&rule_args), + "filled" => presence::Filled::parse_rule(&rule_args), + "confirmed" => presence::Confirmed::parse_rule(&rule_args), + "bail" => presence::Bail::parse_rule(&rule_args), + "sometimes" => presence::Sometimes::parse_rule(&rule_args), + "present" => presence::Present::parse_rule(&rule_args), + "present_if" => presence::PresentIf::parse_rule(&rule_args), + "present_unless" => presence::PresentUnless::parse_rule(&rule_args), + "present_with" => presence::PresentWith::parse_rule(&rule_args), + "present_with_all" => presence::PresentWithAll::parse_rule(&rule_args), + "missing" => presence::Missing::parse_rule(&rule_args), + "missing_if" => presence::MissingIf::parse_rule(&rule_args), + "missing_unless" => presence::MissingUnless::parse_rule(&rule_args), + "missing_with" => presence::MissingWith::parse_rule(&rule_args), + "missing_with_all" => presence::MissingWithAll::parse_rule(&rule_args), + + // string.rs + "alpha" => string::Alpha::parse_rule(&rule_args), + "alpha_dash" => string::AlphaDash::parse_rule(&rule_args), + "alpha_num" => string::AlphaNum::parse_rule(&rule_args), + "ascii" => string::Ascii::parse_rule(&rule_args), + "lowercase" => string::Lowercase::parse_rule(&rule_args), + "uppercase" => string::Uppercase::parse_rule(&rule_args), + "email" => string::Email::parse_rule(&rule_args), + "starts_with" => string::StartsWith::parse_rule(&rule_args), + "ends_with" => string::EndsWith::parse_rule(&rule_args), + "doesnt_start_with" => string::DoesntStartWith::parse_rule(&rule_args), + "doesnt_end_with" => string::DoesntEndWith::parse_rule(&rule_args), + "string" | "str" => string::StringRule::parse_rule(&rule_args), + + // numeric.rs + "numeric" | "num" => numeric::Numeric::parse_rule(&rule_args), + "integer" | "int" => numeric::Integer::parse_rule(&rule_args), + "decimal" => numeric::Decimal::parse_rule(&rule_args), + "digits" => numeric::Digits::parse_rule(&rule_args), + "digits_between" => numeric::DigitsBetween::parse_rule(&rule_args), + "max_digits" => numeric::MaxDigits::parse_rule(&rule_args), + "min_digits" => numeric::MinDigits::parse_rule(&rule_args), + "multiple_of" => numeric::MultipleOf::parse_rule(&rule_args), + + // comparison.rs + "min" => comparison::Min::parse_rule(&rule_args), + "max" => comparison::Max::parse_rule(&rule_args), + "between" => comparison::Between::parse_rule(&rule_args), + "size" => comparison::Size::parse_rule(&rule_args), + "gt" => comparison::Gt::parse_rule(&rule_args), + "gte" => comparison::Gte::parse_rule(&rule_args), + "lt" => comparison::Lt::parse_rule(&rule_args), + "lte" => comparison::Lte::parse_rule(&rule_args), + "same" => comparison::Same::parse_rule(&rule_args), + "different" => comparison::Different::parse_rule(&rule_args), + + // conditional.rs + "accepted_if" => conditional::AcceptedIf::parse_rule(&rule_args), + "declined_if" => conditional::DeclinedIf::parse_rule(&rule_args), + "required_if" => conditional::RequiredIf::parse_rule(&rule_args), + "required_unless" => conditional::RequiredUnless::parse_rule(&rule_args), + "required_if_accepted" => conditional::RequiredIfAccepted::parse_rule(&rule_args), + "required_if_declined" => conditional::RequiredIfDeclined::parse_rule(&rule_args), + "required_with" => conditional::RequiredWith::parse_rule(&rule_args), + "required_with_all" => conditional::RequiredWithAll::parse_rule(&rule_args), + "required_without" => conditional::RequiredWithout::parse_rule(&rule_args), + "required_without_all" => conditional::RequiredWithoutAll::parse_rule(&rule_args), + "prohibited" => conditional::Prohibited::parse_rule(&rule_args), + "prohibited_if" => conditional::ProhibitedIf::parse_rule(&rule_args), + "prohibited_unless" => conditional::ProhibitedUnless::parse_rule(&rule_args), + "prohibits" => conditional::Prohibits::parse_rule(&rule_args), + + // format.rs + "url" => format::Url::parse_rule(&rule_args), + "uuid" => format::Uuid::parse_rule(&rule_args), + "ulid" => format::Ulid::parse_rule(&rule_args), + "json" => format::Json::parse_rule(&rule_args), + "regex" => format::Regex::parse_rule(&rule_args), + "not_regex" => format::NotRegex::parse_rule(&rule_args), + "ip" => format::Ip::parse_rule(&rule_args), + "ipv4" => format::Ipv4::parse_rule(&rule_args), + "ipv6" => format::Ipv6::parse_rule(&rule_args), + "mac_address" => format::MacAddress::parse_rule(&rule_args), + "hex_color" => format::HexColor::parse_rule(&rule_args), + "timezone" => format::Timezone::parse_rule(&rule_args), + "in" => format::In::parse_rule(&rule_args), + "not_in" => format::NotIn::parse_rule(&rule_args), + "boolean" | "bool" => format::Boolean::parse_rule(&rule_args), + "accepted" => format::Accepted::parse_rule(&rule_args), + "declined" => format::Declined::parse_rule(&rule_args), + + // date.rs + "date" => date::Date::parse_rule(&rule_args), + "date_format" => date::DateFormat::parse_rule(&rule_args), + "after" => date::After::parse_rule(&rule_args), + "after_or_equal" => date::AfterOrEqual::parse_rule(&rule_args), + "before" => date::Before::parse_rule(&rule_args), + "before_or_equal" => date::BeforeOrEqual::parse_rule(&rule_args), + + // compat.rs — parse-and-accept no-ops + "array" => compat::Array::parse_rule(&rule_args), + "list" => compat::List::parse_rule(&rule_args), + "distinct" => compat::Distinct::parse_rule(&rule_args), + "in_array" => compat::InArray::parse_rule(&rule_args), + "in_array_keys" => compat::InArrayKeys::parse_rule(&rule_args), + "contains" => compat::Contains::parse_rule(&rule_args), + "doesnt_contain" => compat::DoesntContain::parse_rule(&rule_args), + "required_array_keys" => compat::RequiredArrayKeys::parse_rule(&rule_args), + "exclude" => compat::Exclude::parse_rule(&rule_args), + "exclude_if" => compat::ExcludeIf::parse_rule(&rule_args), + "exclude_unless" => compat::ExcludeUnless::parse_rule(&rule_args), + "exclude_with" => compat::ExcludeWith::parse_rule(&rule_args), + "exclude_without" => compat::ExcludeWithout::parse_rule(&rule_args), + "prohibited_if_accepted" => compat::ProhibitedIfAccepted::parse_rule(&rule_args), + "prohibited_if_declined" => compat::ProhibitedIfDeclined::parse_rule(&rule_args), + "date_equals" => compat::DateEquals::parse_rule(&rule_args), + + // unsupported.rs — hard-reject with a clear error + "file" => unsupported::File::parse_rule(&rule_args), + "image" => unsupported::Image::parse_rule(&rule_args), + "mimes" => unsupported::Mimes::parse_rule(&rule_args), + "mimetypes" => unsupported::Mimetypes::parse_rule(&rule_args), + "extensions" => unsupported::Extensions::parse_rule(&rule_args), + "encoding" => unsupported::Encoding::parse_rule(&rule_args), + "dimensions" => unsupported::Dimensions::parse_rule(&rule_args), + "exists" => unsupported::Exists::parse_rule(&rule_args), + "unique" => unsupported::Unique::parse_rule(&rule_args), + "current_password" => unsupported::CurrentPassword::parse_rule(&rule_args), + "password" => unsupported::Password::parse_rule(&rule_args), + "active_url" => unsupported::ActiveUrl::parse_rule(&rule_args), + "enum" => unsupported::Enum::parse_rule(&rule_args), + + rule => Err(compact_str::format_compact!( + "unknown or unsupported validation rule: {rule}" + )), + } +} diff --git a/rule-validator/src/rules/numeric.rs b/rule-validator/src/rules/numeric.rs new file mode 100644 index 000000000..ef994b81a --- /dev/null +++ b/rule-validator/src/rules/numeric.rs @@ -0,0 +1,326 @@ +use crate::{ParseValidationRule, ValidateRule, Validator}; + +pub struct Numeric; + +impl ParseValidationRule for Numeric { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Numeric)) + } +} + +impl ValidateRule for Numeric { + fn label(&self) -> &'static str { + "numeric" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.parse::().is_ok() + { + return Ok(false); + } + + Err("must be a valid numeric value".into()) + } +} + +pub struct Integer; + +impl ParseValidationRule for Integer { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Integer)) + } +} + +impl ValidateRule for Integer { + fn label(&self) -> &'static str { + "integer" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.parse::().is_ok() + { + return Ok(false); + } + + Err("must be a valid integer".into()) + } +} + +/// `decimal:N` (exact N decimal places) or `decimal:N,M` (between N and M). +pub struct Decimal { + min: usize, + max: usize, +} + +impl ParseValidationRule for Decimal { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + let (min, max) = match rule.len() { + 1 => { + let n = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for decimal"))?; + (n, n) + } + 2 => { + let n = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for decimal"))?; + let m = rule[1] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for decimal"))?; + (n, m) + } + _ => return Err("decimal requires one or two numeric values".into()), + }; + + Ok(Box::new(Decimal { min, max })) + } +} + +impl ValidateRule for Decimal { + fn label(&self) -> &'static str { + "decimal" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.parse::().is_ok() + { + let decimals = match value.split_once('.') { + Some((_, frac)) => frac.len(), + None => 0, + }; + if decimals >= self.min && decimals <= self.max { + return Ok(false); + } + } + + if self.min == self.max { + Err(compact_str::format_compact!( + "must have exactly {} decimal places", + self.min + )) + } else { + Err(compact_str::format_compact!( + "must have between {} and {} decimal places", + self.min, + self.max + )) + } + } +} + +pub struct Digits { + length: usize, +} + +impl ParseValidationRule for Digits { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("digits requires one numeric value for length".into()); + } + + let length = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid length value"))?; + + Ok(Box::new(Digits { length })) + } +} + +impl ValidateRule for Digits { + fn label(&self) -> &'static str { + "digits" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.chars().all(|c| c.is_ascii_digit()) + && value.len() == self.length + { + return Ok(false); + } + + Err(compact_str::format_compact!( + "must contain exactly {} digits", + self.length + )) + } +} + +pub struct DigitsBetween { + minimum: usize, + maximum: usize, +} + +impl ParseValidationRule for DigitsBetween { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 2 { + return Err("digits_between requires two numeric values".into()); + } + + let minimum = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid minimum value"))?; + let maximum = rule[1] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid maximum value"))?; + + Ok(Box::new(DigitsBetween { minimum, maximum })) + } +} + +impl ValidateRule for DigitsBetween { + fn label(&self) -> &'static str { + "digits_between" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.chars().all(|c| c.is_ascii_digit()) + { + let len = value.len(); + if len >= self.minimum && len <= self.maximum { + return Ok(false); + } + } + + Err(compact_str::format_compact!( + "must contain between {} and {} digits", + self.minimum, + self.maximum + )) + } +} + +pub struct MaxDigits { + value: usize, +} + +impl ParseValidationRule for MaxDigits { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("max_digits requires one numeric value".into()); + } + + let value = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for max_digits"))?; + + Ok(Box::new(MaxDigits { value })) + } +} + +impl ValidateRule for MaxDigits { + fn label(&self) -> &'static str { + "max_digits" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.chars().all(|c| c.is_ascii_digit()) + && value.len() <= self.value + { + return Ok(false); + } + + Err(compact_str::format_compact!( + "must contain at most {} digits", + self.value + )) + } +} + +pub struct MinDigits { + value: usize, +} + +impl ParseValidationRule for MinDigits { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("min_digits requires one numeric value".into()); + } + + let value = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for min_digits"))?; + + Ok(Box::new(MinDigits { value })) + } +} + +impl ValidateRule for MinDigits { + fn label(&self) -> &'static str { + "min_digits" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.chars().all(|c| c.is_ascii_digit()) + && value.len() >= self.value + { + return Ok(false); + } + + Err(compact_str::format_compact!( + "must contain at least {} digits", + self.value + )) + } +} + +pub struct MultipleOf { + value: f64, +} + +impl ParseValidationRule for MultipleOf { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.len() != 1 { + return Err("multiple_of requires one numeric value".into()); + } + + let value = rule[0] + .parse::() + .map_err(|_| compact_str::CompactString::const_new("invalid value for multiple_of"))?; + + Ok(Box::new(MultipleOf { value })) + } +} + +impl ValidateRule for MultipleOf { + fn label(&self) -> &'static str { + "multiple_of" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && let Ok(num) = value.parse::() + && num % self.value == 0.0 + { + return Ok(false); + } + + Err(compact_str::format_compact!( + "must be a multiple of {}", + self.value + )) + } +} diff --git a/rule-validator/src/rules/presence.rs b/rule-validator/src/rules/presence.rs new file mode 100644 index 000000000..3713c5e4b --- /dev/null +++ b/rule-validator/src/rules/presence.rs @@ -0,0 +1,140 @@ +use crate::{ParseValidationRule, ValidateRule, Validator}; + +/// `required` — field must be present and non-empty. +/// +/// Treats both `""` and the literal string `"null"` as empty so that `required` +/// and [`Nullable`] agree on what "empty" means in this scalar env-var model. +pub struct Required; + +impl ParseValidationRule for Required { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Required)) + } +} + +impl ValidateRule for Required { + fn label(&self) -> &'static str { + "required" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key).copied() + && !value.is_empty() + && value != "null" + { + return Ok(false); + } + + Err("is required and cannot be empty".into()) + } +} + +/// `nullable` — short-circuit further validation for this field when the value +/// is empty or the literal string `"null"`. +pub struct Nullable; + +impl ParseValidationRule for Nullable { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Nullable)) + } +} + +impl ValidateRule for Nullable { + fn label(&self) -> &'static str { + "nullable" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key).copied() + && (value.is_empty() || value == "null") + { + return Ok(true); + } + + Ok(false) + } +} + +/// `filled` — if the field is present, it must be non-empty. Distinct from +/// `required`: absent fields are OK, only present-and-empty is an error. +pub struct Filled; + +impl ParseValidationRule for Filled { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Filled)) + } +} + +impl ValidateRule for Filled { + fn label(&self) -> &'static str { + "filled" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + match data.data.get(key).copied() { + Some(value) if value.is_empty() || value == "null" => { + Err("must not be empty when present".into()) + } + _ => Ok(false), + } + } +} + +pub struct Confirmed; + +impl ParseValidationRule for Confirmed { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Confirmed)) + } +} + +impl ValidateRule for Confirmed { + fn label(&self) -> &'static str { + "confirmed" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + let confirm_key = format!("{key}_confirmation"); + if let Some(value) = data.data.get(key) + && let Some(confirm_value) = data.data.get(confirm_key.as_str()) + && value == confirm_value + { + return Ok(false); + } + + Err(compact_str::format_compact!( + "does not match confirmation field '{confirm_key}'" + )) + } +} + +// `bail` and `sometimes` are control flags in Laravel that the current +// validator's per-field, first-error semantics already cover. We accept them +// for Laravel-string compatibility but they have no runtime effect here. +no_op_rule!(Bail, "bail"); +no_op_rule!(Sometimes, "sometimes"); + +// `present` and its variants require the key to exist in the input. In our +// flat `HashMap<&str, &str>` model, the key always exists if any rule was +// defined for it, so these are trivially true. Parsed for import compat. +no_op_rule!(Present, "present"); +no_op_rule!(PresentIf, "present_if"); +no_op_rule!(PresentUnless, "present_unless"); +no_op_rule!(PresentWith, "present_with"); +no_op_rule!(PresentWithAll, "present_with_all"); + +// `missing` and variants are the inverse — field must NOT be present. Env vars +// are always supplied in this model, so these are no-ops for import compat. +no_op_rule!(Missing, "missing"); +no_op_rule!(MissingIf, "missing_if"); +no_op_rule!(MissingUnless, "missing_unless"); +no_op_rule!(MissingWith, "missing_with"); +no_op_rule!(MissingWithAll, "missing_with_all"); diff --git a/rule-validator/src/rules/string.rs b/rule-validator/src/rules/string.rs new file mode 100644 index 000000000..23b7b39c7 --- /dev/null +++ b/rule-validator/src/rules/string.rs @@ -0,0 +1,396 @@ +use std::sync::OnceLock; + +use crate::{ParseValidationRule, ValidateRule, Validator}; + +pub struct Alpha { + only_ascii: bool, +} + +impl ParseValidationRule for Alpha { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + let only_ascii = rule.first().is_some_and(|s| s == "ascii"); + Ok(Box::new(Alpha { only_ascii })) + } +} + +impl ValidateRule for Alpha { + fn label(&self) -> &'static str { + "alpha" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + let ok = if self.only_ascii { + value.chars().all(|c| c.is_ascii_alphabetic()) + } else { + value.chars().all(|c| c.is_alphabetic()) + }; + if ok { + return Ok(false); + } + } + + Err("must contain only alphabetic characters".into()) + } +} + +pub struct AlphaDash { + only_ascii: bool, +} + +impl ParseValidationRule for AlphaDash { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + let only_ascii = rule.first().is_some_and(|s| s == "ascii"); + Ok(Box::new(AlphaDash { only_ascii })) + } +} + +impl ValidateRule for AlphaDash { + fn label(&self) -> &'static str { + "alpha_dash" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + let ok = if self.only_ascii { + value + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + } else { + value + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + }; + if ok { + return Ok(false); + } + } + + Err("must contain only alphanumeric characters, dashes, or underscores".into()) + } +} + +pub struct AlphaNum { + only_ascii: bool, +} + +impl ParseValidationRule for AlphaNum { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + let only_ascii = rule.first().is_some_and(|s| s == "ascii"); + Ok(Box::new(AlphaNum { only_ascii })) + } +} + +impl ValidateRule for AlphaNum { + fn label(&self) -> &'static str { + "alpha_num" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + let ok = if self.only_ascii { + value.chars().all(|c| c.is_ascii_alphanumeric()) + } else { + value.chars().all(|c| c.is_alphanumeric()) + }; + if ok { + return Ok(false); + } + } + + Err("must contain only alphanumeric characters".into()) + } +} + +pub struct Ascii; + +impl ParseValidationRule for Ascii { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Ascii)) + } +} + +impl ValidateRule for Ascii { + fn label(&self) -> &'static str { + "ascii" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.is_ascii() + { + return Ok(false); + } + + Err("must contain only ASCII characters".into()) + } +} + +pub struct Lowercase; + +impl ParseValidationRule for Lowercase { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Lowercase)) + } +} + +impl ValidateRule for Lowercase { + fn label(&self) -> &'static str { + "lowercase" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.chars().all(|c| !c.is_alphabetic() || c.is_lowercase()) + { + return Ok(false); + } + + Err("must be lowercase".into()) + } +} + +pub struct Uppercase; + +impl ParseValidationRule for Uppercase { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Uppercase)) + } +} + +impl ValidateRule for Uppercase { + fn label(&self) -> &'static str { + "uppercase" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && value.chars().all(|c| !c.is_alphabetic() || c.is_uppercase()) + { + return Ok(false); + } + + Err("must be uppercase".into()) + } +} + +/// `email` — regex-based check (not DNS or RFC-strict). Laravel offers +/// stricter modes (`rfc`, `dns`, `spoof`, `filter`) that we do not replicate. +pub struct Email; + +fn email_regex() -> &'static regex::Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + regex::Regex::new(r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,63}$").unwrap() + }) +} + +impl ParseValidationRule for Email { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(Email)) + } +} + +impl ValidateRule for Email { + fn label(&self) -> &'static str { + "email" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) + && email_regex().is_match(value) + { + return Ok(false); + } + + Err("must be a valid email address".into()) + } +} + +pub struct StartsWith { + prefixes: Vec, +} + +impl ParseValidationRule for StartsWith { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.is_empty() { + return Err("starts_with requires at least one prefix".into()); + } + + Ok(Box::new(StartsWith { + prefixes: rule.to_vec(), + })) + } +} + +impl ValidateRule for StartsWith { + fn label(&self) -> &'static str { + "starts_with" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + for prefix in &self.prefixes { + if value.starts_with(&**prefix) { + return Ok(false); + } + } + } + + Err(compact_str::format_compact!( + "must start with one of: {}", + self.prefixes.join(", ") + )) + } +} + +/// `ends_with` — previously inverted (copy-paste of `doesnt_end_with`); now +/// correctly accepts values that end with any of the listed suffixes. +pub struct EndsWith { + suffixes: Vec, +} + +impl ParseValidationRule for EndsWith { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.is_empty() { + return Err("ends_with requires at least one suffix".into()); + } + + Ok(Box::new(EndsWith { + suffixes: rule.to_vec(), + })) + } +} + +impl ValidateRule for EndsWith { + fn label(&self) -> &'static str { + "ends_with" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + for suffix in &self.suffixes { + if value.ends_with(&**suffix) { + return Ok(false); + } + } + } + + Err(compact_str::format_compact!( + "must end with one of: {}", + self.suffixes.join(", ") + )) + } +} + +pub struct DoesntStartWith { + prefixes: Vec, +} + +impl ParseValidationRule for DoesntStartWith { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.is_empty() { + return Err("doesnt_start_with requires at least one prefix".into()); + } + + Ok(Box::new(DoesntStartWith { + prefixes: rule.to_vec(), + })) + } +} + +impl ValidateRule for DoesntStartWith { + fn label(&self) -> &'static str { + "doesnt_start_with" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + for prefix in &self.prefixes { + if value.starts_with(&**prefix) { + return Err(compact_str::format_compact!( + "must not start with '{prefix}'" + )); + } + } + } + + Ok(false) + } +} + +pub struct DoesntEndWith { + suffixes: Vec, +} + +impl ParseValidationRule for DoesntEndWith { + fn parse_rule( + rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + if rule.is_empty() { + return Err("doesnt_end_with requires at least one suffix".into()); + } + + Ok(Box::new(DoesntEndWith { + suffixes: rule.to_vec(), + })) + } +} + +impl ValidateRule for DoesntEndWith { + fn label(&self) -> &'static str { + "doesnt_end_with" + } + + fn validate(&self, key: &str, data: &Validator) -> Result { + if let Some(value) = data.data.get(key) { + for suffix in &self.suffixes { + if value.ends_with(&**suffix) { + return Err(compact_str::format_compact!("must not end with '{suffix}'")); + } + } + } + + Ok(false) + } +} + +pub struct StringRule; + +impl ParseValidationRule for StringRule { + fn parse_rule( + _rule: &[compact_str::CompactString], + ) -> Result, compact_str::CompactString> { + Ok(Box::new(StringRule)) + } +} + +impl ValidateRule for StringRule { + fn label(&self) -> &'static str { + "string" + } + + fn validate(&self, _key: &str, _data: &Validator) -> Result { + Ok(false) + } +} diff --git a/rule-validator/src/rules/unsupported.rs b/rule-validator/src/rules/unsupported.rs new file mode 100644 index 000000000..09e347f92 --- /dev/null +++ b/rule-validator/src/rules/unsupported.rs @@ -0,0 +1,22 @@ +// File-related rules — env vars are strings, never file uploads. +unsupported_rule!(File, "file"); +unsupported_rule!(Image, "image"); +unsupported_rule!(Mimes, "mimes"); +unsupported_rule!(Mimetypes, "mimetypes"); +unsupported_rule!(Extensions, "extensions"); +unsupported_rule!(Encoding, "encoding"); +unsupported_rule!(Dimensions, "dimensions"); + +// Database-backed rules — no DB lookup in this validator. +unsupported_rule!(Exists, "exists"); +unsupported_rule!(Unique, "unique"); + +// Auth-context rules — not applicable to env vars. +unsupported_rule!(CurrentPassword, "current_password"); +unsupported_rule!(Password, "password"); + +// Network-effecting rule — would do DNS/HTTP at validate time. +unsupported_rule!(ActiveUrl, "active_url"); + +// PHP-typed enum class — semantically equivalent to `in:` in our context. +unsupported_rule!(Enum, "enum"); diff --git a/rule-validator/tests/compat_noops.rs b/rule-validator/tests/compat_noops.rs new file mode 100644 index 000000000..7a166449a --- /dev/null +++ b/rule-validator/tests/compat_noops.rs @@ -0,0 +1,81 @@ +use compact_str::CompactString; + +fn rules(strs: &[&str]) -> Vec { + strs.iter().map(|s| CompactString::from(*s)).collect() +} + +#[test] +fn parse_and_accept_rules_do_not_error_on_validate_rules() { + // Each of these used to be unknown — now they're parse-and-accept no-ops. + let cases: &[&[&str]] = &[ + &["bail"], + &["sometimes"], + &["present"], + &["present_if:other,x"], + &["present_unless:other,x"], + &["present_with:other"], + &["present_with_all:a,b"], + &["missing"], + &["missing_if:other,x"], + &["missing_unless:other,x"], + &["missing_with:other"], + &["missing_with_all:a,b"], + &["array"], + &["list"], + &["distinct"], + &["in_array:other.*"], + &["in_array_keys:k1,k2"], + &["contains:a,b"], + &["doesnt_contain:a,b"], + &["required_array_keys:k1,k2"], + &["exclude"], + &["exclude_if:other,x"], + &["exclude_unless:other,x"], + &["exclude_with:other"], + &["exclude_without:other"], + &["prohibited_if_accepted:other"], + &["prohibited_if_declined:other"], + &["date_equals:2020-01-01"], + ]; + + for case in cases { + let r = rule_validator::validate_rules(&rules(case), &()); + assert!(r.is_ok(), "expected ok for {case:?}, got {r:?}"); + } +} + +#[test] +fn hard_rejected_rules_error_with_clear_message() { + let cases: &[&str] = &[ + "file", + "image", + "mimes:jpg,png", + "mimetypes:image/jpeg", + "extensions:jpg,png", + "encoding:utf-8", + "dimensions:min_width=100", + "exists:users,id", + "unique:users", + "current_password", + "password", + "active_url", + "enum:Foo", + ]; + + for case in cases { + let r = rule_validator::validate_rules(&rules(&[case]), &()); + assert!(r.is_err(), "expected err for {case:?}, got {r:?}"); + let msg = format!("{:?}", r.unwrap_err()); + assert!( + msg.contains("unsupported in env-variable validator"), + "expected 'unsupported in env-variable validator' in error for {case:?}, got {msg}" + ); + } +} + +#[test] +fn unknown_rule_still_errors() { + // Truly unknown rules still fail (not parse-and-accept). + let r = rule_validator::validate_rules(&rules(&["this_does_not_exist"]), &()); + assert!(r.is_err()); +} diff --git a/rule-validator/tests/fixtures/eggs/README.md b/rule-validator/tests/fixtures/eggs/README.md new file mode 100644 index 000000000..cdc77e636 --- /dev/null +++ b/rule-validator/tests/fixtures/eggs/README.md @@ -0,0 +1,29 @@ +# Egg fixtures + +Real-world Pterodactyl-format egg JSON files vendored from upstream egg repos to +test that `rule-validator` parses every rule string they contain. Both panels +write the `rules` field as a single pipe-delimited string (e.g. +`"required|string|max:20"`); the test splits on `|` before calling +`validate_rules`. + +Files here are unmodified copies. Upstream `LICENSE` files are reproduced next +to the eggs they cover. + +## Sources + +Each file is pinned to a specific upstream commit. To refresh, re-pin the SHA +and re-copy. + +| Local path | Upstream | Commit SHA | +|------------------------------------|-----------------------------|--------------------------------------------| +| `pterodactyl/minecraft-paper.json` | pterodactyl/game-eggs | `fdeead688aac4d5a67a5116a8dc07bef691e7588` | +| `pterodactyl/factorio.json` | pterodactyl/game-eggs | `fdeead688aac4d5a67a5116a8dc07bef691e7588` | +| `pterodactyl/ark-survival.json` | pterodactyl/game-eggs | `fdeead688aac4d5a67a5116a8dc07bef691e7588` | +| `pelican/minecraft-paper.json` | pelican-eggs/minecraft | `75bf05db3c6c305e0fa6eef1d38c7e7176121de9` | +| `pelican/steamcmd-rust.json` | pelican-eggs/games-steamcmd | `46dc04e7375af97695b3753dc815fba200676596` | +| `pelican/generic-nodejs.json` | pelican-eggs/generic | `0080f55043d7849b81ef6abc4085692d98bab451` | + +The single `pelican/LICENSE` covers all three `pelican-eggs/*` source repos +(verified identical at the pinned SHAs). + +Both upstreams are MIT-licensed. diff --git a/rule-validator/tests/fixtures/eggs/pelican/LICENSE b/rule-validator/tests/fixtures/eggs/pelican/LICENSE new file mode 100644 index 000000000..816a92cba --- /dev/null +++ b/rule-validator/tests/fixtures/eggs/pelican/LICENSE @@ -0,0 +1,21 @@ +MIT + +Copyright (c) 2018 Michael Parker and Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/rule-validator/tests/fixtures/eggs/pelican/generic-nodejs.json b/rule-validator/tests/fixtures/eggs/pelican/generic-nodejs.json new file mode 100644 index 000000000..9ce206a96 --- /dev/null +++ b/rule-validator/tests/fixtures/eggs/pelican/generic-nodejs.json @@ -0,0 +1,136 @@ +{ + "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO", + "meta": { + "version": "PTDL_v2", + "update_url": null + }, + "exported_at": "2025-10-20T09:57:28+08:00", + "name": "node.js generic", + "author": "parker@parkervcp.com", + "description": "a generic node.js egg\r\n\r\nThis will clone a git repo. it defaults to master if no branch is specified.\r\n\r\nInstalls the node_modules on install. If you set user_upload then I assume you know what you are doing.", + "features": null, + "docker_images": { + "Nodejs 20": "ghcr.io\/pelican-eggs\/yolks:nodejs_20", + "Nodejs 21": "ghcr.io\/pelican-eggs\/yolks:nodejs_21", + "Nodejs 22": "ghcr.io\/pelican-eggs\/yolks:nodejs_22", + "Nodejs 23": "ghcr.io\/pelican-eggs\/yolks:nodejs_23", + "Nodejs 24": "ghcr.io\/pelican-eggs\/yolks:nodejs_24" + }, + "file_denylist": [], + "startup": "if [[ -d .git ]] && [[ {{AUTO_UPDATE}} == \"1\" ]]; then git pull; fi; if [[ ! -z ${NODE_PACKAGES} ]]; then \/usr\/local\/bin\/npm install ${NODE_PACKAGES}; fi; if [[ ! -z ${UNNODE_PACKAGES} ]]; then \/usr\/local\/bin\/npm uninstall ${UNNODE_PACKAGES}; fi; if [ -f \/home\/container\/package.json ]; then \/usr\/local\/bin\/npm install; fi; if [[ \"${MAIN_FILE}\" == \"*.js\" ]]; then \/usr\/local\/bin\/node \"\/home\/container\/${MAIN_FILE}\" ${NODE_ARGS}; else \/usr\/local\/bin\/ts-node --esm \"\/home\/container\/${MAIN_FILE}\" ${NODE_ARGS}; fi", + "config": { + "files": "{}", + "startup": "{\r\n \"done\": [\r\n \"change this text 1\",\r\n \"change this text 2\"\r\n ]\r\n}", + "logs": "{}", + "stop": "^C" + }, + "scripts": { + "installation": { + "script": "#!\/bin\/bash\r\n# NodeJS App Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\napt update\r\napt install -y git curl jq file unzip make gcc g++ python3 python3-dev python3-pip libtool\r\n\r\necho -e \"updating npm. please wait...\"\r\nnpm install npm@latest --location=global\r\n\r\nmkdir -p \/mnt\/server\r\ncd \/mnt\/server\r\n\r\nif [ \"${USER_UPLOAD}\" == \"true\" ] || [ \"${USER_UPLOAD}\" == \"1\" ]; then\r\n echo -e \"assuming user knows what they are doing have a good day.\"\r\n exit 0\r\nfi\r\n\r\n## add git ending if it's not on the address\r\nif [[ ${GIT_ADDRESS} != *.git ]]; then\r\n GIT_ADDRESS=${GIT_ADDRESS}.git\r\nfi\r\n\r\nif [ -z \"${USERNAME}\" ] && [ -z \"${ACCESS_TOKEN}\" ]; then\r\n echo -e \"using anon api call\"\r\nelse\r\n GIT_ADDRESS=\"https:\/\/${USERNAME}:${ACCESS_TOKEN}@$(echo -e ${GIT_ADDRESS} | cut -d\/ -f3-)\"\r\nfi\r\n\r\n## pull git js repo\r\nif [ \"$(ls -A \/mnt\/server)\" ]; then\r\n echo -e \"\/mnt\/server directory is not empty.\"\r\n if [ -d .git ]; then\r\n echo -e \".git directory exists\"\r\n if [ -f .git\/config ]; then\r\n echo -e \"loading info from git config\"\r\n ORIGIN=$(git config --get remote.origin.url)\r\n else\r\n echo -e \"files found with no git config\"\r\n echo -e \"closing out without touching things to not break anything\"\r\n exit 10\r\n fi\r\n fi\r\n\r\n if [ \"${ORIGIN}\" == \"${GIT_ADDRESS}\" ]; then\r\n echo \"pulling latest from github\"\r\n git pull\r\n fi\r\nelse\r\n echo -e \"\/mnt\/server is empty.\\ncloning files into repo\"\r\n if [ -z ${BRANCH} ]; then\r\n echo -e \"cloning default branch\"\r\n git clone ${GIT_ADDRESS} .\r\n else\r\n echo -e \"cloning ${BRANCH}'\"\r\n git clone --single-branch --branch ${BRANCH} ${GIT_ADDRESS} .\r\n fi\r\n\r\nfi\r\n\r\necho \"Installing nodejs packages\"\r\nif [[ ! -z ${NODE_PACKAGES} ]]; then\r\n \/usr\/local\/bin\/npm install ${NODE_PACKAGES}\r\nfi\r\n\r\nif [ -f \/mnt\/server\/package.json ]; then\r\n \/usr\/local\/bin\/npm install --production\r\nfi\r\n\r\necho -e \"install complete\"\r\nexit 0", + "container": "node:20-bookworm-slim", + "entrypoint": "bash" + } + }, + "variables": [ + { + "name": "Git Repo Address", + "description": "GitHub Repo to clone\r\n\r\nI.E. https:\/\/github.com\/parkervcp\/repo_name", + "env_variable": "GIT_ADDRESS", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string", + "field_type": "text" + }, + { + "name": "Install Branch", + "description": "The branch to install.", + "env_variable": "BRANCH", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string", + "field_type": "text" + }, + { + "name": "User Uploaded Files", + "description": "Skip all the install stuff if you are letting a user upload files.\r\n\r\n0 = false (default)\r\n1 = true", + "env_variable": "USER_UPLOAD", + "default_value": "0", + "user_viewable": true, + "user_editable": true, + "rules": "required|boolean", + "field_type": "text" + }, + { + "name": "Auto Update", + "description": "Pull the latest files on startup when using a GitHub repo.", + "env_variable": "AUTO_UPDATE", + "default_value": "0", + "user_viewable": true, + "user_editable": true, + "rules": "required|boolean", + "field_type": "text" + }, + { + "name": "Additional Node packages", + "description": "Install additional node packages.\r\n\r\nUse spaces to separate.", + "env_variable": "NODE_PACKAGES", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string", + "field_type": "text" + }, + { + "name": "Git Username", + "description": "Username to auth with git.", + "env_variable": "USERNAME", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string", + "field_type": "text" + }, + { + "name": "Git Access Token", + "description": "Password to use with git.\r\n\r\nIt's best practice to use a Personal Access Token.\r\nhttps:\/\/github.com\/settings\/tokens\r\nhttps:\/\/gitlab.com\/-\/profile\/personal_access_tokens", + "env_variable": "ACCESS_TOKEN", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string", + "field_type": "text" + }, + { + "name": "Uninstall Node packages", + "description": "Uninstall node packages.\r\n\r\nUse spaces to separate.", + "env_variable": "UNNODE_PACKAGES", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string", + "field_type": "text" + }, + { + "name": "Main file", + "description": "The file that starts the app.\r\nCan be .js and .ts", + "env_variable": "MAIN_FILE", + "default_value": "index.js", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:16", + "field_type": "text" + }, + { + "name": "Additional Arguments.", + "description": "Any extra arguments for nodejs or ts-node", + "env_variable": "NODE_ARGS", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string|max:64", + "field_type": "text" + } + ] +} \ No newline at end of file diff --git a/rule-validator/tests/fixtures/eggs/pelican/minecraft-paper.json b/rule-validator/tests/fixtures/eggs/pelican/minecraft-paper.json new file mode 100644 index 000000000..910e6b6f6 --- /dev/null +++ b/rule-validator/tests/fixtures/eggs/pelican/minecraft-paper.json @@ -0,0 +1,80 @@ +{ + "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO", + "meta": { + "version": "PTDL_v2", + "update_url": "https://raw.githubusercontent.com/pelican-eggs/minecraft/refs/heads/main/java/paper/pterodactyl-egg-paper.json" + }, + "exported_at": "2025-12-31T13:01:49+00:00", + "name": "Paper", + "author": "parker@example.com", + "description": "High performance Spigot fork that aims to fix gameplay and mechanics inconsistencies.", + "features": [ + "eula", + "java_version", + "pid_limit" + ], + "docker_images": { + "Java 21": "ghcr.io\/pelican-eggs\/yolks:java_21", + "Java 17": "ghcr.io\/pelican-eggs\/yolks:java_17", + "Java 16": "ghcr.io\/pelican-eggs\/yolks:java_16", + "Java 11": "ghcr.io\/pelican-eggs\/yolks:java_11", + "Java 8": "ghcr.io\/pelican-eggs\/yolks:java_8" + }, + "file_denylist": [], + "startup": "java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true -jar {{SERVER_JARFILE}}", + "config": { + "files": "{\n \"server.properties\": {\n \"parser\": \"properties\",\n \"find\": {\n \"server-ip\": \"\",\n \"server-port\": \"{{server.build.default.port}}\",\n \"query.port\": \"{{server.build.default.port}}\"\n }\n }\n}", + "startup": "{\n \"done\": \")! For help, type \"\n}", + "logs": "[]", + "stop": "stop" + }, + "scripts": { + "installation": { + "script": "#!\/bin\/ash\n# Paper Installation Script\n#\n# Server Files: \/mnt\/server\nPROJECT=paper\n\nif [ -n \"${DL_PATH}\" ]; then\n\techo -e \"Using supplied download url: ${DL_PATH}\"\n\tDOWNLOAD_URL=`eval echo $(echo ${DL_PATH} | sed -e 's\/{{\/${\/g' -e 's\/}}\/}\/g')`\nelse\n\tVER_EXISTS=`curl -s https:\/\/fill.papermc.io\/v3\/projects\/${PROJECT} | jq -r --arg VERSION $MINECRAFT_VERSION '.versions | any(.[]; index($VERSION))' | grep -m1 true`\n\tLATEST_VERSION=`curl -s https:\/\/fill.papermc.io\/v3\/projects\/${PROJECT} | jq -r '.versions | to_entries | .[0].value[0]'`\n\n\tif [ \"${VER_EXISTS}\" == \"true\" ]; then\n\t\techo -e \"Version is valid. Using version ${MINECRAFT_VERSION}\"\n\telse\n\t\techo -e \"Specified version not found. Defaulting to the latest ${PROJECT} version\"\n\t\tMINECRAFT_VERSION=${LATEST_VERSION}\n\tfi\n\n\tBUILD_EXISTS=`curl -s https:\/\/fill.papermc.io\/v3\/projects\/${PROJECT}\/versions\/${MINECRAFT_VERSION} | jq -r --arg BUILD ${BUILD_NUMBER} '.builds[] | tostring | contains($BUILD)' | grep -m1 true`\n\tLATEST_BUILD=`curl -s https:\/\/fill.papermc.io\/v3\/projects\/${PROJECT}\/versions\/${MINECRAFT_VERSION} | jq -r '.builds' | jq -r '.[0]'`\n\n\tif [ \"${BUILD_EXISTS}\" == \"true\" ]; then\n\t\techo -e \"Build is valid for version ${MINECRAFT_VERSION}. Using build ${BUILD_NUMBER}\"\n\telse\n\t\techo -e \"Using the latest ${PROJECT} build for version ${MINECRAFT_VERSION}\"\n\t\tBUILD_NUMBER=${LATEST_BUILD}\n\tfi\n\n\techo \"Version being downloaded\"\n\techo -e \"Project: ${PROJECT}\"\n\techo -e \"MC Version: ${MINECRAFT_VERSION}\"\n\techo -e \"Build: ${BUILD_NUMBER}\"\n\tDOWNLOAD_URL=`curl -s https:\/\/fill.papermc.io\/v3\/projects\/${PROJECT}\/versions\/${MINECRAFT_VERSION}\/builds\/${BUILD_NUMBER} | jq -r '.downloads.\"server:default\".url'`\nfi\n\ncd \/mnt\/server\n\necho -e \"Running curl -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\"\n\nif [ -f ${SERVER_JARFILE} ]; then\n\tmv ${SERVER_JARFILE} ${SERVER_JARFILE}.old\nfi\n\ncurl -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\n\nif [ ! -f server.properties ]; then\n echo -e \"Downloading MC server.properties\"\n curl -o server.properties https:\/\/raw.githubusercontent.com\/pelican-eggs\/minecraft\/refs\/heads\/main\/java\/server.properties\nfi", + "container": "ghcr.io\/pelican-eggs\/installers:alpine", + "entrypoint": "ash" + } + }, + "variables": [ + { + "name": "Build Number", + "description": "The build number for the paper release.\r\n\r\nLeave at latest to always get the latest version. Invalid versions will default to latest.", + "env_variable": "BUILD_NUMBER", + "default_value": "latest", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:20", + "field_type": "text" + }, + { + "name": "Download Path", + "description": "A URL to use to download a server.jar rather than the ones in the install script. This is not user\nviewable.", + "env_variable": "DL_PATH", + "default_value": "", + "user_viewable": false, + "user_editable": false, + "rules": "nullable|string", + "field_type": "text" + }, + { + "name": "Minecraft Version", + "description": "The version of minecraft to download. \r\n\r\nLeave at latest to always get the latest version. Invalid versions will default to latest.", + "env_variable": "MINECRAFT_VERSION", + "default_value": "latest", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string|max:20", + "field_type": "text" + }, + { + "name": "Server Jar File", + "description": "The name of the server jarfile to run the server with.", + "env_variable": "SERVER_JARFILE", + "default_value": "server.jar", + "user_viewable": true, + "user_editable": true, + "rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/", + "field_type": "text" + } + ] +} \ No newline at end of file diff --git a/rule-validator/tests/fixtures/eggs/pelican/steamcmd-rust.json b/rule-validator/tests/fixtures/eggs/pelican/steamcmd-rust.json new file mode 100644 index 000000000..facf30de8 --- /dev/null +++ b/rule-validator/tests/fixtures/eggs/pelican/steamcmd-rust.json @@ -0,0 +1,224 @@ +{ + "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL", + "meta": { + "update_url": null, + "version": "PTDL_v2" + }, + "exported_at": "2024-06-01T00:04:43+00:00", + "name": "Rust Autowipe", + "author": "support@pterodactyl.io", + "description": "The only aim in Rust is to survive. To do this you will need to overcome struggles such as hunger, thirst and cold. Build a fire. Build a shelter. Kill animals for meat. Protect yourself from other players, and kill them for meat. Create alliances with other players and form a town. Do whatever it takes to survive.", + "features": [ + "steam_disk_space" + ], + "docker_images": { + "ghcr.io/pterodactyl/games:rust": "ghcr.io/pterodactyl/games:rust" + }, + "file_denylist": [], + "startup": "\"./RustDedicated -batchmode +server.port {{SERVER_PORT}} +server.queryport {{QUERY_PORT}} +server.identity \"rust\" +rcon.ip 0.0.0.0 +rcon.port {{RCON_PORT}} +rcon.web true +server.hostname \\\"{{HOSTNAME}}\\\" +server.level \\\"{{LEVEL}}\\\" +server.description \\\"{{DESCRIPTION}}\\\" +server.url \\\"{{SERVER_URL}}\\\" +server.headerimage \\\"{{SERVER_IMG}}\\\" +server.maxplayers {{MAX_PLAYERS}} +rcon.password \\\"{{RCON_PASS}}\\\" +app.port {{APP_PORT}} +server.saveinterval {{SAVEINTERVAL}} $( [ -z ${MAP_URL} ] \u0026\u0026 printf %s \"+server.worldsize \\\"{{WORLD_SIZE}}\\\" +server.seed \\\"$( if [ -f seed.txt ] \u0026\u0026 [[ ${WORLD_SEED} == \"0\" ]]; then printf %s $(cat seed.txt); else printf %s ${WORLD_SEED}; fi )\\\"\"|| printf %s \"+server.levelurl {{MAP_URL}}\" ) {{ADDITIONAL_ARGS}}\"", + "config": { + "files": "{}", + "logs": "{}", + "startup": "{\r\n \"done\": \"Server startup complete\"\r\n}", + "stop": "quit" + }, + "scripts": { + "installation": { + "container": "ghcr.io/parkervcp/installers:debian", + "entrypoint": "bash", + "script": "#!/bin/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: /mnt/server\r\n# Image to install with is 'ghcr.io/parkervcp/installers:debian'\r\n\r\n##\r\n#\r\n# Variables\r\n# STEAM_USER, STEAM_PASS, STEAM_AUTH - Steam user setup. If a user has 2fa enabled it will most likely fail due to timeout. Leave blank for anon install.\r\n# WINDOWS_INSTALL - if it's a windows server you want to install set to 1\r\n# SRCDS_APPID - steam app id found here - https://developer.valvesoftware.com/wiki/Dedicated_Servers_List\r\n# SRCDS_BETAID - beta branch of a steam app. Leave blank to install normal branch\r\n# SRCDS_BETAPASS - password for a beta branch should one be required during private or closed testing phases.. Leave blank for no password.\r\n# INSTALL_FLAGS - Any additional SteamCMD flags to pass during install.. Keep in mind that steamcmd auto update process in the docker image might overwrite or ignore these when it performs update on server boot.\r\n# AUTO_UPDATE - Adding this variable to the egg allows disabling or enabling automated updates on boot. Boolean value. 0 to disable and 1 to enable.\r\n#\r\n ##\r\n\r\n# Install packages. Default packages below are not required if using our existing install image thus speeding up the install process.\r\n#apt -y update\r\n#apt -y --no-install-recommends install curl lib32gcc-s1 ca-certificates\r\n\r\n## just in case someone removed the defaults.\r\nif [[ \"${STEAM_USER}\" == \"\" ]] || [[ \"${STEAM_PASS}\" == \"\" ]]; then\r\n echo -e \"steam user is not set.\\n\"\r\n echo -e \"Using anonymous user.\\n\"\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nelse\r\n echo -e \"user set to ${STEAM_USER}\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd /tmp\r\nmkdir -p /mnt/server/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C /mnt/server/steamcmd\r\nmkdir -p /mnt/server/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd /mnt/server/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root /mnt\r\nexport HOME=/mnt/server\r\n\r\n## install game using steamcmd\r\n./steamcmd.sh +force_install_dir /mnt/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ \"${WINDOWS_INSTALL}\" == \"1\" ]] \u0026\u0026 printf %s '+@sSteamCmdForcePlatformType windows' ) +app_update ${SRCDS_APPID} $( [[ -z ${SRCDS_BETAID} ]] || printf %s \"-beta ${SRCDS_BETAID}\" ) $( [[ -z ${SRCDS_BETAPASS} ]] || printf %s \"-betapassword ${SRCDS_BETAPASS}\" ) ${INSTALL_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p /mnt/server/.steam/sdk32\r\ncp -v linux32/steamclient.so ../.steam/sdk32/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p /mnt/server/.steam/sdk64\r\ncp -v linux64/steamclient.so ../.steam/sdk64/steamclient.so\r\n\r\n## add below your custom commands if needed\r\n\r\n\r\nif [ ${REGEN_SERVER} == \"1\" ]; then\r\n cd /mnt/server/\r\n rm -rf ${REMOVE_FILES}\r\nfi\r\n\r\nif [ $WORLD_SEED == \"0\" ]; then\r\n if [ ! -f /mnt/server/seed.txt ]; then\r\n rm -sf /mnt/server/seed.txt\r\n fi\r\n \r\n cat /dev/urandom | tr -dc '1-9' | fold -w 5 | head -n 1 \u003e /mnt/server/seed.txt\r\nfi\r\n\r\n## install end\r\necho \"-----------------------------------------\"\r\necho \"Installation completed...\"\r\necho \"-----------------------------------------\"" + } + }, + "variables": [ + { + "name": "SRCDS_APPID", + "description": "", + "env_variable": "SRCDS_APPID", + "default_value": "258550", + "user_viewable": false, + "user_editable": false, + "rules": "required|string|max:20", + "field_type": "text" + }, + { + "name": "Max Players", + "description": "The maximum amount of players allowed in the server at once.", + "env_variable": "MAX_PLAYERS", + "default_value": "40", + "user_viewable": true, + "user_editable": true, + "rules": "required|integer", + "field_type": "text" + }, + { + "name": "Server Name", + "description": "The name of your server in the public server list.", + "env_variable": "HOSTNAME", + "default_value": "A Rust Server", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:40", + "field_type": "text" + }, + { + "name": "Level", + "description": "The world file for Rust to use.", + "env_variable": "LEVEL", + "default_value": "Procedural Map", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:20", + "field_type": "text" + }, + { + "name": "Description", + "description": "The description under your server title. Commonly used for rules \u0026 info. Use \\n for newlines.", + "env_variable": "DESCRIPTION", + "default_value": "Powered by Pterodactyl", + "user_viewable": true, + "user_editable": true, + "rules": "required|string", + "field_type": "text" + }, + { + "name": "URL", + "description": "The URL for your server. This is what comes up when clicking the \"Visit Website\" button.", + "env_variable": "SERVER_URL", + "default_value": "http://pterodactyl.io", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|url", + "field_type": "text" + }, + { + "name": "World Size", + "description": "The world size for a procedural map.", + "env_variable": "WORLD_SIZE", + "default_value": "3000", + "user_viewable": true, + "user_editable": true, + "rules": "required|integer", + "field_type": "text" + }, + { + "name": "World Seed", + "description": "The seed for a procedural map.", + "env_variable": "WORLD_SEED", + "default_value": "0", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string", + "field_type": "text" + }, + { + "name": "Server Image", + "description": "The header image for the top of your server listing.", + "env_variable": "SERVER_IMG", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|url", + "field_type": "text" + }, + { + "name": "RCON Port", + "description": "Port for RCON connections.", + "env_variable": "RCON_PORT", + "default_value": "28016", + "user_viewable": true, + "user_editable": false, + "rules": "required|integer", + "field_type": "text" + }, + { + "name": "RCON Password", + "description": "RCON access password.", + "env_variable": "RCON_PASS", + "default_value": "CHANGEME", + "user_viewable": true, + "user_editable": true, + "rules": "required|regex:/^[\\w.-]*$/|max:64", + "field_type": "text" + }, + { + "name": "Save Interval", + "description": "Sets the server’s auto-save interval in seconds.", + "env_variable": "SAVEINTERVAL", + "default_value": "60", + "user_viewable": true, + "user_editable": true, + "rules": "required|integer", + "field_type": "text" + }, + { + "name": "Additional Arguments", + "description": "Add additional startup parameters to the server.", + "env_variable": "ADDITIONAL_ARGS", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string", + "field_type": "text" + }, + { + "name": "Regen Server", + "description": "If the server should have its files removed and regenerate the server seed on reinstall.", + "env_variable": "REGEN_SERVER", + "default_value": "0", + "user_viewable": true, + "user_editable": true, + "rules": "boolean", + "field_type": "text" + }, + { + "name": "Files to remove", + "description": "A space-separated list of files to remove when regenerating the server on reinstall.", + "env_variable": "REMOVE_FILES", + "default_value": "server/rust/player.deaths.*.db server/rust/player.identities.*.db server/rust/player.states.*.db server/rust/player.tokens.db proceduralmap.*.*.*.map server/rust/proceduralmap.*.*.*.sav oxide/data/Kits_Data.json oxide/data/NTeleportationHome.json oxide/data/ServerRewards/player_data.json oxide/data/PTTracker/playtime_data.json", + "user_viewable": true, + "user_editable": true, + "rules": "required|string", + "field_type": "text" + }, + { + "name": "QUERY PORT", + "description": "Port for QUERY connections.", + "env_variable": "QUERY_PORT", + "default_value": "28017", + "user_viewable": true, + "user_editable": true, + "rules": "required|integer", + "field_type": "text" + }, + { + "name": "APP PORT", + "description": "Port for Rust+ applications. -1 to disable.", + "env_variable": "APP_PORT", + "default_value": "28082", + "user_viewable": true, + "user_editable": true, + "rules": "required|integer", + "field_type": "text" + }, + { + "name": "Custom Map URL", + "description": "Overwrites the map with the one from the direct download URL. Invalid URLs will cause the server to crash.", + "env_variable": "MAP_URL", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|url", + "field_type": "text" + }, + { + "name": "Modding Framework", + "description": "The modding framework to be used: carbon, oxide, vanilla.\r\nDefaults to \"vanilla\" for a non-modded server installation.", + "env_variable": "FRAMEWORK", + "default_value": "vanilla", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|in:vanilla,carbon,oxide", + "field_type": "text" + } + ] +} \ No newline at end of file diff --git a/rule-validator/tests/fixtures/eggs/pterodactyl/LICENSE b/rule-validator/tests/fixtures/eggs/pterodactyl/LICENSE new file mode 100644 index 000000000..f24c32ef6 --- /dev/null +++ b/rule-validator/tests/fixtures/eggs/pterodactyl/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Pterodactyl team and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rule-validator/tests/fixtures/eggs/pterodactyl/ark-survival.json b/rule-validator/tests/fixtures/eggs/pterodactyl/ark-survival.json new file mode 100644 index 000000000..11507332f --- /dev/null +++ b/rule-validator/tests/fixtures/eggs/pterodactyl/ark-survival.json @@ -0,0 +1,164 @@ +{ + "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO", + "meta": { + "version": "PTDL_v2", + "update_url": null + }, + "exported_at": "2025-07-16T10:03:10+00:00", + "name": "Ark: Survival Evolved", + "author": "dev@shepper.fr", + "description": "As a man or woman stranded, naked, freezing, and starving on the unforgiving shores of a mysterious island called ARK, use your skill and cunning to kill or tame and ride the plethora of leviathan dinosaurs and other primeval creatures roaming the land. Hunt, harvest resources, craft items, grow crops, research technologies, and build shelters to withstand the elements and store valuables, all while teaming up with (or preying upon) hundreds of other players to survive, dominate... and escape! \u2014 Gamepedia: ARK", + "features": [ + "steam_disk_space" + ], + "docker_images": { + "ghcr.io\/ptero-eggs\/games:source": "ghcr.io\/ptero-eggs\/games:source" + }, + "file_denylist": [], + "startup": "rmv() { echo \"stopping server\"; rcon -t rcon -a 127.0.0.1:${RCON_PORT} -p ${ARK_ADMIN_PASSWORD} saveworld &&rcon -t rcon -a 127.0.0.1:${RCON_PORT} -p ${ARK_ADMIN_PASSWORD} DoExit && wait ${ARK_PID}; echo \"Server Closed\"; exit; }; trap rmv 15 2; cd ShooterGame\/Binaries\/Linux && .\/ShooterGameServer {{SERVER_MAP}}?listen?SessionName=\"{{SESSION_NAME}}\"?ServerPassword={{ARK_PASSWORD}}?ServerAdminPassword={{ARK_ADMIN_PASSWORD}}?Port={{SERVER_PORT}}?RCONPort={{RCON_PORT}}?QueryPort={{QUERY_PORT}}?RCONEnabled=True?MaxPlayers={{MAX_PLAYERS}}?GameModIds=\\\"{{MOD_ID}}\\\"$( [ \"$BATTLE_EYE\" == \"1\" ] || printf %s ' -NoBattlEye' ) -server -automanagedmods {{ARGS}} -log & ARK_PID=$! ; until echo \"waiting for rcon connection...\"; (rcon -t rcon -a 127.0.0.1:${RCON_PORT} -p ${ARK_ADMIN_PASSWORD})<&0 & wait $!; do sleep 5; done", + "config": { + "files": "{}", + "startup": "{\r\n \"done\": \"Waiting commands for 127.0.0.1:\"\r\n}", + "logs": "{}", + "stop": "^C" + }, + "scripts": { + "installation": { + "script": "#!\/bin\/bash\r\n\r\nif [[ \"${STEAM_USER}\" == \"\" ]] || [[ \"${STEAM_PASS}\" == \"\" ]]; then\r\n echo -e \"steam user is not set.\\n\"\r\n echo -e \"Using anonymous user.\\n\"\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nelse\r\n echo -e \"user set to ${STEAM_USER}\"\r\nfi\r\n\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/serve\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +force_install_dir \/mnt\/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ \"${WINDOWS_INSTALL}\" == \"1\" ]] && printf %s '+@sSteamCmdForcePlatformType windows' ) +app_update ${SRCDS_APPID} $( [[ -z ${SRCDS_BETAID} ]] || printf %s \"-beta ${SRCDS_BETAID}\" ) $( [[ -z ${SRCDS_BETAPASS} ]] || printf %s \"-betapassword ${SRCDS_BETAPASS}\" ) ${INSTALL_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so\r\n\r\n\r\n\r\n# Install SteamCMD into server folder\r\nmkdir -p \/mnt\/server\/Engine\/Binaries\/ThirdParty\/SteamCMD\/Linux\r\ntar -xzvf \/tmp\/steamcmd.tar.gz -C \/mnt\/server\/Engine\/Binaries\/ThirdParty\/SteamCMD\/Linux\r\n\r\n## create a symbolic link for loading mods\r\ncd \/mnt\/server\/Engine\/Binaries\/ThirdParty\/SteamCMD\/Linux\r\nln -sf ..\/..\/..\/..\/..\/Steam\/steamapps steamapps", + "container": "ghcr.io\/ptero-eggs\/installers:debian", + "entrypoint": "bash" + } + }, + "variables": [ + { + "name": "Server Password", + "description": "If specified, players must provide this password to join the server.", + "env_variable": "ARK_PASSWORD", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|alpha_dash|between:1,100", + "field_type": "text" + }, + { + "name": "Admin Password", + "description": "If specified, players must provide this password (via the in-game console) to gain access to administrator commands on the server.", + "env_variable": "ARK_ADMIN_PASSWORD", + "default_value": "PleaseChangeMe", + "user_viewable": true, + "user_editable": true, + "rules": "required|alpha_dash|between:1,100", + "field_type": "text" + }, + { + "name": "Server Map", + "description": "Available Maps: TheIsland, TheCenter, Ragnarok, ScorchedEarth_P, Aberration_P, Extinction, Valguero_P, Genesis, CrystalIsles, Gen2, Fjordur", + "env_variable": "SERVER_MAP", + "default_value": "TheIsland", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:20", + "field_type": "text" + }, + { + "name": "Server Name", + "description": "ARK server name", + "env_variable": "SESSION_NAME", + "default_value": "A Pterodactyl Hosted ARK Server", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:128", + "field_type": "text" + }, + { + "name": "Rcon Port", + "description": "ARK rcon port used by rcon tools.", + "env_variable": "RCON_PORT", + "default_value": "27020", + "user_viewable": true, + "user_editable": true, + "rules": "required|numeric", + "field_type": "text" + }, + { + "name": "Query Port", + "description": "ARK query port used by steam server browser and ark client server browser.", + "env_variable": "QUERY_PORT", + "default_value": "27015", + "user_viewable": true, + "user_editable": true, + "rules": "required|numeric", + "field_type": "text" + }, + { + "name": "Auto-update server", + "description": "Enable auto-updating from steam", + "env_variable": "AUTO_UPDATE", + "default_value": "1", + "user_viewable": true, + "user_editable": true, + "rules": "required|boolean|in:0,1", + "field_type": "text" + }, + { + "name": "Battle Eye", + "description": "Enable BattleEye", + "env_variable": "BATTLE_EYE", + "default_value": "1", + "user_viewable": true, + "user_editable": true, + "rules": "required|boolean|in:0,1", + "field_type": "text" + }, + { + "name": "App ID", + "description": "ARK steam app id for auto updates. Leave blank to avoid auto update.", + "env_variable": "SRCDS_APPID", + "default_value": "376030", + "user_viewable": true, + "user_editable": false, + "rules": "nullable|numeric", + "field_type": "text" + }, + { + "name": "Additional Arguments", + "description": "Specify additional launch parameters such as -crossplay. You must include a dash - and separate each parameter with space: -crossplay -exclusivejoin", + "env_variable": "ARGS", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string", + "field_type": "text" + }, + { + "name": "Mods", + "description": "Specifies the order and which mods are loaded. ModIDs need to be comma-separated such as: ModID1,ModID2", + "env_variable": "MOD_ID", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|regex:\/^[0-9,]+$\/", + "field_type": "text" + }, + { + "name": "Max Players", + "description": "Specifies the maximum amount of players able to join the server.", + "env_variable": "MAX_PLAYERS", + "default_value": "12", + "user_viewable": true, + "user_editable": true, + "rules": "numeric", + "field_type": "text" + }, + { + "name": "Beta Branch", + "description": "Installs beta branch if specified.", + "env_variable": "SRCDS_BETAID", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string|max:50", + "field_type": "text" + } + ] +} \ No newline at end of file diff --git a/rule-validator/tests/fixtures/eggs/pterodactyl/factorio.json b/rule-validator/tests/fixtures/eggs/pterodactyl/factorio.json new file mode 100644 index 000000000..e633789c5 --- /dev/null +++ b/rule-validator/tests/fixtures/eggs/pterodactyl/factorio.json @@ -0,0 +1,132 @@ +{ + "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO", + "meta": { + "version": "PTDL_v2", + "update_url": null + }, + "exported_at": "2024-04-02T11:03:08+02:00", + "name": "Factorio", + "author": "parker@parkervcp.com", + "description": "The vanilla Factorio server.\r\n\r\nhttps:\/\/www.factorio.com\/", + "features": null, + "docker_images": { + "ghcr.io\/ptero-eggs\/yolks:debian": "ghcr.io\/ptero-eggs\/yolks:debian" + }, + "file_denylist": [], + "startup": "if [ ! -f \".\/saves\/{{SAVE_NAME}}.zip\" ]; then .\/bin\/x64\/factorio --create .\/saves\/{{SAVE_NAME}}.zip --map-gen-settings data\/map-gen-settings.json --map-settings data\/map-settings.json; fi; .\/bin\/x64\/factorio --port {{SERVER_PORT}} --server-settings data\/server-settings.json --start-server saves\/{{SAVE_NAME}}.zip", + "config": { + "files": "{\r\n \"data\/server-settings.json\": {\r\n \"parser\": \"json\",\r\n \"find\": {\r\n \"name\": \"{{server.build.env.SERVER_NAME}}\",\r\n \"description\": \"{{server.build.env.SERVER_DESC}}\",\r\n \"max_players\": \"{{server.build.env.MAX_SLOTS}}\",\r\n \"username\": \"{{server.build.env.SERVER_USERNAME}}\",\r\n \"token\": \"{{server.build.env.SERVER_TOKEN}}\",\r\n \"autosave_interval\": \"{{server.build.env.SAVE_INTERVAL}}\",\r\n \"autosave_slots\": \"{{server.build.env.SAVE_SLOTS}}\",\r\n \"afk_autokick_interval\": \"{{server.build.env.AFK_KICK}}\"\r\n }\r\n }\r\n}", + "startup": "{\r\n \"done\": \"Hosting game at\"\r\n}", + "logs": "{}", + "stop": "\/quit" + }, + "scripts": { + "installation": { + "script": "#!\/bin\/bash\r\n# Factorio Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\napt update \r\napt install -y curl tar xz-utils jq\r\n\r\nVERSION_JSON=$(curl -sSL https:\/\/factorio.com\/api\/latest-releases)\r\n\r\nlatest_stable=$(echo $VERSION_JSON | jq -r '.stable.headless')\r\nlatest_experimental=$(echo $VERSION_JSON | jq -r '.experimental.headless')\r\n\r\nif [ -z \"${FACTORIO_VERSION}\" ] || [ \"${FACTORIO_VERSION}\" == \"latest\" ]; then\r\n DL_VERSION=$latest_stable\r\nelif [ \"${FACTORIO_VERSION}\" == \"experimental\" ]; then\r\n DL_VERSION=$latest_experimental\r\nelse\r\n DL_VERSION=${FACTORIO_VERSION}\r\nfi\r\n\r\nmkdir -p \/mnt\/server\r\ncd \/mnt\/server\r\n\r\necho -e \"\\n running 'curl -sL https:\/\/www.factorio.com\/get-download\/${DL_VERSION}\/headless\/linux64 -o factorio-${DL_VERSION}' \\n\"\r\n\r\ncurl -sL https:\/\/www.factorio.com\/get-download\/${DL_VERSION}\/headless\/linux64 -o factorio-${DL_VERSION}\r\n\r\ntar -xf factorio-${DL_VERSION} --strip-components=1 -C \/mnt\/server\r\n\r\nrm factorio-${DL_VERSION}\r\n\r\nif [ -e data\/map-gen-settings.json ]; then\r\n echo \"map-gen exists\"\r\nelse\r\n echo \"copying map-gen default settings\"\r\n mv data\/map-gen-settings.example.json data\/map-gen-settings.json\r\nfi\r\n\r\nif [ -e data\/server-settings.json ]; then\r\n echo \"server settings exists\"\r\nelse\r\n echo \"copying server default settings\"\r\n mv data\/server-settings.example.json data\/server-settings.json\r\nfi\r\n\r\nif [ -e map-settings.json ]; then\r\n echo \"map settings exists\"\r\nelse\r\n echo \"copying map default settings\"\r\n mv data\/map-settings.example.json data\/map-settings.json\r\nfi\r\n\r\nif [ -e .\/saves\/${SAVE_NAME}.zip ]; then\r\n echo \"save file exists\"\r\nelse\r\n .\/bin\/x64\/factorio --create .\/saves\/${SAVE_NAME} --map-gen-settings data\/map-gen-settings.json --map-settings data\/map-settings.json\r\n chmod o+w .\/saves\/${SAVE_NAME}.zip\r\nfi\r\n\r\n## install end\r\necho \"-----------------------------------------\"\r\necho \"Installation completed...\"\r\necho \"-----------------------------------------\"", + "container": "ghcr.io\/ptero-eggs\/installers:debian", + "entrypoint": "bash" + } + }, + "variables": [ + { + "name": "Factorio Version", + "description": "Which version of Factorio to install and use.", + "env_variable": "FACTORIO_VERSION", + "default_value": "latest", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|between:3,12", + "field_type": "text" + }, + { + "name": "Maximum Slots", + "description": "Total number of slots to allow on the server.", + "env_variable": "MAX_SLOTS", + "default_value": "20", + "user_viewable": true, + "user_editable": false, + "rules": "required|numeric|digits_between:1,3", + "field_type": "text" + }, + { + "name": "Save Name", + "description": "The save name for the server.", + "env_variable": "SAVE_NAME", + "default_value": "gamesave", + "user_viewable": true, + "user_editable": true, + "rules": "alpha_dash|between:1,100", + "field_type": "text" + }, + { + "name": "Server Token", + "description": "Your factorio.com token, it is required for your server to be visible in the public server list.", + "env_variable": "SERVER_TOKEN", + "default_value": "undefined", + "user_viewable": true, + "user_editable": true, + "rules": "alpha_num|max:100", + "field_type": "text" + }, + { + "name": "Server Name", + "description": "Name of the game as it will appear in the game listing", + "env_variable": "SERVER_NAME", + "default_value": "Factorio Server", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:100", + "field_type": "text" + }, + { + "name": "Server Description", + "description": "Description of the game that will appear in the listing.", + "env_variable": "SERVER_DESC", + "default_value": "Description", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:200", + "field_type": "text" + }, + { + "name": "Server Username", + "description": "Username used for the server", + "env_variable": "SERVER_USERNAME", + "default_value": "unnamed", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:40", + "field_type": "text" + }, + { + "name": "Auto Save Interval", + "description": "Time between auto saves specified in minutes", + "env_variable": "SAVE_INTERVAL", + "default_value": "10", + "user_viewable": true, + "user_editable": true, + "rules": "required|numeric|digits_between:1,3", + "field_type": "text" + }, + { + "name": "Auto Save Slots", + "description": "The number of auto saves to keep.", + "env_variable": "SAVE_SLOTS", + "default_value": "5", + "user_viewable": true, + "user_editable": true, + "rules": "required|numeric|digits_between:1,3", + "field_type": "text" + }, + { + "name": "AFK Kick", + "description": "Time specified in minutes to kick AFK players.\r\n0 is off", + "env_variable": "AFK_KICK", + "default_value": "0", + "user_viewable": true, + "user_editable": true, + "rules": "required|numeric|digits_between:1,3", + "field_type": "text" + } + ] +} \ No newline at end of file diff --git a/rule-validator/tests/fixtures/eggs/pterodactyl/minecraft-paper.json b/rule-validator/tests/fixtures/eggs/pterodactyl/minecraft-paper.json new file mode 100644 index 000000000..fb6355e53 --- /dev/null +++ b/rule-validator/tests/fixtures/eggs/pterodactyl/minecraft-paper.json @@ -0,0 +1,82 @@ +{ + "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO", + "meta": { + "version": "PTDL_v2", + "update_url": null + }, + "exported_at": "2026-03-27T14:32:59+01:00", + "name": "Paper", + "author": "parker@pterodactyl.io", + "description": "High performance Spigot fork that aims to fix gameplay and mechanics inconsistencies.", + "features": [ + "eula", + "java_version", + "pid_limit" + ], + "docker_images": { + "Java 25": "ghcr.io\/ptero-eggs\/yolks:java_25", + "Java 22": "ghcr.io\/ptero-eggs\/yolks:java_22", + "Java 21": "ghcr.io\/ptero-eggs\/yolks:java_21", + "Java 17": "ghcr.io\/ptero-eggs\/yolks:java_17", + "Java 16": "ghcr.io\/ptero-eggs\/yolks:java_16", + "Java 11": "ghcr.io\/ptero-eggs\/yolks:java_11", + "Java 8": "ghcr.io\/ptero-eggs\/yolks:java_8" + }, + "file_denylist": [], + "startup": "java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true -jar {{SERVER_JARFILE}}", + "config": { + "files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"server-port\": \"{{server.build.default.port}}\",\r\n \"query.port\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}", + "startup": "{\r\n \"done\": \")! For help, type \"\r\n}", + "logs": "{}", + "stop": "stop" + }, + "scripts": { + "installation": { + "script": "#!\/bin\/ash\r\n# Paper Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\nPROJECT=paper\r\nUSER_AGENT=\"Pterodactyl (https:\/\/Pterodactyl.io)\"\r\n\r\nif [ -n \"${DL_PATH}\" ]; then\r\n\techo -e \"Using supplied download url: ${DL_PATH}\"\r\n\tDOWNLOAD_URL=`eval echo $(echo ${DL_PATH} | sed -e 's\/{{\/${\/g' -e 's\/}}\/}\/g')`\r\nelse\r\n\tVER_EXISTS=`curl --user-agent \"${USER_AGENT}\" -s https:\/\/fill.papermc.io\/v3\/projects\/${PROJECT} | jq -r --arg VERSION $MINECRAFT_VERSION '.versions | any(.[]; index($VERSION))' | grep -m1 true`\r\n\tLATEST_VERSION=`curl --user-agent \"${USER_AGENT}\" -s https:\/\/fill.papermc.io\/v3\/projects\/${PROJECT} | jq -r '.versions | to_entries | .[0].value[0]'`\r\n\r\n\tif [ \"${VER_EXISTS}\" == \"true\" ]; then\r\n\t\techo -e \"Version is valid. Using version ${MINECRAFT_VERSION}\"\r\n\telse\r\n\t\techo -e \"Specified version not found. Defaulting to the latest ${PROJECT} version\"\r\n\t\tMINECRAFT_VERSION=${LATEST_VERSION}\r\n\tfi\r\n\r\n\tBUILD_EXISTS=`curl --user-agent \"${USER_AGENT}\" -s https:\/\/fill.papermc.io\/v3\/projects\/${PROJECT}\/versions\/${MINECRAFT_VERSION} | jq -r --arg BUILD ${BUILD_NUMBER} '.builds[] | tostring | contains($BUILD)' | grep -m1 true`\r\n\tLATEST_BUILD=`curl --user-agent \"${USER_AGENT}\" -s https:\/\/fill.papermc.io\/v3\/projects\/${PROJECT}\/versions\/${MINECRAFT_VERSION} | jq -r '.builds' | jq -r '.[0]'`\r\n\r\n\tif [ \"${BUILD_EXISTS}\" == \"true\" ]; then\r\n\t\techo -e \"Build is valid for version ${MINECRAFT_VERSION}. Using build ${BUILD_NUMBER}\"\r\n\telse\r\n\t\techo -e \"Using the latest ${PROJECT} build for version ${MINECRAFT_VERSION}\"\r\n\t\tBUILD_NUMBER=${LATEST_BUILD}\r\n\tfi\r\n\r\n\techo \"Version being downloaded\"\r\n\techo -e \"Project: ${PROJECT}\"\r\n\techo -e \"MC Version: ${MINECRAFT_VERSION}\"\r\n\techo -e \"Build: ${BUILD_NUMBER}\"\r\n\tDOWNLOAD_URL=`curl --user-agent \"${USER_AGENT}\" -s https:\/\/fill.papermc.io\/v3\/projects\/${PROJECT}\/versions\/${MINECRAFT_VERSION}\/builds\/${BUILD_NUMBER} | jq -r '.downloads.\"server:default\".url'`\r\nfi\r\n\r\ncd \/mnt\/server\r\n\r\necho -e \"Running curl --user-agent \\\"${USER_AGENT}\\\" -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\"\r\n\r\nif [ -f ${SERVER_JARFILE} ]; then\r\n\tmv ${SERVER_JARFILE} ${SERVER_JARFILE}.old\r\nfi\r\n\r\ncurl --user-agent \"${USER_AGENT}\" -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\r\n\r\nif [ ! -f server.properties ]; then\r\n echo -e \"Downloading MC server.properties\"\r\n curl --user-agent \"${USER_AGENT}\" -o server.properties https:\/\/raw.githubusercontent.com\/pterodactyl\/game-eggs\/main\/minecraft\/java\/server.properties\r\nfi", + "container": "ghcr.io\/ptero-eggs\/installers:alpine", + "entrypoint": "ash" + } + }, + "variables": [ + { + "name": "Minecraft Version", + "description": "The version of minecraft to download. \r\n\r\nLeave at latest to always get the latest version. Invalid versions will default to latest.", + "env_variable": "MINECRAFT_VERSION", + "default_value": "latest", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string|max:20", + "field_type": "text" + }, + { + "name": "Server Jar File", + "description": "The name of the server jarfile to run the server with.", + "env_variable": "SERVER_JARFILE", + "default_value": "server.jar", + "user_viewable": true, + "user_editable": true, + "rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/", + "field_type": "text" + }, + { + "name": "Download Path", + "description": "A URL to use to download a server.jar rather than the ones in the install script. This is not user viewable.", + "env_variable": "DL_PATH", + "default_value": "", + "user_viewable": false, + "user_editable": false, + "rules": "nullable|string", + "field_type": "text" + }, + { + "name": "Build Number", + "description": "The build number for the paper release.\r\n\r\nLeave at latest to always get the latest version. Invalid versions will default to latest.", + "env_variable": "BUILD_NUMBER", + "default_value": "latest", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:20", + "field_type": "text" + } + ] +} \ No newline at end of file diff --git a/rule-validator/tests/new_rules.rs b/rule-validator/tests/new_rules.rs new file mode 100644 index 000000000..c10e884f1 --- /dev/null +++ b/rule-validator/tests/new_rules.rs @@ -0,0 +1,268 @@ +use std::collections::HashMap; + +use compact_str::CompactString; + +fn run(rules_per_field: &[(&str, &[&str])], values: &[(&str, &str)]) -> Result<(), String> { + let rule_vecs: Vec<(&str, Vec)> = rules_per_field + .iter() + .map(|(k, rs)| (*k, rs.iter().map(|s| CompactString::from(*s)).collect())) + .collect(); + + let mut data: HashMap<&str, (&[CompactString], &str)> = HashMap::new(); + for (k, rules) in &rule_vecs { + let v = values + .iter() + .find(|(kk, _)| kk == k) + .map(|(_, v)| *v) + .unwrap_or(""); + data.insert(*k, (rules.as_slice(), v)); + } + + let validator = rule_validator::Validator::new(data).map_err(|e| e.to_string())?; + validator.validate() +} + +// --- email --------------------------------------------------------------- + +#[test] +fn email_happy() { + assert!(run(&[("f", &["email"])], &[("f", "a@b.co")]).is_ok()); + assert!(run(&[("f", &["email"])], &[("f", "first.last+tag@sub.example.com")]).is_ok()); +} + +#[test] +fn email_sad() { + assert!(run(&[("f", &["email"])], &[("f", "nope")]).is_err()); + assert!(run(&[("f", &["email"])], &[("f", "no@dotcom")]).is_err()); + assert!(run(&[("f", &["email"])], &[("f", "@b.co")]).is_err()); +} + +// --- ulid ---------------------------------------------------------------- + +#[test] +fn ulid_happy() { + assert!(run(&[("f", &["ulid"])], &[("f", "01H8ZJ8MX2K5N7P9Q1R3T5V7W9")]).is_ok()); +} + +#[test] +fn ulid_sad() { + // Letter 'I' is not in Crockford base32. + assert!(run(&[("f", &["ulid"])], &[("f", "01HIZJ8MX2K5N7P9Q1R3T5V7W9")]).is_err()); + // Too short. + assert!(run(&[("f", &["ulid"])], &[("f", "01H8ZJ8MX2K5")]).is_err()); +} + +// --- decimal ------------------------------------------------------------- + +#[test] +fn decimal_exact() { + assert!(run(&[("f", &["decimal:2"])], &[("f", "3.14")]).is_ok()); + assert!(run(&[("f", &["decimal:2"])], &[("f", "3.1")]).is_err()); + assert!(run(&[("f", &["decimal:2"])], &[("f", "3.141")]).is_err()); +} + +#[test] +fn decimal_range() { + assert!(run(&[("f", &["decimal:1,3"])], &[("f", "3.14")]).is_ok()); + assert!(run(&[("f", &["decimal:1,3"])], &[("f", "3")]).is_err()); + assert!(run(&[("f", &["decimal:1,3"])], &[("f", "3.1415")]).is_err()); +} + +// --- filled -------------------------------------------------------------- + +#[test] +fn filled_passes_when_absent_or_filled() { + assert!(run(&[("f", &["filled"])], &[("f", "abc")]).is_ok()); + // Absent (we model "absent" as empty in this validator). + assert!(run(&[("f", &["filled"])], &[("f", "abc")]).is_ok()); +} + +#[test] +fn filled_fails_when_empty_or_null() { + assert!(run(&[("f", &["filled"])], &[("f", "")]).is_err()); + assert!(run(&[("f", &["filled"])], &[("f", "null")]).is_err()); +} + +// --- required_with / required_with_all / required_without / required_without_all --- + +#[test] +fn required_with() { + // other is present → f required + let r = run( + &[("f", &["required_with:other"]), ("other", &[])], + &[("f", ""), ("other", "x")], + ); + assert!(r.is_err(), "f required when other is set, got {r:?}"); + + // other absent → f optional + let r = run( + &[("f", &["required_with:other"]), ("other", &[])], + &[("f", ""), ("other", "")], + ); + assert!(r.is_ok(), "f optional when other is absent, got {r:?}"); +} + +#[test] +fn required_with_all() { + let r = run( + &[ + ("f", &["required_with_all:a,b"]), + ("a", &[]), + ("b", &[]), + ], + &[("f", ""), ("a", "x"), ("b", "y")], + ); + assert!(r.is_err(), "required when all of a,b set, got {r:?}"); + + let r = run( + &[ + ("f", &["required_with_all:a,b"]), + ("a", &[]), + ("b", &[]), + ], + &[("f", ""), ("a", "x"), ("b", "")], + ); + assert!(r.is_ok(), "optional when not all set, got {r:?}"); +} + +#[test] +fn required_without() { + let r = run( + &[("f", &["required_without:other"]), ("other", &[])], + &[("f", ""), ("other", "")], + ); + assert!(r.is_err(), "required when other absent, got {r:?}"); + + let r = run( + &[("f", &["required_without:other"]), ("other", &[])], + &[("f", ""), ("other", "x")], + ); + assert!(r.is_ok(), "optional when other set, got {r:?}"); +} + +#[test] +fn required_without_all() { + let r = run( + &[ + ("f", &["required_without_all:a,b"]), + ("a", &[]), + ("b", &[]), + ], + &[("f", ""), ("a", ""), ("b", "")], + ); + assert!(r.is_err(), "required when all of a,b absent, got {r:?}"); + + let r = run( + &[ + ("f", &["required_without_all:a,b"]), + ("a", &[]), + ("b", &[]), + ], + &[("f", ""), ("a", "x"), ("b", "")], + ); + assert!(r.is_ok(), "optional when some are set, got {r:?}"); +} + +#[test] +fn required_unless() { + let r = run( + &[("f", &["required_unless:other,skip"]), ("other", &[])], + &[("f", ""), ("other", "skip")], + ); + assert!(r.is_ok(), "optional when other=skip, got {r:?}"); + + let r = run( + &[("f", &["required_unless:other,skip"]), ("other", &[])], + &[("f", ""), ("other", "do")], + ); + assert!(r.is_err(), "required when other != skip, got {r:?}"); +} + +// --- prohibited family --------------------------------------------------- + +#[test] +fn prohibited() { + assert!(run(&[("f", &["prohibited"])], &[("f", "")]).is_ok()); + assert!(run(&[("f", &["prohibited"])], &[("f", "x")]).is_err()); +} + +#[test] +fn prohibited_if() { + let r = run( + &[("f", &["prohibited_if:other,trigger"]), ("other", &[])], + &[("f", "x"), ("other", "trigger")], + ); + assert!(r.is_err()); + + let r = run( + &[("f", &["prohibited_if:other,trigger"]), ("other", &[])], + &[("f", "x"), ("other", "ok")], + ); + assert!(r.is_ok()); +} + +#[test] +fn prohibited_unless() { + let r = run( + &[("f", &["prohibited_unless:other,allow"]), ("other", &[])], + &[("f", "x"), ("other", "block")], + ); + assert!(r.is_err()); + + let r = run( + &[("f", &["prohibited_unless:other,allow"]), ("other", &[])], + &[("f", "x"), ("other", "allow")], + ); + assert!(r.is_ok()); +} + +#[test] +fn prohibits() { + let r = run( + &[("f", &["prohibits:other"]), ("other", &[])], + &[("f", "set"), ("other", "also-set")], + ); + assert!(r.is_err(), "f set prohibits other being set, got {r:?}"); + + let r = run( + &[("f", &["prohibits:other"]), ("other", &[])], + &[("f", "set"), ("other", "")], + ); + assert!(r.is_ok()); +} + +// --- date comparisons ---------------------------------------------------- + +#[test] +fn after() { + assert!(run(&[("f", &["after:2020-01-01"])], &[("f", "2020-06-01")]).is_ok()); + assert!(run(&[("f", &["after:2020-01-01"])], &[("f", "2019-12-31")]).is_err()); + assert!(run(&[("f", &["after:2020-01-01"])], &[("f", "2020-01-01")]).is_err()); +} + +#[test] +fn before() { + assert!(run(&[("f", &["before:2020-01-01"])], &[("f", "2019-06-01")]).is_ok()); + assert!(run(&[("f", &["before:2020-01-01"])], &[("f", "2021-01-01")]).is_err()); +} + +#[test] +fn after_or_equal() { + assert!(run(&[("f", &["after_or_equal:2020-01-01"])], &[("f", "2020-01-01")]).is_ok()); + assert!(run(&[("f", &["after_or_equal:2020-01-01"])], &[("f", "2019-12-31")]).is_err()); +} + +#[test] +fn before_or_equal() { + assert!(run(&[("f", &["before_or_equal:2020-01-01"])], &[("f", "2020-01-01")]).is_ok()); + assert!(run(&[("f", &["before_or_equal:2020-01-01"])], &[("f", "2020-01-02")]).is_err()); +} + +#[test] +fn date_comparison_with_field_reference() { + let r = run( + &[("end", &["after:start"]), ("start", &["date"])], + &[("end", "2020-06-01"), ("start", "2020-01-01")], + ); + assert!(r.is_ok(), "end after start, got {r:?}"); +} diff --git a/rule-validator/tests/parse_real_eggs.rs b/rule-validator/tests/parse_real_eggs.rs new file mode 100644 index 000000000..b391e35de --- /dev/null +++ b/rule-validator/tests/parse_real_eggs.rs @@ -0,0 +1,86 @@ +use std::{fs, path::Path}; + +use compact_str::CompactString; +use serde_json::Value; + +/// Walk both fixture directories and assert that every variable's rule string +/// parses cleanly. A real-world failure here means the validator silently +/// drops the variable on import (see backend's pterodactyl/pelican import). +#[test] +fn all_fixture_eggs_parse() { + let fixture_root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/eggs"); + + let mut total_eggs = 0; + let mut total_variables = 0; + let mut total_rules = 0; + let mut failures: Vec = Vec::new(); + + for panel_dir in ["pterodactyl", "pelican"] { + let dir = fixture_root.join(panel_dir); + for entry in fs::read_dir(&dir).unwrap_or_else(|_| panic!("read_dir {dir:?}")) { + let entry = entry.expect("dir entry"); + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + total_eggs += 1; + + let body = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {path:?}: {e}")); + let egg: Value = serde_json::from_str(&body) + .unwrap_or_else(|e| panic!("parse {path:?}: {e}")); + + let variables = egg + .get("variables") + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("no variables in {path:?}")); + + for var in variables { + total_variables += 1; + let env_name = var + .get("env_variable") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let rules_str = match var.get("rules").and_then(|v| v.as_str()) { + Some(s) => s, + None => continue, + }; + + let rules: Vec = rules_str + .split('|') + .filter(|s| !s.is_empty()) + .map(CompactString::from) + .collect(); + total_rules += rules.len(); + + if let Err(e) = rule_validator::validate_rules(&rules, &()) { + failures.push(format!( + "{}::{} ({}): {}", + path.file_name().unwrap().to_string_lossy(), + env_name, + rules_str, + e + )); + } + } + } + } + + if !failures.is_empty() { + panic!( + "{} of {} variables across {} eggs ({} rules) failed to parse:\n {}", + failures.len(), + total_variables, + total_eggs, + total_rules, + failures.join("\n ") + ); + } + + assert!(total_eggs >= 6, "expected at least 6 eggs, found {total_eggs}"); + assert!( + total_variables > 0, + "expected variables across fixtures, found {total_variables}" + ); +} diff --git a/rule-validator/tests/regressions.rs b/rule-validator/tests/regressions.rs new file mode 100644 index 000000000..3b3eb2b1c --- /dev/null +++ b/rule-validator/tests/regressions.rs @@ -0,0 +1,95 @@ +use std::collections::HashMap; + +use compact_str::CompactString; + +fn run(rules_per_field: &[(&str, &[&str])], values: &[(&str, &str)]) -> Result<(), String> { + // CompactString slices need to outlive the borrow, so collect them up front. + let rule_vecs: Vec<(&str, Vec)> = rules_per_field + .iter() + .map(|(k, rs)| (*k, rs.iter().map(|s| CompactString::from(*s)).collect())) + .collect(); + + let mut data: HashMap<&str, (&[CompactString], &str)> = HashMap::new(); + for (k, rules) in &rule_vecs { + let v = values + .iter() + .find(|(kk, _)| kk == k) + .map(|(_, v)| *v) + .unwrap_or(""); + data.insert(*k, (rules.as_slice(), v)); + } + + let validator = rule_validator::Validator::new(data).map_err(|e| e.to_string())?; + validator.validate() +} + +/// Bug fix #1: previously inverted (copy of doesnt_end_with). +#[test] +fn ends_with_accepts_matching_suffix() { + let r = run( + &[("f", &["ends_with:.zip,.tar.gz"])], + &[("f", "backup.zip")], + ); + assert!(r.is_ok(), "expected ok, got {r:?}"); +} + +#[test] +fn ends_with_rejects_non_matching_suffix() { + let r = run(&[("f", &["ends_with:.zip"])], &[("f", "backup.txt")]); + assert!(r.is_err(), "expected err, got {r:?}"); +} + +/// Bug fix #2: Numeric used to accept "+-+1" because it only checked char set. +#[test] +fn numeric_rejects_garbage() { + let r = run(&[("f", &["numeric"])], &[("f", "+-+1")]); + assert!(r.is_err(), "expected err, got {r:?}"); +} + +#[test] +fn numeric_accepts_real_numbers() { + for v in ["1", "-1.5", "0.0", "1e5", "1.2e-3"] { + let r = run(&[("f", &["numeric"])], &[("f", v)]); + assert!(r.is_ok(), "numeric should accept {v:?}, got {r:?}"); + } +} + +/// Bug fix #3: Max/Min now picks numeric-vs-length sensibly. +#[test] +fn max_uses_char_length_when_string_rule_present() { + // With `string`, max:5 means "5 characters or fewer". + let r = run(&[("f", &["string", "max:5"])], &[("f", "123456")]); + assert!(r.is_err(), "6 chars should violate max:5 (string), got {r:?}"); + + let r = run(&[("f", &["string", "max:5"])], &[("f", "1234")]); + assert!(r.is_ok(), "4 chars should satisfy max:5 (string), got {r:?}"); +} + +#[test] +fn max_uses_numeric_when_value_parses_as_number() { + // No `string` rule, value parses as number → numeric comparison. + let r = run(&[("f", &["max:100"])], &[("f", "200")]); + assert!(r.is_err(), "200 > 100, got {r:?}"); + + let r = run(&[("f", &["max:100"])], &[("f", "42")]); + assert!(r.is_ok(), "42 <= 100, got {r:?}"); +} + +/// Bug fix #4: Required now rejects literal "null" string, matching Nullable. +#[test] +fn required_rejects_null_string() { + let r = run(&[("f", &["required"])], &[("f", "null")]); + assert!(r.is_err(), "expected err for literal 'null', got {r:?}"); +} + +#[test] +fn required_rejects_empty() { + let r = run(&[("f", &["required"])], &[("f", "")]); + assert!(r.is_err()); +} + +#[test] +fn required_accepts_real_value() { + let r = run(&[("f", &["required"])], &[("f", "hello")]); + assert!(r.is_ok()); +}