#[cfg(test)]
mod fixtures;
mod forms;
pub use forms::bullets;
use serde_json::Value;
use shep_core::values::{MemSize, UpDuration};
use super::field::{Bounds, Field, FieldKind, ListItem, ValueKind};
use forms::{DURATION_FORMS, Form, MEMORY_FORMS};
pub const REDACTED: &str = "<set>";
#[must_use]
pub(super) fn shown_value(secret: bool, typed: &str) -> &str {
if secret { REDACTED } else { typed }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refusal {
pub text: String,
}
#[must_use]
pub fn refusal(field: &Field, typed: &str) -> Option<Refusal> {
if typed.is_empty() {
return None;
}
let shown = shown_value(field.secret, typed);
if field.kind == FieldKind::Integer {
return integer_refusal(&field.key, &field.bounds, typed, shown);
}
match field.value_kind {
Some(ValueKind::UpDuration) => typed
.parse::<UpDuration>()
.is_err()
.then(|| refuse(&field.key, shown, "a duration", DURATION_FORMS)),
Some(ValueKind::MemSize) => typed
.parse::<MemSize>()
.is_err()
.then(|| refuse(&field.key, shown, "a size", MEMORY_FORMS)),
None => pattern_refusal(
&field.key,
field.bounds.pattern.as_deref(),
&field.accepts,
typed,
shown,
),
}
}
#[must_use]
pub fn item_refusal(
key: &str,
item: &ListItem,
bounds: &Bounds,
typed: &str,
secret: bool,
) -> Option<Refusal> {
let shown = shown_value(secret, typed);
let value = match item {
ListItem::Integer => match integer_of(key, bounds, typed, shown) {
Ok(number) => {
if let Some(refused) = range_refusal(key, bounds, shown, number) {
return Some(refused);
}
Value::from(number)
}
Err(refused) => return Some(refused),
},
ListItem::Text | ListItem::Choice(_) => {
if let Some(refused) = min_length_refusal(key, bounds, typed, shown) {
return Some(refused);
}
if let Some(refused) =
pattern_refusal(key, bounds.pattern.as_deref(), &[], typed, shown)
{
return Some(refused);
}
Value::String(typed.to_owned())
}
};
enum_refusal(key, bounds, &value, shown)
}
fn min_length_refusal(key: &str, bounds: &Bounds, typed: &str, shown: &str) -> Option<Refusal> {
let least = bounds.min_length?;
(typed.chars().count() < least).then(|| Refusal {
text: format!("{key} is \"{shown}\"; {key} needs at least {least} characters"),
})
}
fn enum_refusal(key: &str, bounds: &Bounds, value: &Value, shown: &str) -> Option<Refusal> {
if bounds.choices.is_empty()
|| bounds
.choices
.iter()
.any(|choice| choice_taken(choice, value))
{
return None;
}
let allowed: Vec<String> = bounds.choices.iter().map(choice_text).collect();
Some(Refusal {
text: format!(
"{key} is \"{shown}\"; {key} takes one of {}",
allowed.join(", ")
),
})
}
fn choice_taken(choice: &Value, value: &Value) -> bool {
match (choice.as_i64(), value.as_i64()) {
(Some(written), Some(typed)) => written == typed,
_ => match (choice.as_f64(), value.as_f64()) {
(Some(written), Some(typed)) => written == typed,
_ => choice == value,
},
}
}
fn choice_text(choice: &Value) -> String {
match choice {
Value::String(text) => text.clone(),
other => other.to_string(),
}
}
fn integer_refusal(key: &str, bounds: &Bounds, typed: &str, shown: &str) -> Option<Refusal> {
match integer_of(key, bounds, typed, shown) {
Ok(number) => range_refusal(key, bounds, shown, number),
Err(refused) => Some(refused),
}
}
fn integer_of(key: &str, bounds: &Bounds, typed: &str, shown: &str) -> Result<i64, Refusal> {
if bounds.unsatisfiable {
return Err(Refusal {
text: format!("{key} takes no value: its schema asks for a range with nothing in it"),
});
}
typed
.parse::<i64>()
.map_err(|_| not_a_whole_number(key, shown))
}
fn range_refusal(key: &str, bounds: &Bounds, shown: &str, number: i64) -> Option<Refusal> {
match (bounds.minimum, bounds.maximum) {
(Some(min), _) if number < min => Some(Refusal {
text: format!("{key} is \"{shown}\"; {key} starts at {min}"),
}),
(_, Some(max)) if number > max => Some(Refusal {
text: format!("{key} is \"{shown}\"; {key} stops at {max}"),
}),
_ => None,
}
}
fn pattern_refusal(
key: &str,
pattern: Option<&str>,
accepts: &[String],
typed: &str,
shown: &str,
) -> Option<Refusal> {
let compiled = regex::Regex::new(pattern?).ok()?;
if compiled.is_match(typed) {
return None;
}
let takes = if accepts.is_empty() {
format!("it matches {}", pattern?)
} else {
accepts.join(", ")
};
Some(Refusal {
text: format!("{key} is \"{shown}\", which it does not take; try {takes}"),
})
}
pub(super) fn not_a_whole_number(key: &str, shown: &str) -> Refusal {
Refusal {
text: format!("{key} is \"{shown}\", which is not a whole number shep accepts"),
}
}
fn refuse(key: &str, shown: &str, what: &str, forms: &[Form]) -> Refusal {
let takes: Vec<&str> = forms.iter().map(|form| form.text).collect();
Refusal {
text: format!(
"{key} is \"{shown}\", which is not {what} shep accepts; try {}",
takes.join(", ")
),
}
}
#[cfg(test)]
mod tests {
use super::fixtures::text_field;
use super::forms::{DURATION_FORMS, DURATION_REFUSALS, MEMORY_FORMS};
use super::*;
use shep_core::values::UpDuration;
#[test]
fn the_shown_value_of_a_secret_is_the_marker_and_never_the_buffer() {
let mut field = text_field("token");
assert_eq!(
shown_value(field.secret, "sk_live_HUNTER2"),
"sk_live_HUNTER2"
);
field.secret = true;
assert_eq!(shown_value(field.secret, "sk_live_HUNTER2"), REDACTED);
assert!(
!not_a_whole_number(&field.key, shown_value(field.secret, "sk_live_HUNTER2"))
.text
.contains("HUNTER2")
);
}
#[test]
fn a_dogs_nullable_duration_refuses_a_day() {
let field = duration_field();
assert_eq!(
field.value_kind,
Some(ValueKind::UpDuration),
"the $ref must resolve through the anyOf or this test proves nothing"
);
assert!(
"1d".parse::<UpDuration>().is_err(),
"1d must be outside the grammar or there is nothing to refuse"
);
let refused = refusal(&field, "1d").expect("1d is not a duration shep parses");
assert_eq!(
refused.text,
"max_age is \"1d\", which is not a duration shep accepts; \
try 500ms, 2s, 5m, 1h, a bare number is milliseconds"
);
}
#[test]
fn the_spellings_the_panel_offers_are_the_spellings_it_takes() {
let field = duration_field();
for accepted in DURATION_FORMS.iter().flat_map(|form| form.examples) {
assert_eq!(
refusal(&field, accepted),
None,
"{accepted} is printed as accepted and must be filed"
);
}
for refused in DURATION_REFUSALS.iter().flat_map(|form| form.examples) {
assert!(
refusal(&field, refused).is_some(),
"{refused} is printed as refused and must not be filed"
);
}
}
#[test]
fn a_size_field_refuses_a_spelling_shep_does_not_take() {
let mut field = text_field("max_size");
field.value_kind = Some(ValueKind::MemSize);
assert_eq!(
refusal(&field, "10MB")
.expect("10MB is not shep's spelling")
.text,
"max_size is \"10MB\", which is not a size shep accepts; \
try 512M, 2G, a bare number is bytes"
);
for accepted in MEMORY_FORMS.iter().flat_map(|form| form.examples) {
assert_eq!(
refusal(&field, accepted),
None,
"{accepted} is printed as accepted and must be filed"
);
}
}
#[test]
fn a_secrets_value_never_reaches_the_sentence_or_the_debug() {
const CREDENTIAL: &str = "sk_live_HUNTER2";
let mut pattern_door = text_field("token");
pattern_door.secret = true;
pattern_door.bounds.pattern = Some("^sk-".to_owned());
let refused = refusal(&pattern_door, CREDENTIAL).expect("the pattern refuses it");
assert_eq!(
refused.text,
"token is \"<set>\", which it does not take; try it matches ^sk-"
);
assert_eq!(
format!("{refused:?}"),
"Refusal { text: \"token is \\\"<set>\\\", which it does not take; try it matches ^sk-\" }"
);
let mut grammar_door = text_field("api_key");
grammar_door.secret = true;
grammar_door.value_kind = Some(ValueKind::UpDuration);
let refused = refusal(&grammar_door, CREDENTIAL).expect("not a duration");
assert!(!refused.text.contains(CREDENTIAL), "{}", refused.text);
assert!(refused.text.contains("<set>"), "{}", refused.text);
let mut integer_door = text_field("pin");
integer_door.secret = true;
integer_door.kind = FieldKind::Integer;
let refused = refusal(&integer_door, CREDENTIAL).expect("not a number");
assert_eq!(
refused.text,
"pin is \"<set>\", which is not a whole number shep accepts"
);
let mut bounded = text_field("pin");
bounded.secret = true;
bounded.kind = FieldKind::Integer;
bounded.bounds.minimum = Some(1000);
let refused = refusal(&bounded, "7").expect("below the floor");
assert_eq!(refused.text, "pin is \"<set>\"; pin starts at 1000");
}
#[test]
fn an_ordinary_field_still_quotes_what_was_typed() {
let mut field = text_field("region");
field.bounds.pattern = Some("^eu-".to_owned());
assert!(
refusal(&field, "Europe")
.expect("refused")
.text
.contains("Europe"),
);
}
#[test]
fn a_range_with_nothing_in_it_says_so() {
let mut field = text_field("workers");
field.kind = FieldKind::Integer;
field.bounds.minimum = Some(3);
field.bounds.maximum = Some(1);
field.bounds.unsatisfiable = true;
for typed in ["1", "2", "3", "not a number"] {
assert_eq!(
refusal(&field, typed).expect("nothing is acceptable").text,
"workers takes no value: its schema asks for a range with nothing in it",
"{typed}"
);
}
}
#[test]
fn an_empty_buffer_is_not_a_refusal() {
assert_eq!(refusal(&duration_field(), ""), None);
}
#[test]
fn a_dogs_own_pattern_refuses_what_it_does_not_match() {
let mut field = text_field("region");
field.bounds.pattern = Some("^[a-z]{2}-[a-z]+-[0-9]$".to_owned());
assert_eq!(
refusal(&field, "eu-west-1"),
None,
"the pattern's own shape"
);
let refused = refusal(&field, "Europe").expect("Europe does not match");
assert_eq!(
refused.text,
"region is \"Europe\", which it does not take; try it matches ^[a-z]{2}-[a-z]+-[0-9]$"
);
field.accepts = vec!["a region like eu-west-1".to_owned()];
let refused = refusal(&field, "Europe").expect("still refused");
assert_eq!(
refused.text,
"region is \"Europe\", which it does not take; try a region like eu-west-1",
"the author's own words win over the regex"
);
}
#[test]
fn a_pattern_that_does_not_compile_refuses_nothing() {
let broken = ["^[", "a-z"].concat();
assert!(
regex::Regex::new(&broken).is_err(),
"the fixture must be a pattern regex really refuses"
);
let mut field = text_field("region");
field.bounds.pattern = Some(broken);
assert_eq!(refusal(&field, "anything at all"), None);
}
#[test]
fn an_integers_schema_floor_and_ceiling_are_enforced() {
let mut field = text_field("keep");
field.kind = FieldKind::Integer;
field.bounds.minimum = Some(1);
field.bounds.maximum = Some(64);
assert_eq!(refusal(&field, "1"), None, "the floor itself is allowed");
assert_eq!(refusal(&field, "64"), None, "the ceiling itself is allowed");
let refused = refusal(&field, "0").expect("0 is below the floor");
assert_eq!(refused.text, "keep is \"0\"; keep starts at 1");
let refused = refusal(&field, "65").expect("65 is above the ceiling");
assert_eq!(refused.text, "keep is \"65\"; keep stops at 64");
}
#[test]
fn an_integer_field_refuses_text_out_loud() {
let mut field = text_field("max_restarts");
field.kind = FieldKind::Integer;
let refused = refusal(&field, "lots").expect("lots is not a number");
assert_eq!(
refused.text,
"max_restarts is \"lots\", which is not a whole number shep accepts"
);
}
#[test]
fn a_field_with_no_grammar_and_no_pattern_refuses_nothing() {
assert_eq!(refusal(&text_field("cwd"), "/srv/web"), None);
assert_eq!(refusal(&text_field("cwd"), "!!! anything"), None);
}
#[test]
fn every_item_rule_refuses_one_element_and_takes_the_next() {
let min_length = Bounds {
min_length: Some(3),
..Default::default()
};
let pattern = Bounds {
pattern: Some("^[a-z]+$".to_owned()),
..Default::default()
};
let words = Bounds {
choices: vec![serde_json::json!("ask"), serde_json::json!("auto")],
..Default::default()
};
let numbers = Bounds {
choices: vec![
serde_json::json!(1),
serde_json::json!(2),
serde_json::json!(3),
],
..Default::default()
};
let range = Bounds {
minimum: Some(1),
maximum: Some(64),
..Default::default()
};
let cases = [
(
"minLength",
ListItem::Text,
&min_length,
"abc",
"ab",
"tags is \"ab\"; tags needs at least 3 characters",
),
(
"pattern",
ListItem::Text,
&pattern,
"abc",
"Ab1",
"tags is \"Ab1\", which it does not take; try it matches ^[a-z]+$",
),
(
"enum",
ListItem::Text,
&words,
"auto",
"never",
"tags is \"never\"; tags takes one of ask, auto",
),
(
"integer enum",
ListItem::Integer,
&numbers,
"2",
"4",
"tags is \"4\"; tags takes one of 1, 2, 3",
),
(
"minimum",
ListItem::Integer,
&range,
"1",
"0",
"tags is \"0\"; tags starts at 1",
),
(
"maximum",
ListItem::Integer,
&range,
"64",
"65",
"tags is \"65\"; tags stops at 64",
),
];
for (rule, item, bounds, accepted, refused, sentence) in cases {
assert_eq!(
item_refusal("tags", &item, bounds, accepted, false),
None,
"{rule}: {accepted} is the value the rule takes"
);
let refused = item_refusal("tags", &item, bounds, refused, false).expect(rule);
assert_eq!(refused.text, sentence, "{rule}");
}
}
#[test]
fn an_enum_number_written_with_a_decimal_point_still_takes_the_integer() {
let bounds = Bounds {
choices: vec![serde_json::json!(1.0), serde_json::json!(2.0)],
..Default::default()
};
for typed in ["1", "2"] {
assert_eq!(
item_refusal("levels", &ListItem::Integer, &bounds, typed, false),
None,
"{typed} is the value the schema wrote"
);
}
let refused = item_refusal("levels", &ListItem::Integer, &bounds, "3", false)
.expect("3 is not in the set");
assert_eq!(
refused.text,
"levels is \"3\"; levels takes one of 1.0, 2.0"
);
let halves = Bounds {
choices: vec![serde_json::json!(1.5)],
..Default::default()
};
let refused =
item_refusal("levels", &ListItem::Integer, &halves, "1", false).expect("1 is not 1.5");
assert_eq!(refused.text, "levels is \"1\"; levels takes one of 1.5");
}
#[test]
fn an_integer_element_that_is_not_a_number_is_refused_out_loud() {
let bounds = Bounds::default();
let refused = item_refusal(
"stop_exit_codes",
&ListItem::Integer,
&bounds,
"lots",
false,
)
.expect("lots is not a number");
assert_eq!(
refused.text,
"stop_exit_codes is \"lots\", which is not a whole number shep accepts"
);
assert_eq!(
item_refusal("stop_exit_codes", &ListItem::Integer, &bounds, "-1", false),
None,
"a negative exit code is a whole number"
);
}
#[test]
fn a_list_with_no_item_rules_refuses_nothing() {
let bounds = Bounds::default();
for typed in ["a", "!!! anything", ""] {
assert_eq!(
item_refusal("args", &ListItem::Text, &bounds, typed, false),
None,
"{typed}"
);
}
}
#[test]
fn a_min_length_counts_characters_and_not_bytes() {
let bounds = Bounds {
min_length: Some(4),
..Default::default()
};
assert_eq!(
item_refusal("tags", &ListItem::Text, &bounds, "abc\u{00e9}", false),
None,
"four characters, five bytes"
);
let refused = item_refusal("tags", &ListItem::Text, &bounds, "ab\u{00e9}", false)
.expect("three characters is under four");
assert_eq!(
refused.text,
"tags is \"ab\u{00e9}\"; tags needs at least 4 characters"
);
}
#[test]
fn a_list_pattern_that_does_not_compile_refuses_nothing() {
let broken = ["^[", "a-z"].concat();
assert!(
regex::Regex::new(&broken).is_err(),
"the fixture must be a pattern regex really refuses"
);
let bounds = Bounds {
pattern: Some(broken),
..Default::default()
};
assert_eq!(
item_refusal("tags", &ListItem::Text, &bounds, "ab", false),
None,
"a pattern that does not compile bounds nothing"
);
}
#[test]
fn a_secret_lists_refusal_names_the_marker_and_never_the_element() {
const CREDENTIAL: &str = "tok_HUNTER2";
let pattern = Bounds {
pattern: Some("^sk-".to_owned()),
..Default::default()
};
let refused = item_refusal("tokens", &ListItem::Text, &pattern, CREDENTIAL, true)
.expect("the pattern refuses it");
assert_eq!(
refused.text,
"tokens is \"<set>\", which it does not take; try it matches ^sk-"
);
let min_length = Bounds {
min_length: Some(32),
..Default::default()
};
let refused = item_refusal("tokens", &ListItem::Text, &min_length, CREDENTIAL, true)
.expect("too short");
assert!(!refused.text.contains(CREDENTIAL), "{}", refused.text);
assert!(refused.text.contains("<set>"), "{}", refused.text);
let choices = Bounds {
choices: vec![serde_json::json!("ask"), serde_json::json!("auto")],
..Default::default()
};
let refused = item_refusal("tokens", &ListItem::Text, &choices, CREDENTIAL, true)
.expect("not a member");
assert!(!refused.text.contains(CREDENTIAL), "{}", refused.text);
let range = Bounds {
minimum: Some(1000),
..Default::default()
};
let refused =
item_refusal("pins", &ListItem::Integer, &range, "7", true).expect("below the floor");
assert_eq!(refused.text, "pins is \"<set>\"; pins starts at 1000");
}
#[test]
fn an_ordinary_element_is_still_quoted_back() {
let bounds = Bounds {
pattern: Some("^eu-".to_owned()),
..Default::default()
};
assert!(
item_refusal("regions", &ListItem::Text, &bounds, "Europe", false)
.expect("refused")
.text
.contains("Europe"),
);
}
fn duration_field() -> Field {
let schema = serde_json::json!({
"$defs": {
"UpDuration": { "type": "string", "pattern": r"^\d+(ms|h|m|s)?$" },
},
"properties": {
"max_age": {
"anyOf": [{ "$ref": "#/$defs/UpDuration" }, { "type": "null" }],
"description": "Also rotate this long after the last rotation.",
},
},
});
crate::lookout::pane::ConfigPane::dog("log-rotate".to_owned(), None, schema, String::new())
.fields()
.by_key("max_age")
.expect("the schema declares max_age")
.clone()
}
}