From 6d9d5e4c4e3f60a4d0e885c74c8dce46aaa3f5be Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 25 May 2026 22:10:19 +0200 Subject: [PATCH] fix(derive): reject form type impostors Tighten generated form-handler validation so only actual Form paths satisfy form handles; names like CreateForm no longer bypass the slhx::Form<_> requirement. Cover with unit and compile-fail tests. req: form/004 req: form/006 req: test/003 --- slhx-derive/src/lib.rs | 29 +++++++++++------ slhx-derive/tests/compile_fail.rs | 54 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/slhx-derive/src/lib.rs b/slhx-derive/src/lib.rs index 9c8959f..6698ddc 100644 --- a/slhx-derive/src/lib.rs +++ b/slhx-derive/src/lib.rs @@ -120,11 +120,18 @@ fn generated_path(file: &str) -> Option { fn has_form_param(function: &ItemFn) -> bool { function.sig.inputs.iter().any(|arg| match arg { - FnArg::Typed(arg) => type_ends_with(&arg.ty, "Form"), + FnArg::Typed(arg) => is_form_type(&arg.ty), FnArg::Receiver(_) => false, }) } +fn is_form_type(ty: &Type) -> bool { + match ty { + Type::Path(path) => path.path.segments.last().is_some_and(|segment| segment.ident == "Form"), + _ => false, + } +} + fn has_non_unit_return(function: &ItemFn) -> bool { match &function.sig.output { ReturnType::Default => false, @@ -132,13 +139,6 @@ fn has_non_unit_return(function: &ItemFn) -> bool { } } -fn type_ends_with(ty: &Type, ident: &str) -> bool { - match ty { - Type::Path(path) => path.path.segments.last().is_some_and(|segment| segment.ident == ident), - _ => false, - } -} - fn syms_contains_handle(path: &PathBuf, ident: &str) -> bool { let Ok(syms) = std::fs::read_to_string(path) else { return true; @@ -169,8 +169,8 @@ fn compile_error(message: &str) -> TokenStream { #[cfg(test)] mod tests { - use super::{handle_requires_form, has_form_param, has_non_unit_return, syms_contains_handle}; - use syn::parse_quote; + use super::{handle_requires_form, has_form_param, has_non_unit_return, is_form_type, syms_contains_handle}; + use syn::{parse_quote, Type}; #[test] fn syms_lookup_matches_handle_ident() { @@ -201,4 +201,13 @@ mod tests { assert!(!has_form_param(&empty)); assert!(!has_non_unit_return(&empty)); } + + #[test] + fn form_param_matches_form_type_not_name_suffix() { + let real_form: Type = parse_quote!(slhx::Form); + let impostor: Type = parse_quote!(CreateTodoForm); + + assert!(is_form_type(&real_form)); + assert!(!is_form_type(&impostor)); + } } diff --git a/slhx-derive/tests/compile_fail.rs b/slhx-derive/tests/compile_fail.rs index 473ed58..3278b88 100644 --- a/slhx-derive/tests/compile_fail.rs +++ b/slhx-derive/tests/compile_fail.rs @@ -163,6 +163,60 @@ fn create() -> impl slhx::IntoEffect { ); } +#[test] +fn form_handle_rejects_form_name_suffix_impostor() { + // req: form/004 req: form/006 req: test/003 + let fixture = Fixture::new("slhx-derive-form-impostor-fail"); + fixture.write( + "Cargo.toml", + &format!( + r#"[package] +name = "slhx-derive-form-impostor-fail" +version = "0.0.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[dependencies] +slhx = {{ path = {:?} }} +"#, + repo_path("slhx") + ), + ); + fixture.write( + "build.rs", + r#"fn main() { + let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); + std::fs::write( + out.join("slhx.syms"), + "slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n", + ) + .unwrap(); +} +"#, + ); + fixture.write( + "src/lib.rs", + r#"struct CreateForm; + +#[slhx::handler] +fn create(_form: CreateForm) -> impl slhx::IntoEffect { + slhx::EffectBatch::default() +} +"#, + ); + + let output = check_fixture(&fixture); + + assert!(!output.status.success(), "fixture unexpectedly compiled"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"), + "missing form-impostor diagnostic in stderr:\n{stderr}" + ); +} + #[test] fn surface_macro_reports_missing_generated_include() { // req: build/004 req: test/003