use std::panic::{catch_unwind, AssertUnwindSafe};
use proptest::prelude::*;
use sui_spec::dockerfile::{apply, DockerfileArgs, MockDockerfileEnvironment};
const CASES: u32 = 256;
fn parse_must_not_panic(text: &str, build_args: &[(String, String)]) {
let owned = text.to_string();
let args = build_args.to_vec();
let result = catch_unwind(AssertUnwindSafe(|| {
let mut env = MockDockerfileEnvironment::default().with_dockerfile("fuzz", &owned);
for (k, v) in &args {
env = env.with_build_arg(k, v);
}
apply(&DockerfileArgs { path: "fuzz".into() }, &env).is_ok()
}));
assert!(result.is_ok(), "parser PANICKED on input {text:?} with args {build_args:?}");
}
proptest! {
#![proptest_config(ProptestConfig { cases: CASES, ..ProptestConfig::default() })]
#[test]
fn arbitrary_unicode_text_never_panics(text in ".*") {
parse_must_not_panic(&text, &[]);
}
#[test]
fn arbitrary_multiline_text_never_panics(lines in prop::collection::vec(".*", 0..40)) {
let text = lines.join("\n");
parse_must_not_panic(&text, &[]);
}
}
fn dollar_fragment() -> impl Strategy<Value = String> {
prop_oneof![
Just("$".to_string()),
Just("${".to_string()),
Just("}".to_string()),
Just("${}".to_string()),
Just("$$".to_string()),
Just("${A".to_string()),
Just("A}".to_string()),
Just("\u{00e9}".to_string()), Just("\u{4e2d}".to_string()), Just("\u{1f600}".to_string()), Just("_".to_string()),
Just("A".to_string()),
Just(" ".to_string()),
"[a-zA-Z0-9_]{1,4}".prop_map(|s| s),
]
}
proptest! {
#![proptest_config(ProptestConfig { cases: CASES, ..ProptestConfig::default() })]
#[test]
fn dollar_injection_in_a_run_body_never_panics(frags in prop::collection::vec(dollar_fragment(), 0..30)) {
let body: String = frags.concat();
let text = format!("FROM x\nRUN echo {body}\n");
parse_must_not_panic(&text, &[]);
}
#[test]
fn dollar_injection_in_an_env_value_never_panics(frags in prop::collection::vec(dollar_fragment(), 0..30)) {
let body: String = frags.concat();
let text = format!("FROM x\nENV FOO={body}\n");
parse_must_not_panic(&text, &[]);
}
#[test]
fn dollar_injection_with_supplied_args_never_panics(
frags in prop::collection::vec(dollar_fragment(), 0..30),
arg_name in "[A-Z][A-Z0-9_]{0,6}",
arg_val in ".{0,20}",
) {
let body: String = frags.concat();
let text = format!("FROM x\nARG {arg_name}\nRUN echo {body} ${arg_name}\n");
parse_must_not_panic(&text, &[(arg_name, arg_val)]);
}
}
fn keyword() -> impl Strategy<Value = &'static str> {
prop_oneof![
Just("FROM"), Just("RUN"), Just("COPY"), Just("ARG"), Just("ENV"),
Just("WORKDIR"), Just("CMD"), Just("ENTRYPOINT"), Just("VOLUME"),
Just("HEALTHCHECK"), Just("LABEL"), Just("USER"), Just("ZZZ"), Just("--from=x"),
]
}
proptest! {
#![proptest_config(ProptestConfig { cases: CASES, ..ProptestConfig::default() })]
#[test]
fn random_instruction_lines_never_panic(
lines in prop::collection::vec((keyword(), ".{0,40}"), 0..25)
) {
let text: String = lines.iter().map(|(kw, rest)| format!("{kw} {rest}")).collect::<Vec<_>>().join("\n");
parse_must_not_panic(&text, &[]);
}
#[test]
fn random_json_array_operands_never_panic(
kw in prop_oneof![Just("CMD"), Just("ENTRYPOINT"), Just("VOLUME")],
inner in r#"[\[\]",a-z /]{0,40}"#,
) {
let text = format!("FROM x\n{kw} [{inner}]\n");
parse_must_not_panic(&text, &[]);
}
#[test]
fn random_flag_operands_never_panic(
flags in prop::collection::vec("--[a-z]{0,6}(=[a-z,=.]{0,15})?", 0..6),
body in ".{0,30}",
) {
let flag_str = flags.join(" ");
let run = format!("FROM x\nRUN {flag_str} {body}\n");
let copy = format!("FROM x\nCOPY {flag_str} {body}\n");
parse_must_not_panic(&run, &[]);
parse_must_not_panic(©, &[]);
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
#[test]
fn deep_continuation_chains_never_panic_or_blow_the_stack(depth in 0usize..8000) {
let mut text = String::from("FROM x\nRUN a");
for _ in 0..depth {
text.push_str(" \\\n b");
}
text.push('\n');
parse_must_not_panic(&text, &[]);
}
#[test]
fn very_long_single_lines_never_panic(len in 0usize..200_000) {
let big = "a".repeat(len);
parse_must_not_panic(&format!("FROM x\nRUN {big}\n"), &[]);
parse_must_not_panic(&format!("FROM {big}\n"), &[]);
}
#[test]
fn many_instructions_never_panic(count in 0usize..3000) {
let mut text = String::from("FROM x\n");
for i in 0..count {
text.push_str(&format!("ARG A{i}=v{i}\n"));
}
parse_must_not_panic(&text, &[]);
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: CASES, ..ProptestConfig::default() })]
#[test]
fn control_and_whitespace_bytes_never_panic(
parts in prop::collection::vec(
prop_oneof![
Just("\0".to_string()),
Just("\r".to_string()),
Just("\n".to_string()),
Just("\t".to_string()),
Just("\u{feff}".to_string()), Just("\u{0007}".to_string()), Just("FROM x".to_string()),
Just("RUN echo".to_string()),
Just("$VAR".to_string()),
".{0,10}",
],
0..40,
)
) {
let text: String = parts.concat();
parse_must_not_panic(&text, &[]);
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: CASES, ..ProptestConfig::default() })]
#[test]
fn a_successful_parse_is_deterministic(
frags in prop::collection::vec(dollar_fragment(), 0..20),
arch in prop_oneof![Just("amd64"), Just("arm64"), Just("s390x")],
) {
let body: String = frags.concat();
let text = format!("FROM debian\nARG TARGETARCH\nRUN echo {body} for $TARGETARCH\nCMD [\"/bin/true\"]\n");
let build = || {
let env = MockDockerfileEnvironment::default()
.with_dockerfile("d", &text)
.with_build_arg("TARGETARCH", arch);
apply(&DockerfileArgs { path: "d".into() }, &env)
};
match (build(), build()) {
(Ok(g1), Ok(g2)) => {
prop_assert_eq!(g1, g2, "a successful parse must be byte-deterministic");
}
(Err(_), Err(_)) => { }
(a, b) => prop_assert!(false, "non-deterministic outcome: {:?} vs {:?}", a.is_ok(), b.is_ok()),
}
}
}