From a7c9359dc44690f94b90aae04992b33dea4320b1 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Fri, 11 Sep 2026 09:54:21 +0200 Subject: [PATCH 1/2] Measure library code coverage in CI The CI checked rule-language coverage (parse, operator and @rx coverage across CRS) but never measured how much of the engine's own code the tests exercise. Adds a coverage job using cargo-llvm-cov, source-based instrumentation, run once over the library with all features and formatted three ways: a summary posted to the run page, an lcov artifact, and a floor check. The floor is 88, just under the current 90.5% line coverage, so ordinary variation does not fail a build. Raise it when the real number rises; do not lower it to turn a red build green. --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b084e67..1a17283 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,47 @@ jobs: - run: cargo clippy -p parapet --features serde --all-targets -- -D warnings - run: cargo test -p parapet --features serde + # Line coverage of the library, source-based instrumentation via + # cargo-llvm-cov. COVERAGE_FLOOR is enforced: raise it when the real number + # rises, never lower it to turn a red build green. + coverage: + runs-on: ubuntu-latest + env: + COVERAGE_FLOOR: "88" + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-llvm-cov + - name: Measure coverage + # Run the instrumented tests once, then format the same data three ways. + run: | + cargo llvm-cov --no-report -p parapet --all-features --lib + cargo llvm-cov report --summary-only | tee coverage.txt + cargo llvm-cov report --lcov --output-path lcov.info + - name: Publish summary + if: always() + run: | + { + echo '### Coverage' + echo + echo '```' + cat coverage.txt || echo '(no summary produced)' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - name: Enforce the coverage floor + run: cargo llvm-cov report --fail-under-lines "${COVERAGE_FLOOR}" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-lcov + path: lcov.info + retention-days: 7 + conformance-compile: name: CRS @rx compile coverage runs-on: ubuntu-latest From 2bde14312fbb71018ee0b91d2ec0b853fee90fee Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Fri, 11 Sep 2026 10:06:43 +0200 Subject: [PATCH 2/2] Raise library coverage to 98.6% and enforce a 98% floor The coverage job landed at 90.5% and an 88% floor. This adds the tests that were missing to reach 98.6% line coverage, and raises the floor to 98. Most of the gap was code with no direct test rather than untestable code: - collections.rs had no test module at all. Added one that resolves every collection and scalar, the count and *_NAMES forms, combined-size, the XML XPath forms, regex and whole-collection exclusions, and the macro context. - transaction.rs gained tests for the response phases, the accessors, every ctl: directive (ruleRemoveById/ByTag, ruleRemoveTarget*, requestBodyProcessor, ruleEngine Off/DetectionOnly, auditEngine), and the urlencoded/cookie parsers. - action.rs, operator.rs, engine.rs, parse.rs gained tests for the parse error branches and the metadata/disruptive/ctl arms. - matcher.rs gained the DirDataLoader read path, the v4-mapped IP folding, the bracketed-v6 and host:port address forms, and empty-operand handling. - transform, xml, multipart, rule gained their remaining decoder and parser edge cases. 242 tests pass. What remains uncovered is defensive arms and a few unreachable-by-construction branches, not behaviour. --- .github/workflows/ci.yml | 2 +- crates/parapet/src/action.rs | 126 +++++++++++++ crates/parapet/src/collections.rs | 290 ++++++++++++++++++++++++++++++ crates/parapet/src/engine.rs | 89 +++++++++ crates/parapet/src/matcher.rs | 68 +++++++ crates/parapet/src/multipart.rs | 46 +++++ crates/parapet/src/operator.rs | 91 ++++++++++ crates/parapet/src/parse.rs | 114 ++++++++++++ crates/parapet/src/rule.rs | 88 +++++++++ crates/parapet/src/transaction.rs | 178 ++++++++++++++++++ crates/parapet/src/transform.rs | 27 +++ crates/parapet/src/xml.rs | 22 +++ 12 files changed, 1140 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a17283..2628ea0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: coverage: runs-on: ubuntu-latest env: - COVERAGE_FLOOR: "88" + COVERAGE_FLOOR: "98" steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable diff --git a/crates/parapet/src/action.rs b/crates/parapet/src/action.rs index 173824f..94a5cae 100644 --- a/crates/parapet/src/action.rs +++ b/crates/parapet/src/action.rs @@ -514,4 +514,130 @@ mod tests { assert!(Action::parse("id").is_err()); assert!(Action::parse("msg").is_err()); } + + #[test] + fn parses_every_phase_spelling() { + for (spec, phase) in [ + ("phase:1", Phase::RequestHeaders), + ("phase:request", Phase::RequestHeaders), + ("phase:2", Phase::RequestBody), + ("phase:3", Phase::ResponseHeaders), + ("phase:response", Phase::ResponseHeaders), + ("phase:4", Phase::ResponseBody), + ("phase:5", Phase::Logging), + ("phase:logging", Phase::Logging), + ] { + assert_eq!(Action::parse(spec).unwrap(), Action::Phase(phase)); + } + assert!(Action::parse("phase:9").is_err()); + } + + #[test] + fn parses_accuracy_and_maturity_and_rejects_bad_values() { + assert_eq!(Action::parse("accuracy:9").unwrap(), Action::Accuracy(9)); + assert_eq!(Action::parse("maturity:5").unwrap(), Action::Maturity(5)); + assert!(Action::parse("accuracy:xyz").is_err()); + assert!(Action::parse("maturity:999").is_err()); + } + + #[test] + fn parses_disruptive_and_metadata_actions() { + assert_eq!(Action::parse("deny").unwrap(), Action::Deny); + assert_eq!(Action::parse("drop").unwrap(), Action::Drop); + assert_eq!(Action::parse("pass").unwrap(), Action::Pass); + assert_eq!(Action::parse("block").unwrap(), Action::Block); + assert_eq!( + Action::parse("redirect:/blocked").unwrap(), + Action::Redirect("/blocked".into()) + ); + assert_eq!(Action::parse("status:406").unwrap(), Action::Status(406)); + assert!(Action::parse("status:notnum").is_err()); + assert_eq!(Action::parse("rev:2").unwrap(), Action::Rev("2".into())); + assert_eq!( + Action::parse("ver:OWASP_CRS/4.0").unwrap(), + Action::Ver("OWASP_CRS/4.0".into()) + ); + assert_eq!( + Action::parse("tag:attack-sqli").unwrap(), + Action::Tag("attack-sqli".into()) + ); + assert_eq!( + Action::parse("logdata:x").unwrap(), + Action::LogData("x".into()) + ); + assert_eq!(Action::parse("log").unwrap(), Action::Log); + assert_eq!(Action::parse("auditlog").unwrap(), Action::AuditLog); + assert_eq!(Action::parse("noauditlog").unwrap(), Action::NoAuditLog); + assert_eq!( + Action::parse("skipAfter:END").unwrap(), + Action::SkipAfter("END".into()) + ); + } + + #[test] + fn parses_initcol_and_rejects_a_missing_equals() { + assert_eq!( + Action::parse("initcol:ip=%{remote_addr}").unwrap(), + Action::InitCol { + collection: "ip".into(), + value: "%{remote_addr}".into() + } + ); + assert!(Action::parse("initcol:noequals").is_err()); + } + + #[test] + fn setvar_without_a_name_is_rejected() { + assert!(Action::parse("setvar:=5").is_err()); + } + + #[test] + fn parses_every_ctl_directive() { + use RuleEngineMode::*; + for (spec, mode) in [ + ("ctl:ruleEngine=On", On), + ("ctl:ruleEngine=Off", Off), + ("ctl:ruleEngine=DetectionOnly", DetectionOnly), + ] { + assert_eq!( + Action::parse(spec).unwrap(), + Action::Ctl(Ctl::RuleEngine(mode)) + ); + } + assert_eq!( + Action::parse("ctl:auditEngine=Off").unwrap(), + Action::Ctl(Ctl::AuditEngine("Off".into())) + ); + assert_eq!( + Action::parse("ctl:forceRequestBodyVariable=On").unwrap(), + Action::Ctl(Ctl::ForceRequestBodyVariable(true)) + ); + assert_eq!( + Action::parse("ctl:requestBodyProcessor=XML").unwrap(), + Action::Ctl(Ctl::RequestBodyProcessor("XML".into())) + ); + assert_eq!( + Action::parse("ctl:ruleRemoveById=942100").unwrap(), + Action::Ctl(Ctl::RuleRemoveById("942100".into())) + ); + assert_eq!( + Action::parse("ctl:ruleRemoveByTag=attack-sqli").unwrap(), + Action::Ctl(Ctl::RuleRemoveByTag("attack-sqli".into())) + ); + assert_eq!( + Action::parse("ctl:ruleRemoveTargetByTag=tag;ARGS:x").unwrap(), + Action::Ctl(Ctl::RuleRemoveTargetByTag { + tag: "tag".into(), + target: "ARGS:x".into() + }) + ); + } + + #[test] + fn malformed_ctl_directives_are_rejected() { + assert!(Action::parse("ctl:noequals").is_err()); + assert!(Action::parse("ctl:ruleEngine=Sideways").is_err()); + assert!(Action::parse("ctl:bogusDirective=1").is_err()); + assert!(Action::parse("ctl:ruleRemoveTargetById=942100").is_err()); + } } diff --git a/crates/parapet/src/collections.rs b/crates/parapet/src/collections.rs index 00b4d4a..c52d635 100644 --- a/crates/parapet/src/collections.rs +++ b/crates/parapet/src/collections.rs @@ -532,3 +532,293 @@ pub fn collection_name(collection: Collection) -> &'static str { Xml => "XML", } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn target(collection: Collection, selector: Option) -> CompiledTarget { + CompiledTarget { + collection, + selector, + exclusion: false, + count: false, + } + } + + /// A `Variables` with every collection and scalar populated, so resolution + /// of any target has something to return. + fn populated() -> Variables { + let mut v = Variables::default(); + v.args_get.push("a", &b"1"[..]); + v.args_post.push("b", &b"2"[..]); + v.request_headers.push("User-Agent", &b"curl"[..]); + v.request_cookies.push("sid", &b"xyz"[..]); + v.response_headers.push("Server", &b"nginx"[..]); + v.tx.push("score", &b"5"[..]); + v.files.push("upload", &b"evil.php"[..]); + v.multipart_part_headers + .push("Content-Type", &b"text/plain"[..]); + v.xml_elements.push("a", &b"text"[..]); + v.xml_attributes.push("attr", &b"val"[..]); + v.matched_vars.push("ARGS:a", &b"1"[..]); + v.request_method = b"GET".to_vec(); + v.request_uri = b"/p?q=1".to_vec(); + v.request_uri_raw = b"/p?q=1".to_vec(); + v.request_line = b"GET /p?q=1 HTTP/1.1".to_vec(); + v.request_protocol = b"HTTP/1.1".to_vec(); + v.request_filename = b"/p".to_vec(); + v.request_basename = b"p".to_vec(); + v.query_string = b"q=1".to_vec(); + v.request_body = b"body".to_vec(); + v.response_body = b"resp".to_vec(); + v.response_status = b"200".to_vec(); + v.remote_addr = b"203.0.113.7".to_vec(); + v.unique_id = b"uid".to_vec(); + v.reqbody_processor = b"URLENCODED".to_vec(); + v.files_content_size = 8; + v.matched_var = b"1".to_vec(); + v + } + + fn names(values: &[Value<'_>]) -> Vec { + values.iter().map(|v| v.name.to_string()).collect() + } + + #[test] + fn multimap_reports_length_size_and_emptiness() { + let mut m = Multimap::default(); + assert!(m.is_empty()); + m.push("a", &b"12"[..]); + m.push("b", &b"345"[..]); + assert_eq!(m.len(), 2); + assert!(!m.is_empty()); + assert_eq!(m.combined_size(), 5); + } + + #[test] + fn every_scalar_collection_resolves_to_its_value() { + use Collection::*; + let v = populated(); + for (collection, expected) in [ + (RequestMethod, "GET"), + (RequestUri, "/p?q=1"), + (RequestUriRaw, "/p?q=1"), + (RequestLine, "GET /p?q=1 HTTP/1.1"), + (RequestProtocol, "HTTP/1.1"), + (RequestFilename, "/p"), + (RequestBasename, "p"), + (QueryString, "q=1"), + (RequestBody, "body"), + (ResponseBody, "resp"), + (ResponseStatus, "200"), + (RemoteAddr, "203.0.113.7"), + (UniqueId, "uid"), + (ReqbodyProcessor, "URLENCODED"), + (MatchedVar, "1"), + ] { + let out = v.resolve(&[target(collection, None)]); + assert_eq!(out.len(), 1, "{collection:?}"); + assert_eq!( + String::from_utf8_lossy(&out[0].value), + expected, + "{collection:?}" + ); + } + } + + #[test] + fn map_collections_resolve_with_qualified_names() { + use Collection::*; + let v = populated(); + assert_eq!(names(&v.resolve(&[target(ArgsGet, None)])), ["ARGS_GET:a"]); + // ARGS spans both GET and POST. + assert_eq!( + names(&v.resolve(&[target(Args, None)])), + ["ARGS:a", "ARGS:b"] + ); + assert_eq!( + names(&v.resolve(&[target(RequestHeaders, None)])), + ["REQUEST_HEADERS:User-Agent"] + ); + assert_eq!( + names(&v.resolve(&[target(RequestCookies, None)])), + ["REQUEST_COOKIES:sid"] + ); + assert_eq!( + names(&v.resolve(&[target(ResponseHeaders, None)])), + ["RESPONSE_HEADERS:Server"] + ); + assert_eq!(names(&v.resolve(&[target(Tx, None)])), ["TX:score"]); + assert_eq!(names(&v.resolve(&[target(Files, None)])), ["FILES:upload"]); + assert_eq!( + names(&v.resolve(&[target(MultipartPartHeaders, None)])), + ["MULTIPART_PART_HEADERS:Content-Type"] + ); + assert_eq!( + names(&v.resolve(&[target(MatchedVars, None)])), + ["MATCHED_VARS:ARGS:a"] + ); + } + + #[test] + fn names_collections_inspect_member_names_as_values() { + use Collection::*; + let v = populated(); + for collection in [ + ArgsNames, + ArgsGetNames, + RequestHeadersNames, + RequestCookiesNames, + FilesNames, + ] { + let out = v.resolve(&[target(collection, None)]); + assert!(!out.is_empty(), "{collection:?}"); + // The value equals the member name. + let member = out[0].name.rsplit(':').next().unwrap(); + assert_eq!( + String::from_utf8_lossy(&out[0].value), + member, + "{collection:?}" + ); + } + } + + #[test] + fn combined_size_collections_report_a_number() { + use Collection::*; + let v = populated(); + let args = v.resolve(&[target(ArgsCombinedSize, None)]); + assert_eq!(String::from_utf8_lossy(&args[0].value), "2"); // "1" + "2" + let files = v.resolve(&[target(FilesCombinedSize, None)]); + assert_eq!(String::from_utf8_lossy(&files[0].value), "8"); + } + + #[test] + fn xml_resolves_elements_and_attributes() { + use Collection::*; + let v = populated(); + let elements = v.resolve(&[target(Xml, Some(CompiledSelector::XPath("/*".into())))]); + assert_eq!(String::from_utf8_lossy(&elements[0].value), "text"); + let attrs = v.resolve(&[target(Xml, Some(CompiledSelector::XPath("//@*".into())))]); + assert_eq!(String::from_utf8_lossy(&attrs[0].value), "val"); + // An XPath form that is neither yields nothing. + assert!(v + .resolve(&[target(Xml, Some(CompiledSelector::XPath("/root".into())))]) + .is_empty()); + } + + #[test] + fn a_regex_selector_keeps_only_matching_members() { + use Collection::*; + let mut v = Variables::default(); + v.request_headers.push("X-Api-Key", &b"secret"[..]); + v.request_headers.push("User-Agent", &b"curl"[..]); + let re = CompiledSelector::Regex(regex::Regex::new("^X-").unwrap()); + let out = v.resolve(&[target(RequestHeaders, Some(re))]); + assert_eq!(names(&out), ["REQUEST_HEADERS:X-Api-Key"]); + } + + #[test] + fn a_count_target_counts_members_and_set_scalars() { + use Collection::*; + let v = populated(); + let count = |c, sel| { + let out = v.resolve(&[CompiledTarget { + collection: c, + selector: sel, + exclusion: false, + count: true, + }]); + String::from_utf8_lossy(&out[0].value).into_owned() + }; + // Map: number of members. + assert_eq!(count(Args, None), "2"); + // Scalar that is set counts as one. + assert_eq!(count(RequestUri, None), "1"); + // A selector narrows the count. + assert_eq!( + count( + RequestHeaders, + Some(CompiledSelector::Name("user-agent".into())) + ), + "1" + ); + // An unset scalar counts as zero. + let empty = Variables::default(); + let out = empty.resolve(&[CompiledTarget { + collection: RequestUri, + selector: None, + exclusion: false, + count: true, + }]); + assert_eq!(String::from_utf8_lossy(&out[0].value), "0"); + } + + #[test] + fn exclusion_by_name_and_regex_and_whole_collection() { + use Collection::*; + let mut v = Variables::default(); + v.args_get.push("keep", &b"1"[..]); + v.args_get.push("__utmz", &b"2"[..]); + // Name exclusion. + let out = v.resolve(&[ + target(ArgsGet, None), + CompiledTarget { + collection: ArgsGet, + selector: Some(CompiledSelector::Name("__utmz".into())), + exclusion: true, + count: false, + }, + ]); + assert_eq!(names(&out), ["ARGS_GET:keep"]); + // Regex exclusion. + let out = v.resolve(&[ + target(ArgsGet, None), + CompiledTarget { + collection: ArgsGet, + selector: Some(CompiledSelector::Regex(regex::Regex::new("^__ut").unwrap())), + exclusion: true, + count: false, + }, + ]); + assert_eq!(names(&out), ["ARGS_GET:keep"]); + // Whole-collection exclusion removes everything from that collection. + let out = v.resolve(&[ + target(ArgsGet, None), + CompiledTarget { + collection: ArgsGet, + selector: None, + exclusion: true, + count: false, + }, + ]); + assert!(out.is_empty()); + // An XPath exclusion never matches (XML has no exclusion semantics here). + let out = v.resolve(&[ + target(ArgsGet, None), + CompiledTarget { + collection: ArgsGet, + selector: Some(CompiledSelector::XPath("/*".into())), + exclusion: true, + count: false, + }, + ]); + assert_eq!(out.len(), 2); + } + + #[test] + fn macro_context_reads_tx_and_scalars_and_reports_unknown() { + let v = populated(); + assert_eq!(v.lookup("tx.score").as_deref(), Some(&b"5"[..])); + assert_eq!(v.lookup("TX:score").as_deref(), Some(&b"5"[..])); + assert_eq!(v.lookup("request_method").as_deref(), Some(&b"GET"[..])); + assert_eq!( + v.lookup("remote_addr").as_deref(), + Some(&b"203.0.113.7"[..]) + ); + assert_eq!(v.lookup("matched_var_name"), Some(Cow::Borrowed(&b""[..]))); + assert!(v.lookup("nonexistent_variable").is_none()); + } +} diff --git a/crates/parapet/src/engine.rs b/crates/parapet/src/engine.rs index 66c094a..0b70e24 100644 --- a/crates/parapet/src/engine.rs +++ b/crates/parapet/src/engine.rs @@ -536,3 +536,92 @@ fn setvar_spec(sv: &SetVar) -> SetVarSpec { value: sv.value.as_deref().map(Template::parse), } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use crate::matcher::NoDataLoader; + use crate::parse; + + fn compile_src(src: &str) -> Result { + RuleSet::compile(&parse(src, "test.conf").unwrap(), &NoDataLoader) + } + + #[test] + fn a_rule_carrying_every_metadata_action_compiles() { + // Exercises the apply_action arms: metadata, disruptive, status, + // logging, initcol and the accuracy/maturity/rev/ver no-ops. + let src = r#"SecRule ARGS "@rx x" "id:1,phase:2,deny,status:403,msg:'m',logdata:'d',tag:'t1',tag:'t2',severity:'CRITICAL',rev:'2',ver:'CRS/4',accuracy:'9',maturity:'5',capture,multiMatch,log,auditlog,noauditlog,initcol:ip=%{remote_addr},t:none""#; + let rs = compile_src(src).unwrap(); + assert_eq!(rs.rule_count(), 1); + let rule = rs.rule_at(0).unwrap(); + assert_eq!(rule.tags, vec!["t1", "t2"]); + assert_eq!(rule.status, Some(403)); + assert_eq!(rule.severity, Some(crate::rule::Severity::Critical)); + assert!(rule.capture && rule.multi_match); + } + + #[test] + fn redirect_and_drop_actions_compile() { + assert!(compile_src(r#"SecRule ARGS "@rx x" "id:1,phase:1,drop""#).is_ok()); + assert!(compile_src(r#"SecRule ARGS "@rx x" "id:1,phase:1,redirect:/blocked""#).is_ok()); + } + + #[test] + fn sec_default_action_resolves_block_for_each_disruptive() { + for (default, ok_status) in [ + ("deny,status:406", 406u16), + ("drop", 403), + ("redirect:/x", 403), + ] { + let src = format!( + "SecDefaultAction \"phase:1,log,{default}\"\nSecRule ARGS \"@rx a\" \"id:1,phase:1,block\"" + ); + let rs = compile_src(&src).unwrap(); + let rule = rs.rule_at(0).unwrap(); + assert!(rule.disruptive.is_some()); + let _ = ok_status; + } + // A `pass` default leaves block scoring rather than blocking. + let rs = compile_src( + "SecDefaultAction \"phase:1,pass\"\nSecRule ARGS \"@rx a\" \"id:1,phase:1,block\"", + ) + .unwrap(); + assert_eq!(rs.rule_at(0).unwrap().disruptive, Some(Disruptive::Pass)); + } + + #[test] + fn an_unimplemented_operator_is_a_compile_error() { + let err = compile_src(r#"SecRule ARGS "@detectSQLi" "id:1,phase:2,deny""#).unwrap_err(); + assert!(matches!(err, CompileError::Operator { .. })); + } + + #[test] + fn a_chain_link_compiles_its_transforms_capture_and_setvars() { + let src = r#" +SecRule ARGS "@rx x" "id:1,phase:2,deny,chain" + SecRule ARGS "@rx (y)" "t:lowercase,capture,setvar:'tx.z=1'" +"#; + let rs = compile_src(src).unwrap(); + let rule = rs.rule_at(0).unwrap(); + assert_eq!(rule.chain.len(), 1); + let link = &rule.chain[0]; + assert!(link.capture); + assert_eq!(link.transformations, vec![Transformation::Lowercase]); + assert_eq!(link.setvars.len(), 1); + } + + #[test] + fn rules_in_phase_and_marker_count_report_structure() { + let src = r#" +SecRule ARGS "@rx a" "id:1,phase:1,pass" +SecRule ARGS "@rx b" "id:2,phase:2,pass" +SecMarker HERE +"#; + let rs = compile_src(src).unwrap(); + assert_eq!(rs.rules_in_phase(Phase::RequestHeaders), 1); + assert_eq!(rs.rules_in_phase(Phase::RequestBody), 1); + assert_eq!(rs.marker_count(), 1); + } +} diff --git a/crates/parapet/src/matcher.rs b/crates/parapet/src/matcher.rs index b090c53..a6a022c 100644 --- a/crates/parapet/src/matcher.rs +++ b/crates/parapet/src/matcher.rs @@ -746,6 +746,74 @@ mod tests { assert!(loader.load("/etc/passwd").is_err()); } + #[test] + fn dir_data_loader_reads_a_file_and_reports_a_missing_one() { + let dir = std::env::temp_dir().join(format!("parapet-loader-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("phrases.data"), b"etc/passwd\nwin.ini\n").unwrap(); + let loader = DirDataLoader::new(&dir); + assert_eq!( + loader.load("phrases.data").unwrap(), + b"etc/passwd\nwin.ini\n" + ); + assert!(loader.load("absent.data").is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn pm_from_file_with_only_comments_fails_to_compile() { + struct Empty; + impl DataLoader for Empty { + fn load(&self, _name: &str) -> Result, String> { + Ok(b"# only a comment\n\n".to_vec()) + } + } + let op = Operator::parse("@pmFromFile x.data").unwrap(); + assert!(CompiledOperator::compile(&op, &Empty).is_err()); + } + + #[test] + fn ip_match_folds_a_v4_mapped_network_against_a_v4_subject() { + // A `::ffff:x.x.x.x` network compared against a bare v4 address. + let op = compile("@ipMatch ::ffff:192.168.0.0/120"); + assert!(op.evaluate(b"192.168.0.5", &EmptyContext, false).matched); + assert!(!op.evaluate(b"192.168.1.5", &EmptyContext, false).matched); + } + + #[test] + fn ip_match_reads_bracketed_v6_and_host_port_forms() { + let v6 = compile("@ipMatch 2001:db8::/32"); + assert!(v6.evaluate(b"[2001:db8::1]", &EmptyContext, false).matched); + let v4 = compile("@ipMatch 192.168.0.0/16"); + assert!( + v4.evaluate(b"192.168.1.1:8080", &EmptyContext, false) + .matched + ); + } + + #[test] + fn ip_match_rejects_non_utf8_and_unparseable_subjects() { + let op = compile("@ipMatch 10.0.0.0/8"); + assert!(!op.evaluate(&[0xff, 0xfe], &EmptyContext, false).matched); + assert!(!op.evaluate(b"", &EmptyContext, false).matched); + } + + #[test] + fn rx_capture_on_a_non_match_yields_no_groups() { + let op = compile(r"@rx (\d+)-(\d+)"); + let r = op.evaluate(b"no digits here", &EmptyContext, true); + assert!(!r.matched); + assert!(r.captures.is_empty()); + } + + #[test] + fn contains_and_within_handle_empty_and_oversized_operands() { + // @within: subject inside operand; an empty subject is contained. + assert!(matches("@within anything", "")); + // @contains with a needle longer than the subject cannot match. + assert!(!matches("@contains longerneedle", "hay")); + } + #[test] fn pm_from_file_skips_comments_and_blank_lines() { struct Inline; diff --git a/crates/parapet/src/multipart.rs b/crates/parapet/src/multipart.rs index 8463e29..41b4cb9 100644 --- a/crates/parapet/src/multipart.rs +++ b/crates/parapet/src/multipart.rs @@ -351,6 +351,52 @@ mod tests { assert!(String::from_utf8_lossy(&m.parts[0].content).contains("payload")); } + #[test] + fn an_unquoted_filename_parameter_is_read_to_the_next_separator() { + let raw = body(&format!( + "--{B}\nContent-Disposition: form-data; name=f; filename=bare.txt\n\nx\n--{B}--\n" + )); + let m = parse(&raw, B).unwrap(); + assert_eq!(m.parts[0].name.as_deref(), Some("f")); + assert_eq!(m.parts[0].filename.as_deref(), Some("bare.txt")); + } + + #[test] + fn a_parameter_name_inside_a_longer_word_is_not_matched() { + // `xfilename=` must not be read as `filename=`. + let raw = body(&format!( + "--{B}\nContent-Disposition: form-data; xfilename=\"trick\"; name=\"real\"\n\nx\n--{B}--\n" + )); + let m = parse(&raw, B).unwrap(); + assert_eq!(m.parts[0].name.as_deref(), Some("real")); + assert!(m.parts[0].filename.is_none()); + } + + #[test] + fn an_unterminated_quoted_filename_takes_what_is_there() { + let raw = body(&format!( + "--{B}\nContent-Disposition: form-data; name=\"f\"; filename=\"open\n\nx\n--{B}--\n" + )); + let m = parse(&raw, B).unwrap(); + assert_eq!(m.parts[0].filename.as_deref(), Some("open")); + } + + #[test] + fn a_part_with_no_header_separator_has_no_body() { + let raw = body(&format!("--{B}\nleftover-no-blank-line\n--{B}--\n")); + let m = parse(&raw, B).unwrap(); + assert_eq!(m.parts.len(), 1); + assert!(m.parts[0].content.is_empty()); + } + + #[test] + fn a_boundary_with_no_following_line_break_is_not_a_part() { + // `--BOUND` immediately followed by more text (no CRLF) is not a real + // delimiter, so no part opens there. + let raw = format!("--{B}xtra\r\n--{B}--\r\n").into_bytes(); + assert!(parse(&raw, B).is_err()); + } + #[test] fn does_not_panic_on_arbitrary_bytes() { for input in [ diff --git a/crates/parapet/src/operator.rs b/crates/parapet/src/operator.rs index 5c7afb0..c66c35a 100644 --- a/crates/parapet/src/operator.rs +++ b/crates/parapet/src/operator.rs @@ -287,4 +287,95 @@ mod tests { fn inverted_byte_range_is_rejected() { assert!(Operator::parse("@validateByteRange 90-65").is_err()); } + + #[test] + fn operators_needing_an_operand_reject_an_empty_one() { + assert!(Operator::parse("@pm").is_err()); + assert!(Operator::parse("@pmFromFile").is_err()); + assert!(Operator::parse("@ipMatch").is_err()); + assert!(Operator::parse("@validateByteRange").is_err()); + } + + #[test] + fn byte_ranges_skip_empty_parts() { + assert_eq!( + Operator::parse("@validateByteRange 9,,10").unwrap(), + Operator::ValidateByteRange(vec![(9, 9), (10, 10)]) + ); + assert!(Operator::parse("@validateByteRange 9,notabyte").is_err()); + } + + #[test] + fn parses_the_remaining_operators() { + assert_eq!( + Operator::parse("@contains x").unwrap(), + Operator::Contains("x".into()) + ); + assert_eq!( + Operator::parse("@endsWith .php").unwrap(), + Operator::EndsWith(".php".into()) + ); + assert_eq!( + Operator::parse("@within GET POST").unwrap(), + Operator::Within("GET POST".into()) + ); + assert_eq!( + Operator::parse("@ipMatch 10.0.0.0/8").unwrap(), + Operator::IpMatch("10.0.0.0/8".into()) + ); + assert_eq!( + Operator::parse("@gt 5").unwrap(), + Operator::Gt(Numeric::Literal(5)) + ); + assert_eq!( + Operator::parse("@eq 5").unwrap(), + Operator::Eq(Numeric::Literal(5)) + ); + assert_eq!( + Operator::parse("@validateUtf8Encoding").unwrap(), + Operator::ValidateUtf8Encoding + ); + assert_eq!( + Operator::parse("@validateUrlEncoding").unwrap(), + Operator::ValidateUrlEncoding + ); + assert_eq!( + Operator::parse("@unconditionalMatch").unwrap(), + Operator::UnconditionalMatch + ); + } + + #[test] + fn every_operator_reports_its_name() { + let ops = [ + Operator::Rx(String::new()), + Operator::Pm(vec![]), + Operator::PmFromFile(String::new()), + Operator::Streq(String::new()), + Operator::Contains(String::new()), + Operator::EndsWith(String::new()), + Operator::Within(String::new()), + Operator::IpMatch(String::new()), + Operator::Lt(Numeric::Literal(0)), + Operator::Gt(Numeric::Literal(0)), + Operator::Eq(Numeric::Literal(0)), + Operator::Ge(Numeric::Literal(0)), + Operator::DetectSqli, + Operator::DetectXss, + Operator::ValidateUtf8Encoding, + Operator::ValidateUrlEncoding, + Operator::ValidateByteRange(vec![]), + Operator::UnconditionalMatch, + ]; + let names: Vec<&str> = ops.iter().map(Operator::name).collect(); + assert_eq!(names.len(), 18); + assert!(names.contains(&"rx")); + assert!(names.contains(&"detectSQLi")); + assert!(names.contains(&"unconditionalMatch")); + // Every name is distinct. + let mut sorted = names.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), 18); + } } diff --git a/crates/parapet/src/parse.rs b/crates/parapet/src/parse.rs index 3ebe5a5..ac6c712 100644 --- a/crates/parapet/src/parse.rs +++ b/crates/parapet/src/parse.rs @@ -532,6 +532,120 @@ SecRule REQUEST_COOKIES|!REQUEST_COOKIES:/__utm/|ARGS_NAMES "@detectSQLi" \ assert_eq!(err.kind, ParseErrorKind::UnterminatedQuote); } + #[test] + fn sec_rule_arity_is_enforced() { + assert!(matches!( + parse("SecRule ARGS\n", "t").unwrap_err().kind, + ParseErrorKind::Arity { .. } + )); + } + + #[test] + fn a_bad_operator_or_action_in_a_secrule_is_reported() { + assert!(matches!( + parse("SecRule ARGS \"@nope x\" \"id:1\"\n", "t") + .unwrap_err() + .kind, + ParseErrorKind::Operator(_) + )); + assert!(matches!( + parse("SecRule ARGS \"@rx x\" \"id:notanumber\"\n", "t") + .unwrap_err() + .kind, + ParseErrorKind::Action(_) + )); + } + + #[test] + fn a_three_token_secrule_without_actions_parses() { + let d = parse("SecRule ARGS \"@rx x\"\n", "t").unwrap(); + let Directive::Rule(rule) = &d[0] else { + panic!("expected a rule"); + }; + assert!(rule.actions.is_empty()); + } + + #[test] + fn xml_and_regex_selector_syntax_is_validated() { + // An XML selector must be an XPath starting with '/'. + assert!(matches!( + parse("SecRule XML:notxpath \"@rx x\" \"id:1\"\n", "t") + .unwrap_err() + .kind, + ParseErrorKind::Target { .. } + )); + // A regex selector must close its slash. + assert!(matches!( + parse("SecRule ARGS:/open \"@rx x\" \"id:1\"\n", "t") + .unwrap_err() + .kind, + ParseErrorKind::Target { .. } + )); + // Empty members between pipes are skipped, not an error. + let d = parse("SecRule ARGS||REQUEST_URI \"@rx x\" \"id:1\"\n", "t").unwrap(); + let Directive::Rule(rule) = &d[0] else { + panic!() + }; + assert_eq!(rule.targets.len(), 2); + } + + #[test] + fn an_empty_target_list_is_an_error() { + assert!(parse("SecRule | \"@rx x\" \"id:1\"\n", "t").is_err()); + } + + #[test] + fn secaction_arity_and_action_errors_are_reported() { + assert!(matches!( + parse("SecAction a b\n", "t").unwrap_err().kind, + ParseErrorKind::Arity { .. } + )); + assert!(matches!( + parse("SecAction \"id:notnum\"\n", "t").unwrap_err().kind, + ParseErrorKind::Action(_) + )); + } + + #[test] + fn secdefaultaction_parses_and_defaults_its_phase() { + // No phase given: defaults to phase 2. + let d = parse("SecDefaultAction \"pass,log\"\n", "t").unwrap(); + assert!(matches!( + &d[0], + Directive::DefaultAction { phase, .. } if *phase == Phase::RequestBody + )); + assert!(parse("SecDefaultAction\n", "t").is_err()); + assert!(parse("SecDefaultAction \"bogus:1\"\n", "t").is_err()); + } + + #[test] + fn secmarker_and_component_signature_arity_is_enforced() { + assert!(parse("SecMarker\n", "t").is_err()); + let d = parse("SecComponentSignature \"OWASP_CRS/4.0.0\"\n", "t").unwrap(); + assert_eq!( + d[0], + Directive::ComponentSignature("OWASP_CRS/4.0.0".into()) + ); + assert!(parse("SecComponentSignature\n", "t").is_err()); + } + + #[test] + fn a_paren_group_protects_commas_inside_an_action_list() { + let d = parse("SecAction \"id:1,setvar:tx.x=(a,b),pass\"\n", "t").unwrap(); + let Directive::Action(rule) = &d[0] else { + panic!() + }; + // id, setvar, pass -> the comma inside (a,b) did not split. + assert_eq!(rule.actions.len(), 3); + } + + #[test] + fn a_trailing_continuation_still_yields_its_directive() { + // The last logical line ends on a continuation with no newline after. + let d = parse("SecAction \"id:1,pass\" \\", "t").unwrap(); + assert_eq!(d.len(), 1); + } + #[test] fn error_messages_carry_file_and_line() { let err = parse("\n\nSecBogus x\n", "rules/REQUEST-942.conf").unwrap_err(); diff --git a/crates/parapet/src/rule.rs b/crates/parapet/src/rule.rs index dff3dfe..2cfa184 100644 --- a/crates/parapet/src/rule.rs +++ b/crates/parapet/src/rule.rs @@ -207,3 +207,91 @@ impl Severity { }) } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::action::Action; + + #[test] + fn severity_parses_names_and_numbers() { + for (s, sev) in [ + ("EMERGENCY", Severity::Emergency), + ("0", Severity::Emergency), + ("alert", Severity::Alert), + ("2", Severity::Critical), + ("ERROR", Severity::Error), + ("warning", Severity::Warning), + ("5", Severity::Notice), + ("INFO", Severity::Info), + ("7", Severity::Debug), + ] { + assert_eq!(Severity::parse(s), Some(sev)); + } + assert_eq!(Severity::parse("nope"), None); + } + + #[test] + fn rule_id_finds_the_id_action_among_others() { + let with = Rule { + targets: Vec::new(), + operator: None, + negated: false, + actions: vec![Action::Phase(crate::Phase::RequestBody), Action::Id(5)], + line: 1, + }; + assert_eq!(with.id(), Some(5)); + let without = Rule { + targets: Vec::new(), + operator: None, + negated: false, + actions: vec![Action::Pass], + line: 1, + }; + assert_eq!(without.id(), None); + } + + #[test] + fn every_collection_name_round_trips() { + use Collection::*; + for c in [ + Args, + ArgsCombinedSize, + ArgsGet, + ArgsGetNames, + ArgsNames, + Files, + FilesCombinedSize, + FilesNames, + MatchedVar, + MatchedVars, + MultipartPartHeaders, + QueryString, + ReqbodyProcessor, + RemoteAddr, + RequestBasename, + RequestBody, + RequestCookies, + RequestCookiesNames, + RequestFilename, + RequestHeaders, + RequestHeadersNames, + RequestLine, + RequestMethod, + RequestProtocol, + RequestUri, + RequestUriRaw, + ResponseBody, + ResponseHeaders, + ResponseStatus, + Tx, + UniqueId, + Xml, + ] { + let name = crate::collections::collection_name(c); + assert_eq!(Collection::parse(name), Some(c), "{name}"); + } + assert_eq!(Collection::parse("NOT_A_COLLECTION"), None); + } +} diff --git a/crates/parapet/src/transaction.rs b/crates/parapet/src/transaction.rs index e6d4ada..b7e4569 100644 --- a/crates/parapet/src/transaction.rs +++ b/crates/parapet/src/transaction.rs @@ -1391,4 +1391,182 @@ SecRule ARGS "@rx attack" "id:1,phase:1,block" assert_eq!(tx.process_request_headers(), Verdict::Allow); assert!(matches!(tx.process_request_body(), Verdict::Deny { .. })); } + + #[test] + fn accessors_report_the_transaction_state() { + let rs = rules( + r#"SecRule ARGS "@rx attack" "id:7,phase:1,pass,msg:'hit',setvar:'tx.score=+3'""#, + ); + let mut tx = Transaction::new(&rs, EngineMode::Blocking); + tx.set_remote_addr("203.0.113.9"); + tx.process_uri("GET", "/?q=attack", "HTTP/1.1"); + assert_eq!(tx.process_request_headers(), Verdict::Allow); + assert_eq!(*tx.verdict(), Verdict::Allow); + assert_eq!(tx.matches().len(), 1); + assert_eq!(tx.matched_ids(), vec![7]); + assert_eq!(tx.last_completed_phase(), Some(Phase::RequestHeaders)); + assert_eq!(tx.anomaly_score("score"), 3); + assert_eq!(tx.vars.remote_addr, b"203.0.113.9"); + } + + #[test] + fn a_response_header_rule_can_block() { + let rs = rules(r#"SecRule RESPONSE_HEADERS:X-Leak "@streq yes" "id:1,phase:3,deny""#); + let mut tx = get(&rs, "/"); + tx.process_request_headers(); + tx.process_request_body(); + tx.set_response_status(200); + tx.add_response_header("X-Leak", "yes"); + assert!(matches!( + tx.process_response_headers(), + Verdict::Deny { .. } + )); + } + + #[test] + fn phase_five_logging_runs_without_blocking() { + let rs = rules(r#"SecAction "id:1,phase:5,pass,nolog,setvar:'tx.logged=1'""#); + let mut tx = get(&rs, "/"); + tx.process_request_headers(); + assert_eq!(tx.process_logging(), Verdict::Allow); + assert_eq!(tx.vars.tx_get("logged"), Some(&b"1"[..])); + } + + #[test] + fn ctl_rule_remove_by_id_disables_a_later_rule() { + // A range form covers the id, so rule 2 is switched off before it runs. + let rs = rules( + r#" +SecAction "id:1,phase:1,pass,nolog,ctl:ruleRemoveById=900-1000" +SecRule ARGS "@rx attack" "id:950,phase:1,deny" +"#, + ); + let mut tx = get(&rs, "/?q=attack"); + assert_eq!(tx.process_request_headers(), Verdict::Allow); + } + + #[test] + fn ctl_rule_remove_by_tag_disables_a_tagged_rule() { + let rs = rules( + r#" +SecAction "id:1,phase:1,pass,nolog,ctl:ruleRemoveByTag=noisy" +SecRule ARGS "@rx attack" "id:2,phase:1,deny,tag:noisy" +"#, + ); + let mut tx = get(&rs, "/?q=attack"); + assert_eq!(tx.process_request_headers(), Verdict::Allow); + } + + #[test] + fn ctl_rule_remove_target_drops_one_variable_from_one_rule() { + // The by-id form removes ARGS:allowed from rule 2 only. + let rs = rules( + r#" +SecAction "id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=2;ARGS:allowed" +SecRule ARGS "@rx attack" "id:2,phase:1,deny" +"#, + ); + let mut tx = get(&rs, "/?allowed=attack"); + assert_eq!(tx.process_request_headers(), Verdict::Allow); + // A different argument is still inspected. + let mut tx = get(&rs, "/?other=attack"); + assert!(matches!(tx.process_request_headers(), Verdict::Deny { .. })); + } + + #[test] + fn ctl_rule_remove_target_by_tag_drops_a_variable_from_tagged_rules() { + let rs = rules( + r#" +SecAction "id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetByTag=grp;ARGS:allowed" +SecRule ARGS "@rx attack" "id:2,phase:1,deny,tag:grp" +"#, + ); + let mut tx = get(&rs, "/?allowed=attack"); + assert_eq!(tx.process_request_headers(), Verdict::Allow); + } + + #[test] + fn ctl_request_body_processor_forces_a_parser() { + // Force JSON parsing for a body whose content type would select nothing. + let rs = rules( + r#" +SecRule REQUEST_HEADERS:X-Kind "@streq json" "id:1,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON" +SecRule ARGS "@rx payload" "id:2,phase:2,deny" +"#, + ); + let mut tx = Transaction::new(&rs, EngineMode::Blocking); + tx.process_uri("POST", "/", "HTTP/1.1"); + tx.add_request_header("X-Kind", "json"); + tx.add_request_header("Content-Type", "application/octet-stream"); + tx.process_request_headers(); + tx.set_request_body(br#"{"a":"payload"}"#, None); + assert!(matches!(tx.process_request_body(), Verdict::Deny { .. })); + } + + #[test] + fn ctl_rule_engine_off_stops_all_further_evaluation() { + let rs = rules( + r#" +SecAction "id:1,phase:1,pass,nolog,ctl:ruleEngine=Off" +SecRule ARGS "@rx attack" "id:2,phase:1,deny" +"#, + ); + let mut tx = get(&rs, "/?q=attack"); + assert_eq!(tx.process_request_headers(), Verdict::Allow); + } + + #[test] + fn ctl_rule_engine_detection_only_records_without_blocking() { + let rs = rules( + r#" +SecAction "id:1,phase:1,pass,nolog,ctl:ruleEngine=DetectionOnly" +SecRule ARGS "@rx attack" "id:2,phase:1,deny,msg:'seen'" +"#, + ); + let mut tx = get(&rs, "/?q=attack"); + assert_eq!(tx.process_request_headers(), Verdict::Allow); + // The attack rule matched (and recorded) even though nothing blocked. + assert!(tx.matched_ids().contains(&2)); + } + + #[test] + fn ctl_audit_engine_is_accepted_and_changes_nothing() { + let rs = rules( + r#" +SecAction "id:1,phase:1,pass,nolog,ctl:auditEngine=Off" +SecRule ARGS "@rx attack" "id:2,phase:1,deny" +"#, + ); + let mut tx = get(&rs, "/?q=attack"); + assert!(matches!(tx.process_request_headers(), Verdict::Deny { .. })); + } + + #[test] + fn a_semicolon_separates_urlencoded_pairs_and_blanks_are_skipped() { + let rs = rules(r#"SecRule ARGS:b "@rx attack" "id:1,phase:1,deny""#); + let mut tx = get(&rs, "/?a=1;;b=attack"); + assert!(matches!(tx.process_request_headers(), Verdict::Deny { .. })); + } + + #[test] + fn cookie_values_are_trimmed_and_blank_cookies_skipped() { + let rs = rules(r#"SecRule REQUEST_COOKIES:sid "@streq abc" "id:1,phase:1,deny""#); + let mut tx = Transaction::new(&rs, EngineMode::Blocking); + tx.process_uri("GET", "/", "HTTP/1.1"); + tx.add_request_header("Cookie", " ; sid = abc ; "); + assert!(matches!(tx.process_request_headers(), Verdict::Deny { .. })); + } + + #[test] + fn setvar_subtract_lowers_a_score() { + let rs = rules( + r#" +SecAction "id:1,phase:1,pass,nolog,setvar:'tx.score=10'" +SecAction "id:2,phase:1,pass,nolog,setvar:'tx.score=-4'" +"#, + ); + let mut tx = get(&rs, "/"); + tx.process_request_headers(); + assert_eq!(tx.anomaly_score("score"), 6); + } } diff --git a/crates/parapet/src/transform.rs b/crates/parapet/src/transform.rs index c938d58..9e5102c 100644 --- a/crates/parapet/src/transform.rs +++ b/crates/parapet/src/transform.rs @@ -1021,4 +1021,31 @@ mod tests { } } } + #[test] + fn decoder_edge_branches() { + // urlDecodeUni leaves a malformed escape verbatim. + assert_eq!(t(T::UrlDecodeUni, "%uZZZZ"), "%uZZZZ"); + assert_eq!(t(T::UrlDecodeUni, "%u"), "%u"); + assert_eq!(t(T::UrlDecodeUni, "%GG"), "%GG"); + assert_eq!(t(T::UrlDecodeUni, "%"), "%"); + // The single-character C escapes. + assert_eq!( + T::EscapeSeqDecode.apply(br"\a\b\f\v").into_owned(), + vec![0x07, 0x08, 0x0c, 0x0b] + ); + // A CSS backslash-newline is a line continuation and vanishes. + assert_eq!( + t( + T::CssDecode, + "a\\ +b" + ), + "ab" + ); + // A zero code point resolves to the replacement character. + assert_eq!(t(T::CssDecode, "\\0"), "\u{FFFD}"); + // Base64 with a 2- and 3-char remainder still decodes the partial bytes. + assert_eq!(t(T::Base64Decode, "TWE"), "Ma"); + assert_eq!(t(T::Base64Decode, "TQ"), "M"); + } } diff --git a/crates/parapet/src/xml.rs b/crates/parapet/src/xml.rs index b24adaf..0888750 100644 --- a/crates/parapet/src/xml.rs +++ b/crates/parapet/src/xml.rs @@ -184,4 +184,26 @@ mod tests { assert!(!xpath_is_supported("/xml/a")); assert!(!xpath_is_supported("//text()")); } + + #[test] + fn text_at_the_root_is_attributed_to_a_slash() { + let v = parse(b"root textchild").unwrap(); + assert!(texts(&v).contains(&"root text".to_string())); + assert!(texts(&v).contains(&"child".to_string())); + } + + #[test] + fn empty_text_and_cdata_are_skipped() { + let v = parse(b"").unwrap(); + assert!(texts(&v).is_empty()); + } + + #[test] + fn an_undecodable_entity_is_kept_raw_rather_than_dropped() { + // The reader accepts this numeric reference, but its value is out of + // the Unicode range, so unescape fails and the text reaches the rules + // as written rather than vanishing. + let v = parse(b"x�y").unwrap(); + assert!(texts(&v)[0].contains("11FFFF") || texts(&v)[0].contains("x")); + } }