use printobserver_types::contract::Sample;
use printobserver_types::{FileName, FileNameRefusal, SEPARATORS};
use proptest::prelude::*;
use proptest::test_runner::{Config, FileFailurePersistence, TestCaseError, TestRng, TestRunner};
use serde_json::Value;
const ALPHABET: [char; 72] = [
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l',
'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4',
'5', '6', '7', '8', '9', '.', '-', '_', '+', '(', ')', ' ', 'Γ©', 'δΈ', 'π',
];
const DEFAULT_SEED: [u8; 32] = *b"printobserver-file-name-seed-001";
const CASES: u32 = 128;
fn segment() -> impl Strategy<Value = String> {
prop::collection::vec(prop::sample::select(ALPHABET.as_slice()), 1..7).prop_map(|characters| {
let text: String = characters.into_iter().collect();
if text == "." || text == ".." {
format!("{text}x")
} else {
text
}
})
}
fn segments() -> impl Strategy<Value = Vec<String>> {
prop::collection::vec(segment(), 1..4)
}
fn insert_at(value: &str, index: usize, inserted: char) -> String {
let mut characters: Vec<char> = value.chars().collect();
characters.insert(index, inserted);
characters.into_iter().collect()
}
fn on_the_wire(file_name: &str) -> Value {
Value::String(file_name.to_owned())
}
fn wire_refuses(candidate: &str) -> bool {
serde_json::from_value::<FileName>(on_the_wire(candidate)).is_err()
}
fn assert_refused(candidate: &str) -> Result<(), TestCaseError> {
if FileName::new(candidate).is_ok() {
return Err(TestCaseError::fail(format!(
"construction accepted {candidate:?}"
)));
}
if !wire_refuses(candidate) {
return Err(TestCaseError::fail(format!(
"the wire accepted {candidate:?}"
)));
}
Ok(())
}
fn assert_accepted(candidate: &str) -> Result<(), TestCaseError> {
let name = FileName::new(candidate)
.map_err(|error| TestCaseError::fail(format!("construction refused: {error}")))?;
if name.as_str() != candidate {
return Err(TestCaseError::fail(format!(
"{candidate:?} came back as {name}"
)));
}
let file_name: FileName = serde_json::from_value(on_the_wire(candidate))
.map_err(|error| TestCaseError::fail(format!("the wire refused {candidate:?}: {error}")))?;
if file_name.as_str() != candidate {
return Err(TestCaseError::fail(format!(
"the wire returned {file_name}"
)));
}
Ok(())
}
fn drive_refusals(parts: &[String]) -> Result<(), TestCaseError> {
let single = parts.join("");
let length = single.chars().count();
for index in 0..=length {
for separator in SEPARATORS {
assert_refused(&insert_at(&single, index, separator))?;
}
assert_refused(&insert_at(&single, index, '\0'))?;
}
for separator in SEPARATORS {
for dots in [".", ".."] {
for position in 0..=parts.len() {
let mut joined: Vec<String> = parts.to_vec();
joined.insert(position, dots.to_owned());
assert_refused(&joined.join(&separator.to_string()))?;
}
assert_refused(dots)?;
}
}
for letter in ('a'..='z').chain('A'..='Z') {
assert_refused(&format!("{letter}:{single}"))?;
for separator in SEPARATORS {
assert_refused(&format!("{letter}:{separator}{single}"))?;
}
}
for separator in SEPARATORS {
assert_refused(&format!("{separator}{single}"))?;
}
assert_refused("")
}
#[test]
fn generated_names_are_refused_for_every_forbidden_property() {
let seed = std::env::var("PRINTOBSERVER_FILE_NAME_SEED")
.ok()
.and_then(|text| {
let bytes = text.into_bytes();
<[u8; 32]>::try_from(bytes.as_slice()).ok()
})
.unwrap_or(DEFAULT_SEED);
let config = Config {
cases: CASES,
failure_persistence: Some(Box::new(FileFailurePersistence::Off)),
..Config::default()
};
let mut runner = TestRunner::new_with_rng(
config,
TestRng::from_seed(proptest::test_runner::RngAlgorithm::ChaCha, &seed),
);
let outcome = runner.run(&segments(), |parts| {
assert_accepted(&parts[0])?;
drive_refusals(&parts)
});
assert!(
outcome.is_ok(),
"seed {}: {outcome:?}",
String::from_utf8_lossy(&seed)
);
}
const ACCEPTED_CORPUS: [&str; 7] = [
".gitignore",
"..hidden",
"ab:file.gcode",
"tab\there.gcode",
"newline\nhere.gcode",
" leading.gcode",
"trailing.gcode ",
];
#[test]
fn the_fixed_accepted_corpus_is_accepted() {
for candidate in ACCEPTED_CORPUS {
assert_accepted(candidate)
.unwrap_or_else(|error| panic!("{candidate:?} was refused: {error}"));
}
}
const REGRESSION_CORPUS: [&str; 10] = [
"",
".",
"..",
"/etc/passwd",
"..\\..\\windows",
"benchy/../../etc/passwd",
"C:benchy.gcode",
"c:\\benchy.gcode",
"benchy\0.gcode",
"/benchy.gcode",
];
#[test]
fn the_fixed_regression_corpus_is_refused() {
for candidate in REGRESSION_CORPUS {
assert_refused(candidate)
.unwrap_or_else(|error| panic!("{candidate:?} was accepted: {error}"));
}
}
#[test]
fn the_walk_ranges_over_every_declared_category() {
let ranged_over = [
FileNameRefusal::Empty,
FileNameRefusal::Nul,
FileNameRefusal::LeadingSeparator,
FileNameRefusal::Separator,
FileNameRefusal::DotSegment,
FileNameRefusal::DrivePrefix,
];
assert_eq!(FileNameRefusal::ALL.to_vec(), ranged_over.to_vec());
assert_eq!(
SEPARATORS,
['/', '\\'],
"the separator set is not the two the type declares"
);
}
#[test]
fn each_declared_category_refuses_in_its_own_words() {
let cases = [
("", FileNameRefusal::Empty),
("benchy\0.gcode", FileNameRefusal::Nul),
("/benchy.gcode", FileNameRefusal::LeadingSeparator),
("a/benchy.gcode", FileNameRefusal::Separator),
("..", FileNameRefusal::DotSegment),
("c:benchy.gcode", FileNameRefusal::DrivePrefix),
];
for (candidate, expected) in cases {
let error = FileName::new(candidate).expect_err("the candidate is refused");
assert_eq!(error.refusal(), expected, "{candidate:?}");
assert_eq!(error.value(), candidate);
assert!(!error.to_string().is_empty());
}
assert_eq!(
cases.len(),
FileNameRefusal::ALL.len(),
"a category is not reached"
);
}
#[test]
fn the_canonical_name_is_accepted() {
assert_accepted(FileName::sample_full().as_str()).expect("the canonical name is accepted");
}