diff --git a/Cargo.lock b/Cargo.lock index 0eaca78..4ef2c70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,10 +224,11 @@ dependencies = [ "figment", "fully_pub", "futures-util", + "glob", "itertools", "k8s-openapi", "kube", - "rust_search", + "pretty_assertions", "serde", "serde_yml", "simplelog", @@ -410,7 +411,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim 0.11.1", + "strsim", "unicase", "unicode-width", ] @@ -528,7 +529,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim 0.11.1", + "strsim", "syn 2.0.87", ] @@ -570,6 +571,12 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00" +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -580,26 +587,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "dirs" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -673,9 +660,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ "atomic", + "parking_lot", "pear", "serde", "serde_yaml", + "tempfile", "uncased", "version_check", ] @@ -821,6 +810,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "globset" version = "0.4.15" @@ -1556,16 +1551,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "num_threads" version = "0.1.7" @@ -1818,6 +1803,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.89" @@ -1888,17 +1883,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom", - "libredox", - "thiserror", -] - [[package]] name = "regex" version = "1.11.1" @@ -1943,19 +1927,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rust_search" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27d7be20245d289c9dde663f06521de08663d73cbaefc45785aa65d02022378" -dependencies = [ - "dirs", - "ignore", - "num_cpus", - "regex", - "strsim 0.10.0", -] - [[package]] name = "rustc-demangle" version = "0.1.24" @@ -2328,12 +2299,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index e716cff..47db42d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ anyhow = "1.0.82" clap = { version = "4.5.4", features = ["unicode", "env", "derive"] } clap-verbosity-flag = "2.2.0" itertools = "0.12.1" -rust_search = "2.1.0" +glob = "0.3.1" serde = { version = "1.0", features = ["derive"] } serde_yml = "0.0.12" tera = "1.19.1" @@ -26,4 +26,7 @@ tokio = { version = "1.38.0", features = ["rt", "macros"] } bollard = "0.16.1" tar = "0.4.42" tempfile = "3.13.0" -figment = { version = "0.10.19", features = ["env", "yaml"] } +figment = { version = "0.10.19", features = ["env", "yaml", "test"] } + +[dev-dependencies] +pretty_assertions = "1.4.1" diff --git a/src/configparser/challenge.rs b/src/configparser/challenge.rs index b0bb92c..62b9339 100644 --- a/src/configparser/challenge.rs +++ b/src/configparser/challenge.rs @@ -1,10 +1,12 @@ -use anyhow::{Context, Error, Result}; +use anyhow::{anyhow, Context, Error, Result}; +use figment::providers::{Env, Format, Serialized, Yaml}; +use figment::Figment; use fully_pub::fully_pub; -use rust_search::SearchBuilder; +use glob::glob; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use simplelog::*; use std::collections::HashMap as Map; -use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; use void::Void; @@ -12,38 +14,98 @@ use void::Void; use crate::configparser::config::Resource; use crate::configparser::field_coersion::string_or_struct; -use figment::providers::{Env, Format, Serialized, Yaml}; -use figment::Figment; - -pub fn parse_all() -> Vec> { +pub fn parse_all() -> Result, Vec> { // find all challenge.yaml files - SearchBuilder::default() - .location(".") - .search_input("challenge.yaml") - .build() + // only look for paths two entries deep (i.e. always at `//challenge.yaml`) + let (challenges, parse_errors): (Vec<_>, Vec<_>) = glob("*/*/challenge.yaml") + .unwrap() // static pattern so will never error // try to parse each one - .map(|path| { - parse_one(&path).with_context(|| format!("failed to parse challenge config {}", path)) + .map(|glob_result| match glob_result { + Ok(path) => parse_one(&path) + .with_context(|| format!("failed to parse challenge config {:?}", path)), + Err(e) => Err(e.into()), }) - .collect() + .partition_result(); + + trace!( + "parsed chals: {:?}", + challenges + .iter() + .map(|c| format!("{}/{}", c.category, c.name)) + .collect::>() + ); + debug!( + "parsed {} chals, {} others failed parsing", + challenges.len(), + parse_errors.len() + ); + + if parse_errors.is_empty() { + Ok(challenges) + } else { + Err(parse_errors) + } } -pub fn parse_one(path: &str) -> Result { - trace!("trying to parse {path}"); +pub fn parse_one(path: &PathBuf) -> Result { + trace!("trying to parse {path:?}"); + + // remove 'challenge.yaml' from path + let chal_dir = path + .parent() + .expect("could not extract path from search path"); // extract category from challenge path - let category = Path::new(path) + let category = chal_dir .components() - .nth_back(2) + .nth_back(1) .expect("could not find category from path") .as_os_str() .to_str() .unwrap(); - let parsed = Figment::new() - .merge(Yaml::file(path)) + let mut parsed: ChallengeConfig = Figment::new() + .merge(Yaml::file(path.clone())) + // merge in generated data from file path + .merge(Serialized::default("directory", chal_dir)) .merge(Serialized::default("category", category)) .extract()?; + + // coerce pod env lists to maps + // TODO: do this in serde deserialize? + for pod in parsed.pods.iter_mut() { + pod.env = match pod.env.clone() { + ListOrMap::Map(m) => ListOrMap::Map(m), + ListOrMap::List(l) => { + // split NAME=VALUE list into separate name and value + let split: Vec<(String, String)> = l + .into_iter() + .map(|var| { + // error if envvar is malformed + let split = var.splitn(2, '=').collect_vec(); + if split.len() == 2 { + Ok((split[0].to_string(), split[1].to_string())) + } else { + Err(anyhow!("Cannot split envvar {var:?}")) + } + }) + .collect::>()?; + // build hashmap from split name and value iteratively. this + // can't use HashMap::from() here since the values are dynamic + // and from() only works for Vec constants + let map = split + .into_iter() + .fold(Map::new(), |mut map, (name, value)| { + map.insert(name, value); + map + }); + ListOrMap::Map(map) + } + } + } + + trace!("got challenge config: {parsed:#?}"); + Ok(parsed) } @@ -57,13 +119,10 @@ pub struct ChallengeConfig { name: String, author: String, description: String, + category: String, - #[serde(default)] directory: PathBuf, - #[serde(default)] - category: String, - #[serde(default = "default_difficulty")] difficulty: i64, @@ -123,7 +182,9 @@ struct Pod { #[serde(flatten)] image_source: ImageSource, - env: Option, + #[serde(default)] + env: ListOrMap, + resources: Option, replicas: i64, ports: Vec, @@ -163,37 +224,30 @@ fn default_dockerfile() -> String { "Dockerfile".to_string() } -#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[serde(untagged)] #[fully_pub] enum ListOrMap { List(Vec), Map(Map), } +impl Default for ListOrMap { + fn default() -> Self { + ListOrMap::Map(Map::new()) + } +} #[derive(Debug, PartialEq, Serialize, Deserialize)] #[fully_pub] struct PortConfig { internal: i64, - expose: PortType, -} - -#[derive(Debug, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -#[fully_pub] -enum PortType { - Tcp(TcpPort), - Http(HttpEndpoint), -} - -#[derive(Debug, PartialEq, Serialize, Deserialize)] -#[fully_pub] -struct TcpPort { - tcp: i64, + expose: ExposeType, } #[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] #[fully_pub] -struct HttpEndpoint { - http: String, +enum ExposeType { + Tcp(i64), + Http(String), } diff --git a/src/configparser/config.rs b/src/configparser/config.rs index 19d9270..314596b 100644 --- a/src/configparser/config.rs +++ b/src/configparser/config.rs @@ -97,7 +97,7 @@ struct ProfileDeploy { struct ProfileConfig { // deployed_challenges: HashMap, frontend_url: String, - frontend_token: Option, + frontend_token: String, challenges_domain: String, kubeconfig: Option, kubecontext: String, diff --git a/src/configparser/mod.rs b/src/configparser/mod.rs index c192358..71fd701 100644 --- a/src/configparser/mod.rs +++ b/src/configparser/mod.rs @@ -52,25 +52,7 @@ pub fn get_challenges() -> Result> { return Ok(existing); } - let (challenges, parse_errors): (Vec<_>, Vec<_>) = - challenge::parse_all().into_iter().partition_result(); + let chals = challenge::parse_all(); - trace!( - "parsed chals: {:?}", - challenges - .iter() - .map(|c| format!("{}/{}", c.category, c.name)) - .collect::>() - ); - debug!( - "parsed {} chals, {} others failed parsing", - challenges.len(), - parse_errors.len() - ); - - if parse_errors.is_empty() { - return Ok(CHALLENGES.get_or_init(|| challenges)); - } else { - Err(parse_errors) - } + chals.map(|c| CHALLENGES.get_or_init(|| c)) } diff --git a/src/tests/mod.rs b/src/tests/mod.rs index a8eca30..530a7ad 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -1 +1,4 @@ -mod parsing; +mod parsing { + mod challenges; + mod config; +} diff --git a/src/tests/parsing.rs b/src/tests/parsing.rs deleted file mode 100644 index c6e4401..0000000 --- a/src/tests/parsing.rs +++ /dev/null @@ -1,35 +0,0 @@ -use crate::configparser::challenge::*; -// use crate::configparser::config::*; - -#[test] -fn valid_challenge_yaml() { - let parsed = serde_yml::from_str::( - r#" - name: test_chal - author: "me! :)" - description: > - A description that spans multiple lines. - This is for testing purposes. - difficulty: 0 - flag: dam{is-this-your-flag?} - provide: - - test_file1 - - test_file2 - pods: [] - "#, - ); - - assert!(parsed.is_ok()); -} - -#[test] -fn invalid_challenge_yaml() { - let parsed = serde_yml::from_str::( - r#" - name: there's nothing here - difficulty: yes - "#, - ); - - assert!(parsed.is_err()); -} diff --git a/src/tests/parsing/challenges.rs b/src/tests/parsing/challenges.rs new file mode 100644 index 0000000..7f8c8f8 --- /dev/null +++ b/src/tests/parsing/challenges.rs @@ -0,0 +1,513 @@ +use figment::Jail; +use std::collections::HashMap; +use std::path::PathBuf; + +#[cfg(test)] +use pretty_assertions::{assert_eq, assert_ne}; + +use crate::configparser::challenge::*; + +const VALID_CHAL: &str = r#" + name: testchal + author: nobody + description: just a test challenge + difficulty: 1 + + flag: + text: test{it-works} + + provide: [] + pods: [] +"#; + +#[test] +/// No challenge files should parse correctly +fn no_challenges() { + figment::Jail::expect_with(|jail| { + let chals = parse_all(); + + assert!(chals.is_ok()); + assert_eq!(chals.unwrap().len(), 0); + + Ok(()) + }) +} + +#[test] +/// Challenge yaml at repo root should not parse +fn challenge_in_root() { + figment::Jail::expect_with(|jail| { + jail.create_file("challenge.yaml", "name: test")?; + + let chals = parse_all(); + + assert!(chals.is_ok()); + assert_eq!(chals.unwrap().len(), 0); + + Ok(()) + }) +} + +#[test] +/// Challenge yaml one folder down should not parse +fn challenge_one_level() { + figment::Jail::expect_with(|jail| { + let dir = jail.create_dir("foo")?; + jail.create_file(dir.join("challenge.yaml"), "name: test")?; + + let chals = parse_all(); + + assert!(chals.is_ok()); + assert_eq!(chals.unwrap().len(), 0); + + Ok(()) + }) +} + +#[test] +/// Challenge yaml two folders down should be parsed +fn challenge_two_levels() { + figment::Jail::expect_with(|jail| { + let dir = jail.create_dir("foo/test")?; + jail.create_file(dir.join("challenge.yaml"), VALID_CHAL)?; + + let chals = parse_all(); + + assert!(chals.is_ok()); + let chals = chals.unwrap(); + assert_eq!(chals.len(), 1); + + assert_eq!( + chals[0], + ChallengeConfig { + name: "testchal".to_string(), + author: "nobody".to_string(), + description: "just a test challenge".to_string(), + difficulty: 1, + + category: "foo".to_string(), + directory: PathBuf::from("foo/test"), + + flag: FlagType::Text(FileText { + text: "test{it-works}".to_string() + }), + + provide: vec![], + pods: vec![], + } + ); + + Ok(()) + }) +} + +#[test] +/// Challenge yaml three folders down should not parsed +fn challenge_three_levels() { + figment::Jail::expect_with(|jail| { + let dir = jail.create_dir("chals/foo/test")?; + jail.create_file(dir.join("challenge.yaml"), VALID_CHAL)?; + + let chals = parse_all(); + + assert!(chals.is_ok()); + assert_eq!(chals.unwrap().len(), 0); + + Ok(()) + }) +} + +#[test] +fn challenge_missing_fields() { + figment::Jail::expect_with(|jail| { + let dir = jail.create_dir("test/noflag")?; + jail.create_file( + dir.join("challenge.yaml"), + r#" + name: testchal + author: nobody + description: just a test challenge + difficulty: 1 + "#, + )?; + + let dir = jail.create_dir("test/noauthor")?; + jail.create_file( + dir.join("challenge.yaml"), + r#" + name: testchal + description: just a test challenge + difficulty: 1 + + flag: + text: test{asdf} + "#, + )?; + + let dir = jail.create_dir("test/nodescrip")?; + jail.create_file( + dir.join("challenge.yaml"), + r#" + name: testchal + author: nobody + difficulty: 1 + + flag: + text: test{asdf} + "#, + )?; + + let chals = parse_all(); + assert!(chals.is_err()); + let errs = chals.unwrap_err(); + + assert_eq!(errs.len(), 3); + + Ok(()) + }) +} + +#[test] +/// Challenges can omit both provides and pods fields if needed +fn challenge_no_provides_or_pods() { + figment::Jail::expect_with(|jail| { + let dir = jail.create_dir("foo/test")?; + jail.create_file( + dir.join("challenge.yaml"), + r#" + name: testchal + author: nobody + description: just a test challenge + difficulty: 1 + + flag: + text: test{it-works} + "#, + )?; + + let chals = parse_all().unwrap(); + + assert_eq!(chals[0].provide, vec![] as Vec); + assert_eq!(chals[0].pods, vec![] as Vec); + + Ok(()) + }) +} + +#[test] +/// Challenge provide files parse correctly +fn challenge_provide() { + figment::Jail::expect_with(|jail| { + let dir = jail.create_dir("foo/test")?; + jail.create_file( + dir.join("challenge.yaml"), + r#" + name: testchal + author: nobody + description: just a test challenge + difficulty: 1 + + flag: + text: test{it-works} + + provide: + - foo.txt + - bar.jpg + "#, + )?; + + let chals = parse_all().unwrap(); + + assert_eq!( + chals[0].provide, + vec!["foo.txt".to_string(), "bar.jpg".to_string()], + ); + + Ok(()) + }) +} + +#[test] +/// Challenges should be able to have multiple pods +fn challenge_pods() { + figment::Jail::expect_with(|jail| { + let dir = jail.create_dir("foo/test")?; + jail.create_file( + dir.join("challenge.yaml"), + r#" + name: testchal + author: nobody + description: just a test challenge + difficulty: 1 + + flag: + text: test{it-works} + + pods: + - name: foo + image: nginx + replicas: 2 + ports: + - internal: 80 + expose: + http: test.chals.example.com + + - name: bar + build: . + replicas: 1 + ports: + - internal: 8000 + expose: + tcp: 12345 + "#, + )?; + + let chals = parse_all().unwrap(); + + assert_eq!( + chals[0].pods, + vec![ + Pod { + name: "foo".to_string(), + image_source: ImageSource::Image("nginx".to_string()), + replicas: 2, + env: ListOrMap::Map(HashMap::new()), + resources: None, + ports: vec![PortConfig { + internal: 80, + expose: ExposeType::Http("test.chals.example.com".to_string()) + }], + volume: None + }, + Pod { + name: "bar".to_string(), + image_source: ImageSource::Build(BuildObject { + context: ".".to_string(), + dockerfile: "Dockerfile".to_string(), + args: HashMap::new() + }), + replicas: 1, + env: ListOrMap::Map(HashMap::new()), + resources: None, + ports: vec![PortConfig { + internal: 8000, + expose: ExposeType::Tcp(12345) + }], + volume: None + }, + ] + ); + + Ok(()) + }) +} + +#[test] +/// Challenge pods can use simple or complex build options +fn challenge_pod_build() { + figment::Jail::expect_with(|jail| { + let dir = jail.create_dir("foo/test")?; + jail.create_file( + dir.join("challenge.yaml"), + r#" + name: testchal + author: nobody + description: just a test challenge + difficulty: 1 + + flag: + text: test{it-works} + + pods: + - name: foo + build: . + replicas: 1 + ports: + - internal: 80 + expose: + http: test.chals.example.com + + - name: bar + build: + context: image/ + dockerfile: Containerfile + args: + FOO: this + BAR: that + replicas: 1 + ports: + - internal: 80 + expose: + http: test2.chals.example.com + "#, + )?; + + let chals = parse_all().unwrap(); + + assert_eq!( + chals[0].pods, + vec![ + Pod { + name: "foo".to_string(), + + image_source: ImageSource::Build(BuildObject { + context: ".".to_string(), + dockerfile: "Dockerfile".to_string(), + args: HashMap::new() + }), + replicas: 1, + env: ListOrMap::Map(HashMap::new()), + resources: None, + ports: vec![PortConfig { + internal: 80, + expose: ExposeType::Http("test.chals.example.com".to_string()) + }], + volume: None + }, + Pod { + name: "bar".to_string(), + image_source: ImageSource::Build(BuildObject { + context: "image/".to_string(), + dockerfile: "Containerfile".to_string(), + args: HashMap::from([ + ("FOO".to_string(), "this".to_string()), + ("BAR".to_string(), "that".to_string()), + ]) + }), + replicas: 1, + env: ListOrMap::Map(HashMap::new()), + resources: None, + ports: vec![PortConfig { + internal: 80, + expose: ExposeType::Http("test2.chals.example.com".to_string()) + }], + volume: None + } + ] + ); + + Ok(()) + }) +} + +#[test] +/// Challenge pod envvars can be set as either string list or map +fn challenge_pod_env() { + figment::Jail::expect_with(|jail| { + let dir = jail.create_dir("foo/test")?; + jail.create_file( + dir.join("challenge.yaml"), + r#" + name: testchal + author: nobody + description: just a test challenge + difficulty: 1 + + flag: + text: test{it-works} + + pods: + - name: foo + image: nginx + env: + FOO: this + BAR: that + replicas: 1 + ports: + - internal: 80 + expose: + http: test.chals.example.com + + - name: bar + image: nginx + env: + - FOO=this + - BAR=that + replicas: 1 + ports: + - internal: 80 + expose: + http: test2.chals.example.com + "#, + )?; + + let chals = parse_all().unwrap(); + + assert_eq!( + chals[0].pods, + vec![ + Pod { + name: "foo".to_string(), + + image_source: ImageSource::Image("nginx".to_string()), + replicas: 1, + env: ListOrMap::Map(HashMap::from([ + ("FOO".to_string(), "this".to_string()), + ("BAR".to_string(), "that".to_string()), + ])), + resources: None, + ports: vec![PortConfig { + internal: 80, + expose: ExposeType::Http("test.chals.example.com".to_string()) + }], + volume: None + }, + Pod { + name: "bar".to_string(), + image_source: ImageSource::Image("nginx".to_string()), + replicas: 1, + env: ListOrMap::Map(HashMap::from([ + ("FOO".to_string(), "this".to_string()), + ("BAR".to_string(), "that".to_string()), + ])), + resources: None, + ports: vec![PortConfig { + internal: 80, + expose: ExposeType::Http("test2.chals.example.com".to_string()) + }], + volume: None + } + ] + ); + + Ok(()) + }) +} + +#[test] +/// Challenge pod envvar strings error if malformed +fn challenge_pod_bad_env() { + figment::Jail::expect_with(|jail| { + let dir = jail.create_dir("foo/test")?; + jail.create_file( + dir.join("challenge.yaml"), + r#" + name: testchal + author: nobody + description: just a test challenge + difficulty: 1 + + flag: + text: test{it-works} + + pods: + - name: foo + image: nginx + env: + - FOO + replicas: 1 + ports: + - internal: 80 + expose: + http: test.chals.example.com + "#, + )?; + + let chals = parse_all(); + assert!(chals.is_err()); + let errs = chals.unwrap_err(); + assert_eq!(errs.len(), 1); + + Ok(()) + }) +} diff --git a/src/tests/parsing/config.rs b/src/tests/parsing/config.rs new file mode 100644 index 0000000..6260702 --- /dev/null +++ b/src/tests/parsing/config.rs @@ -0,0 +1,343 @@ +use figment::Jail; +use std::collections::HashMap; +use std::fmt::Display; + +#[cfg(test)] +use pretty_assertions::{assert_eq, assert_ne}; + +use crate::configparser::config::*; + +#[test] +/// Test parsing RCDS config where all fields are specified in the yaml +fn all_yaml() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.create_file( + "rcds.yaml", + r#" + flag_regex: test{[a-zA-Z_]+} + + registry: + domain: registry.example/test + build: + user: admin + pass: notrealcreds + cluster: + user: cluster + pass: alsofake + + defaults: + difficulty: 1 + resources: { cpu: 1, memory: 500M } + + points: + - difficulty: 1 + min: 0 + max: 1337 + + deploy: + testing: + misc/foo: true + web/bar: false + + profiles: + testing: + frontend_url: https://frontend.example + frontend_token: secretsecretsecret + challenges_domain: chals.frontend.example + kubecontext: testcluster + s3: + bucket_name: asset_testing + endpoint: s3.example + region: us-fake-1 + access_key: accesskey + secret_key: secretkey + "#, + )?; + + let config = match parse() { + Ok(c) => Ok(c), + // figment::Error cannot coerce from anyhow::Error natively + Err(e) => Err(figment::Error::from(format!("{:?}", e))), + }?; + + let expected = RcdsConfig { + flag_regex: "test{[a-zA-Z_]+}".to_string(), + registry: Registry { + domain: "registry.example/test".to_string(), + build: UserPass { + user: "admin".to_string(), + pass: "notrealcreds".to_string(), + }, + cluster: UserPass { + user: "cluster".to_string(), + pass: "alsofake".to_string(), + }, + }, + defaults: Defaults { + difficulty: 1, + resources: Resource { + cpu: 1, + memory: "500M".to_string(), + }, + }, + points: vec![ChallengePoints { + difficulty: 1, + min: 0, + max: 1337, + }], + + deploy: HashMap::from([( + "testing".to_string(), + ProfileDeploy { + challenges: HashMap::from([ + ("misc/foo".to_string(), true), + ("web/bar".to_string(), false), + ]), + }, + )]), + profiles: HashMap::from([( + "testing".to_string(), + ProfileConfig { + frontend_url: "https://frontend.example".to_string(), + frontend_token: "secretsecretsecret".to_string(), + challenges_domain: "chals.frontend.example".to_string(), + kubeconfig: None, + kubecontext: "testcluster".to_string(), + // s3: S3Config { + // bucket_name: "asset_testing".to_string(), + // endpoint: "s3.example".to_string(), + // region: "us-fake-1".to_string(), + // access_key: "accesskey".to_string(), + // secret_key: "secretkey".to_string(), + // } + }, + )]), + }; + + assert_eq!(config, expected); + + Ok(()) + }); +} + +#[test] +/// Test parsing RCDS config where some secrets are overridden by envvars +fn yaml_with_env_overrides() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.create_file( + "rcds.yaml", + r#" + flag_regex: test{[a-zA-Z_]+} + + registry: + domain: registry.example/test + build: + user: admin + pass: notrealcreds + cluster: + user: cluster + pass: alsofake + + defaults: + difficulty: 1 + resources: { cpu: 1, memory: 500M } + + points: + - difficulty: 1 + min: 0 + max: 1337 + + deploy: + testing: + misc/foo: true + web/bar: false + + profiles: + testing: + frontend_url: https://frontend.example + frontend_token: secretsecretsecret + challenges_domain: chals.frontend.example + kubecontext: testcluster + s3: + bucket_name: asset_testing + endpoint: s3.example + region: us-fake-1 + access_key: accesskey + secret_key: secretkey + "#, + )?; + + jail.set_env("BEAVERCDS_REGISTRY_BUILD_USER", "envbuilduser"); + jail.set_env("BEAVERCDS_REGISTRY_BUILD_PASS", "envbuildpass"); + + jail.set_env("BEAVERCDS_REGISTRY_CLUSTER_USER", "envclusteruser"); + jail.set_env("BEAVERCDS_REGISTRY_CLUSTER_PASS", "envclusterpass"); + + jail.set_env("BEAVERCDS_PROFILES_TESTING_FRONTEND_TOKEN", "envtoken"); + jail.set_env("BEAVERCDS_PROFILES_TESTING_S3_ACCESS_KEY", "envkey"); + jail.set_env("BEAVERCDS_PROFILES_TESTING_S3_SECRET_KEY", "envsecret"); + + let config = match parse() { + Err(e) => Err(figment::Error::from(format!("{:?}", e))), + Ok(config) => Ok(config), + }?; + + // also check that the envvar overrides were applied + assert_eq!(config.registry.build.user, "envbuilduser"); + assert_eq!(config.registry.build.pass, "envbuildpass"); + assert_eq!(config.registry.cluster.user, "envclusteruser"); + assert_eq!(config.registry.cluster.pass, "envclusterpass"); + + let profile = config.profiles.get("testing").unwrap(); + + assert_eq!(profile.frontend_token, "envtoken"); + // assert_eq!(profile.s3.access_key, "envkey"); + // assert_eq!(profile.s3.secret_key, "envsecret"); + + Ok(()) + }); +} + +#[test] +/// Test parsing RCDS config where secrets are set in envvars and omitted from yaml +fn partial_yaml_with_env() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.create_file( + "rcds.yaml", + r#" + flag_regex: test{[a-zA-Z_]+} + + registry: + domain: registry.example/test + + defaults: + difficulty: 1 + resources: { cpu: 1, memory: 500M } + + points: + - difficulty: 1 + min: 0 + max: 1337 + + deploy: + testing: + misc/foo: true + web/bar: false + + profiles: + testing: + frontend_url: https://frontend.example + challenges_domain: chals.frontend.example + kubecontext: testcluster + s3: + bucket_name: asset_testing + endpoint: s3.example + region: us-fake-1 + "#, + )?; + + jail.set_env("BEAVERCDS_REGISTRY_BUILD_USER", "envbuilduser"); + jail.set_env("BEAVERCDS_REGISTRY_BUILD_PASS", "envbuildpass"); + + jail.set_env("BEAVERCDS_REGISTRY_CLUSTER_USER", "envclusteruser"); + jail.set_env("BEAVERCDS_REGISTRY_CLUSTER_PASS", "envclusterpass"); + + jail.set_env("BEAVERCDS_PROFILES_TESTING_FRONTEND_TOKEN", "envtoken"); + jail.set_env("BEAVERCDS_PROFILES_TESTING_S3_ACCESS_KEY", "envkey"); + jail.set_env("BEAVERCDS_PROFILES_TESTING_S3_SECRET_KEY", "envsecret"); + + let config = match parse() { + Err(e) => Err(figment::Error::from(format!("{:?}", e))), + Ok(config) => Ok(config), + }?; + + // also check that the envvar overrides were applied + assert_eq!(config.registry.build.user, "envbuilduser"); + assert_eq!(config.registry.build.pass, "envbuildpass"); + assert_eq!(config.registry.cluster.user, "envclusteruser"); + assert_eq!(config.registry.cluster.pass, "envclusterpass"); + + let profile = config.profiles.get("testing").unwrap(); + + assert_eq!(profile.frontend_token, "envtoken"); + // assert_eq!(profile.s3.access_key, "envkey"); + // assert_eq!(profile.s3.secret_key, "envsecret"); + + Ok(()) + }); +} + +#[test] +/// Test attempting to parse missing config file +fn bad_no_file() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + // don't create file + + let config = parse(); + assert!(config.is_err()); + + Ok(()) + }); +} + +#[test] +/// Test empty config file +fn bad_empty_file() { + figment::Jail::expect_with(|jail| { + jail.create_file("rcds.yaml", "")?; + + let config = parse(); + assert!(config.is_err()); + + Ok(()) + }); +} + +#[test] +/// Test parsing yaml that is missing some fields +fn bad_yaml_missing_secrets() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "rcds.yaml", + r#" + flag_regex: test{[a-zA-Z_]+} + + registry: + domain: registry.example/test + + defaults: + difficulty: 1 + resources: { cpu: 1, memory: 500M } + + points: + - difficulty: 1 + min: 0 + max: 1337 + + deploy: + testing: + misc/foo: true + web/bar: false + + profiles: + testing: + frontend_url: https://frontend.example + challenges_domain: chals.frontend.example + kubecontext: testcluster + s3: + bucket_name: asset_testing + endpoint: s3.example + region: us-fake-1 + "#, + )?; + + let config = parse(); + assert!(config.is_err()); + + Ok(()) + }); +} diff --git a/tests/bad_challenge_config/misc/foo/challenge.yaml b/tests/bad_challenge_config/misc/foo/challenge.yaml deleted file mode 100644 index afcd2fd..0000000 --- a/tests/bad_challenge_config/misc/foo/challenge.yaml +++ /dev/null @@ -1 +0,0 @@ -name: foo-challenge diff --git a/tests/bad_challenge_config/rcds.yaml b/tests/bad_challenge_config/rcds.yaml deleted file mode 100644 index a42d556..0000000 --- a/tests/bad_challenge_config/rcds.yaml +++ /dev/null @@ -1,37 +0,0 @@ -flag_regex: dam{[a-zA-Z...]} - -registry: - domain: registry.example.com/damctf - # then environment variables e.g. REG_USER/REG_PASS - user: admin - pass: admin - -defaults: - difficulty: 1 - resources: { cpu: 1, memory: 500M } - -points: - - difficulty: 1 - min: 0 - max: 1337 - -deploy: - # control challenge deployment status explicitly per environment/profile - staging: - misc/foo: true - rev/bar: false - -profiles: - # configure per-environment credentials etc - staging: - frontend_url: https://frontend.example - # or environment var (recommended): FRONTEND_TOKEN_$PROFILE=secretsecretsecret - frontend_token: secret - challenges_domain: chals.frontend.example - kubeconfig: path/to/kubeconfig - kubecontext: damctf-cluster - s3: - endpoint: x - region: x - accessKey: key - secretAccessKey: secret diff --git a/tests/bad_challenge_config/web/bar/challenge.yaml b/tests/bad_challenge_config/web/bar/challenge.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/tests/no_challenges/rcds.yaml b/tests/no_challenges/rcds.yaml deleted file mode 100644 index 12a4e05..0000000 --- a/tests/no_challenges/rcds.yaml +++ /dev/null @@ -1,38 +0,0 @@ -flag_regex: dam{[a-zA-Z...]} - -registry: - # domain: registry.example.com/damctf - domain: localhost:5000/damctf - # then environment variables e.g. REG_USER/REG_PASS - build: &creds - user: admin - pass: admin - cluster: *creds - -defaults: - difficulty: 1 - resources: { cpu: 1, memory: 500M } - -points: - - difficulty: 1 - min: 0 - max: 1337 - -deploy: - # control challenge deployment status explicitly per environment/profile - test: {} - -profiles: - # configure per-environment credentials etc - test: - frontend_url: https://frontend.example - # or environment var (recommended): FRONTEND_TOKEN_$PROFILE=secretsecretsecret - frontend_token: secret - challenges_domain: chals.frontend.example - # kubeconfig: path/to/kubeconfig - kubecontext: beavercds-testing - s3: - endpoint: x - region: x - accessKey: key - secretAccessKey: secret