From 34d6757ccdba15781688467292861a248907be60 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 25 May 2026 23:31:48 +0200 Subject: [PATCH] fix(derive): reject form impostor parameters Tighten generated-form handler checking so a parameter must look like slhx::Form or imported Form, not a nongeneric local Form or unrelated path with a Form suffix. req: form/004 req: form/006 --- slhx-derive/src/lib.rs | 43 +++++++++++++++++++----- slhx-derive/tests/compile_fail.rs | 54 +++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/slhx-derive/src/lib.rs b/slhx-derive/src/lib.rs index f47ec2b..6bb5161 100644 --- a/slhx-derive/src/lib.rs +++ b/slhx-derive/src/lib.rs @@ -1,7 +1,10 @@ use proc_macro::TokenStream; use quote::quote; use std::path::PathBuf; -use syn::{parse_macro_input, FnArg, Item, ItemFn, ItemMod, Pat, ReturnType, Type}; +use syn::{ + parse_macro_input, FnArg, GenericArgument, Item, ItemFn, ItemMod, Pat, PathArguments, + ReturnType, Type, +}; #[proc_macro_attribute] pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream { @@ -165,10 +168,27 @@ fn has_form_param(function: &ItemFn) -> bool { } fn is_form_type(ty: &Type) -> bool { - match ty { - Type::Path(path) => path.path.segments.last().is_some_and(|segment| segment.ident == "Form"), - _ => false, + let Type::Path(path) = ty else { + return false; + }; + let mut segments = path.path.segments.iter(); + let Some(first) = segments.next() else { + return false; + }; + let last = path.path.segments.last().expect("path has at least one segment"); + let path_is_form = if path.path.segments.len() == 1 { + first.ident == "Form" + } else { + first.ident == "slhx" && last.ident == "Form" + }; + if !path_is_form { + return false; } + matches!( + &last.arguments, + PathArguments::AngleBracketed(args) + if args.args.iter().any(|arg| matches!(arg, GenericArgument::Type(_))) + ) } fn has_non_unit_return(function: &ItemFn) -> bool { @@ -332,11 +352,18 @@ mod tests { #[test] fn form_param_matches_form_type_not_name_suffix() { - let real_form: Type = parse_quote!(slhx::Form); - let impostor: Type = parse_quote!(CreateTodoForm); + // req: form/004 req: form/006 + let qualified_form: Type = parse_quote!(slhx::Form); + let imported_form: Type = parse_quote!(Form); + let name_suffix_impostor: Type = parse_quote!(CreateTodoForm); + let nongeneric_impostor: Type = parse_quote!(Form); + let foreign_form: Type = parse_quote!(other::Form); - assert!(is_form_type(&real_form)); - assert!(!is_form_type(&impostor)); + assert!(is_form_type(&qualified_form)); + assert!(is_form_type(&imported_form)); + assert!(!is_form_type(&name_suffix_impostor)); + assert!(!is_form_type(&nongeneric_impostor)); + assert!(!is_form_type(&foreign_form)); } #[test] diff --git a/slhx-derive/tests/compile_fail.rs b/slhx-derive/tests/compile_fail.rs index e284c52..ba73a42 100644 --- a/slhx-derive/tests/compile_fail.rs +++ b/slhx-derive/tests/compile_fail.rs @@ -324,6 +324,60 @@ fn create(_form: slhx::Form) -> impl slhx::IntoEffect { ); } +#[test] +fn form_handle_rejects_nongeneric_form_impostor() { + // req: form/004 req: form/006 req: test/003 + let fixture = Fixture::new("slhx-derive-form-handler-nongeneric-impostor-fail"); + fixture.write( + "Cargo.toml", + &format!( + r#"[package] +name = "slhx-derive-form-handler-nongeneric-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 Form; + +#[slhx::handler] +fn create(_form: Form) -> 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 nongeneric form diagnostic in stderr:\n{stderr}" + ); +} + #[test] fn form_handle_rejects_form_name_suffix_impostor() { // req: form/004 req: form/006 req: test/003