diff --git a/src/dynamic/framework/adapters/php_routes.rs b/src/dynamic/framework/adapters/php_routes.rs index a18654f0..79623479 100644 --- a/src/dynamic/framework/adapters/php_routes.rs +++ b/src/dynamic/framework/adapters/php_routes.rs @@ -433,8 +433,13 @@ fn visit_laravel_routes<'a>( if out.is_some() { return; } - if node.kind() == "scoped_call_expression" - && let Some(found) = try_laravel_route(node, bytes, target, controller) + if node.kind() == "scoped_call_expression" { + if let Some(found) = try_laravel_route(node, bytes, target, controller) { + *out = Some(found); + return; + } + } else if node.kind() == "member_call_expression" + && let Some(found) = try_laravel_member_route(node, bytes, target, controller) { *out = Some(found); return; @@ -470,6 +475,30 @@ fn try_laravel_route<'a>( }) } +fn try_laravel_member_route<'a>( + call: Node<'a>, + bytes: &'a [u8], + target: &str, + controller: Option<&str>, +) -> Option { + let object = call.child_by_field_name("object")?.utf8_text(bytes).ok()?; + if object.trim_start_matches('$').trim() != "router" { + return None; + } + let verb_node = call.child_by_field_name("name")?.utf8_text(bytes).ok()?; + let args = call.child_by_field_name("arguments")?; + let methods = laravel_route_methods(verb_node, args, bytes)?; + let path = laravel_route_path(verb_node, args, bytes)?; + if !laravel_callable_matches(verb_node, args, bytes, target, controller) { + return None; + } + Some(if methods.len() > 1 { + RouteShape::multi(methods, path) + } else { + RouteShape::single(methods[0], path) + }) +} + fn laravel_route_methods(verb: &str, arguments: Node<'_>, bytes: &[u8]) -> Option> { match verb { "any" => Some(vec![ diff --git a/src/dynamic/harness.rs b/src/dynamic/harness.rs index 4a1f8598..da855940 100644 --- a/src/dynamic/harness.rs +++ b/src/dynamic/harness.rs @@ -104,6 +104,7 @@ fn stage_harness( // Copy the entry file into the workdir so the harness can import/include it. copy_entry_file(spec, &workdir, harness_src.entry_subpath.as_deref()); copy_java_sibling_sources(spec, &workdir); + copy_php_project_manifests(spec, &workdir); Ok(workdir) } @@ -259,6 +260,26 @@ fn copy_java_sibling_sources(spec: &HarnessSpec, workdir: &Path) { } } +fn copy_php_project_manifests(spec: &HarnessSpec, workdir: &Path) { + if spec.lang != crate::symbol::Lang::Php { + return; + } + let entry = PathBuf::from(&spec.entry_file); + let mut dir = entry.parent(); + while let Some(current) = dir { + let composer_json = current.join("composer.json"); + if composer_json.exists() { + let _ = fs::copy(&composer_json, workdir.join("composer.json")); + let composer_lock = current.join("composer.lock"); + if composer_lock.exists() { + let _ = fs::copy(composer_lock, workdir.join("composer.lock")); + } + return; + } + dir = current.parent(); + } +} + /// Extract the source of the entry file (for repro bundles). Best-effort. fn extract_entry_source(spec: &HarnessSpec) -> String { let candidates = [ diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 6cc2537b..48af062b 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -143,22 +143,21 @@ pub enum PhpShape { /// stub (query/body) and invokes the closure resolved from the /// global (which the entry file publishes during include). RouteClosure, - /// Laravel route — `Route::get('/x', 'Controller@method')` or - /// closure callable. Phase 16 v1 dispatches through the same - /// `$GLOBALS['__nyx_route']` channel as `RouteClosure` but - /// publishes a `NYX_LARAVEL_TEST=1` stdout marker so the - /// verifier can confirm the framework toolchain knob propagated. + /// Laravel route — `Route::get('/x', 'Controller@method')`, + /// `$router->get('/x', [Controller::class, 'method'])`, or + /// closure callable. The harness requires Composer autoload, + /// registers the fixture routes against `Illuminate\Routing\Router`, + /// and dispatches an `Illuminate\Http\Request`. LaravelRoute, /// Symfony route — `#[Route('/x')]` PHP attribute on a - /// controller method or top-level function. Phase 16 v1 - /// dispatches via reflective invocation (the entry file's - /// `entry.php` instantiates the controller class and the harness - /// calls the method) plus an `NYX_SYMFONY_TEST=1` stdout marker. + /// controller method or top-level function. The harness requires + /// Composer autoload, registers the fixture routes into a + /// `RouteCollection`, and drives `HttpKernel::handle`. SymfonyRoute, /// CodeIgniter route — `$routes->get('users/(:num)', ...)` - /// published from `app/Config/Routes.php`. Phase 16 v1 - /// dispatches via the `$GLOBALS['__nyx_route']` channel plus a - /// `NYX_CODEIGNITER_TEST=1` stdout marker. + /// published from `app/Config/Routes.php`. The harness requires + /// Composer autoload, registers routes against CodeIgniter's + /// `RouteCollection`, and replays the matching route handler. CodeIgniterRoute, /// CLI script driven by `$argv`. Harness mutates `$argv` then /// includes the entry file (whose top-level body reads `$argv`), @@ -1953,6 +1952,7 @@ fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String { let shim = probe_shim(); let toolchain_marker = build_toolchain_marker(shape); let route_methods_fn = build_route_methods_fn(spec); + let framework_helpers = build_framework_helpers(spec, shape); let crash_callee = if entry_fn.is_empty() { "main" } else { @@ -1976,9 +1976,10 @@ function nyx_payload(): string {{ return ''; }} -{route_methods_fn} - -$payload = nyx_payload(); + {route_methods_fn} + {framework_helpers} + + $payload = nyx_payload(); // Phase 08 sink-site signal handler: install AFTER payload decode so a crash // inside `nyx_payload` writes no Crash probe and routes the verifier to @@ -2016,10 +2017,286 @@ foreach (__nyx_route_methods() as $__nyx_method) {{ shim = shim, toolchain_marker = toolchain_marker, route_methods_fn = route_methods_fn, + framework_helpers = framework_helpers, crash_callee = crash_callee, ) } +fn route_path_for_spec(spec: &HarnessSpec) -> String { + spec.framework + .as_ref() + .and_then(|binding| binding.route.as_ref()) + .map(|route| route.path.clone()) + .unwrap_or_else(|| { + if matches!(spec.entry_kind, crate::evidence::EntryKind::HttpRoute) { + "/run/{payload}".to_owned() + } else { + "/".to_owned() + } + }) +} + +fn build_framework_helpers(spec: &HarnessSpec, shape: PhpShape) -> String { + if !matches!( + shape, + PhpShape::LaravelRoute | PhpShape::SymfonyRoute | PhpShape::CodeIgniterRoute + ) { + return String::new(); + } + + let route_path = php_string_literal(&route_path_for_spec(spec)); + let mut out = String::new(); + out.push_str("const __NYX_ROUTE_PATH = "); + out.push_str(&route_path); + out.push_str(";\n"); + out.push_str( + r#" +function __nyx_find_user_function(string $leaf): ?string { + $funcs = get_defined_functions(); + foreach (($funcs['user'] ?? []) as $fn) { + if ($fn === $leaf || str_ends_with($fn, '\\' . $leaf)) { + return $fn; + } + } + return null; +} + +function __nyx_request_path(string $template, string $payload): string { + $encoded = rawurlencode($payload); + $path = $template; + $replacements = [ + '{payload}' => $encoded, + '{payload?}' => $encoded, + '(:any)' => $encoded, + '(:segment)' => $encoded, + '(:alphanum)' => $encoded, + '(:alpha)' => $encoded, + '(:num)' => $encoded, + '(:hash)' => $encoded, + ]; + foreach ($replacements as $needle => $value) { + if (str_contains($path, $needle)) { + $path = str_replace($needle, $value, $path); + break; + } + } + if ($path === '') { + $path = '/'; + } + if ($path[0] !== '/') { + $path = '/' . $path; + } + return $path; +} + +function __nyx_response_text($response): string { + if ($response === null) { + return ''; + } + if (is_string($response)) { + return $response; + } + if (is_object($response)) { + if (method_exists($response, 'getContent')) { + return (string) $response->getContent(); + } + if (method_exists($response, 'getBody')) { + return (string) $response->getBody(); + } + if (method_exists($response, '__toString')) { + return (string) $response; + } + } + if (is_array($response)) { + return json_encode($response) ?: ''; + } + return (string) $response; +} + +function __nyx_require_registrar(): string { + $registrar = __nyx_find_user_function('nyx_register_routes'); + if ($registrar === null) { + throw new RuntimeException('NYX_ROUTE_REGISTRAR_MISSING: nyx_register_routes'); + } + return $registrar; +} +"#, + ); + + match shape { + PhpShape::LaravelRoute => out.push_str( + r#" +function __nyx_dispatch_laravel(string $payload, string $method) { + $required = [ + '\Illuminate\Container\Container', + '\Illuminate\Events\Dispatcher', + '\Illuminate\Http\Request', + '\Illuminate\Routing\Router', + ]; + foreach ($required as $class) { + if (!class_exists($class)) { + throw new RuntimeException('NYX_LARAVEL_CLASS_MISSING: ' . $class); + } + } + $container = new \Illuminate\Container\Container(); + \Illuminate\Container\Container::setInstance($container); + $events = new \Illuminate\Events\Dispatcher($container); + $router = new \Illuminate\Routing\Router($events, $container); + $registrar = __nyx_require_registrar(); + $registrar($router); + $path = __nyx_request_path(__NYX_ROUTE_PATH, $payload); + $request = \Illuminate\Http\Request::create($path, $method, ['payload' => $payload], [], [], [], $payload); + $response = $router->dispatch($request); + return __nyx_response_text($response); +} +"#, + ), + PhpShape::SymfonyRoute => out.push_str( + r#" +function __nyx_dispatch_symfony(string $payload, string $method) { + $required = [ + '\Symfony\Component\EventDispatcher\EventDispatcher', + '\Symfony\Component\HttpFoundation\Request', + '\Symfony\Component\HttpFoundation\RequestStack', + '\Symfony\Component\HttpKernel\Controller\ArgumentResolver', + '\Symfony\Component\HttpKernel\Controller\ControllerResolver', + '\Symfony\Component\HttpKernel\HttpKernel', + '\Symfony\Component\Routing\Matcher\UrlMatcher', + '\Symfony\Component\Routing\RequestContext', + '\Symfony\Component\Routing\RouteCollection', + ]; + foreach ($required as $class) { + if (!class_exists($class)) { + throw new RuntimeException('NYX_SYMFONY_CLASS_MISSING: ' . $class); + } + } + $routes = new \Symfony\Component\Routing\RouteCollection(); + $registrar = __nyx_require_registrar(); + $registrar($routes); + $path = __nyx_request_path(__NYX_ROUTE_PATH, $payload); + $request = \Symfony\Component\HttpFoundation\Request::create($path, $method, ['payload' => $payload], [], [], [], $payload); + $context = new \Symfony\Component\Routing\RequestContext(); + $context->fromRequest($request); + $matcher = new \Symfony\Component\Routing\Matcher\UrlMatcher($routes, $context); + $request->attributes->add($matcher->match($request->getPathInfo())); + $kernel = new \Symfony\Component\HttpKernel\HttpKernel( + new \Symfony\Component\EventDispatcher\EventDispatcher(), + new \Symfony\Component\HttpKernel\Controller\ControllerResolver(), + new \Symfony\Component\HttpFoundation\RequestStack(), + new \Symfony\Component\HttpKernel\Controller\ArgumentResolver() + ); + $response = $kernel->handle($request); + return __nyx_response_text($response); +} +"#, + ), + PhpShape::CodeIgniterRoute => out.push_str( + r#" +function __nyx_define_codeigniter_config(): void { + if (!defined('ENVIRONMENT')) define('ENVIRONMENT', 'testing'); + if (!defined('ROOTPATH')) define('ROOTPATH', __DIR__ . DIRECTORY_SEPARATOR); + if (!defined('APPPATH')) define('APPPATH', __DIR__ . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR); + if (!defined('WRITEPATH')) define('WRITEPATH', __DIR__ . DIRECTORY_SEPARATOR . 'writable' . DIRECTORY_SEPARATOR); + if (!defined('SYSTEMPATH')) define('SYSTEMPATH', __DIR__ . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'codeigniter4' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR . 'system' . DIRECTORY_SEPARATOR); + if (!is_dir(APPPATH . 'Config')) @mkdir(APPPATH . 'Config', 0777, true); + if (!is_dir(WRITEPATH)) @mkdir(WRITEPATH, 0777, true); + if (!class_exists('Config\\Modules') && class_exists('\\CodeIgniter\\Config\\Modules')) { + eval('namespace Config; class Modules extends \\CodeIgniter\\Config\\Modules {}'); + } + if (!class_exists('Config\\Routing') && class_exists('\\CodeIgniter\\Config\\Routing')) { + eval('namespace Config; class Routing extends \\CodeIgniter\\Config\\Routing {}'); + } + if (!class_exists('Config\\App') && class_exists('\\CodeIgniter\\Config\\BaseConfig')) { + eval('namespace Config; class App extends \\CodeIgniter\\Config\\BaseConfig { public string $baseURL = "http://localhost/"; public array $supportedLocales = ["en"]; }'); + } + if (!class_exists('Config\\Services') && class_exists('\\CodeIgniter\\Config\\BaseService')) { + eval('namespace Config; class Services extends \\CodeIgniter\\Config\\BaseService {}'); + } +} + +function __nyx_codeigniter_routes() { + __nyx_define_codeigniter_config(); + if (!class_exists('\\CodeIgniter\\Router\\RouteCollection')) { + throw new RuntimeException('NYX_CODEIGNITER_CLASS_MISSING: \\CodeIgniter\\Router\\RouteCollection'); + } + if (class_exists('\\CodeIgniter\\Config\\Services')) { + try { + $routes = \CodeIgniter\Config\Services::routes(false); + if ($routes !== null) { + return $routes; + } + } catch (Throwable $_) { + } + } + $modules = class_exists('Config\\Modules') ? new \Config\Modules() : null; + $routing = class_exists('Config\\Routing') ? new \Config\Routing() : null; + if ($routing !== null) { + $routing->defaultNamespace = 'App\\Controllers'; + $routing->defaultController = 'Home'; + $routing->defaultMethod = 'index'; + $routing->translateURIDashes = false; + $routing->autoRoute = false; + $routing->routeFiles = []; + } + $locator = class_exists('\\CodeIgniter\\Autoloader\\FileLocator') && $modules !== null + ? new \CodeIgniter\Autoloader\FileLocator($modules) + : null; + return new \CodeIgniter\Router\RouteCollection($locator, $modules, $routing); +} + +function __nyx_ci_pattern_regex(string $pattern): string { + $regex = str_replace( + ['(:any)', '(:segment)', '(:alphanum)', '(:alpha)', '(:num)', '(:hash)'], + ['(.+)', '([^/]+)', '([a-zA-Z0-9]+)', '([a-zA-Z]+)', '([0-9]+)', '([^/]+)'], + $pattern + ); + return '#^' . str_replace('#', '\\#', trim($regex, '/')) . '$#u'; +} + +function __nyx_ci_invoke_handler($handler, array $args, string $payload) { + if (is_callable($handler)) { + return call_user_func_array($handler, $args ?: [$payload]); + } + if (!is_string($handler)) { + throw new RuntimeException('NYX_CODEIGNITER_HANDLER_UNSUPPORTED'); + } + [$target, $_tail] = array_pad(explode('/', $handler, 2), 2, ''); + if (!str_contains($target, '::')) { + throw new RuntimeException('NYX_CODEIGNITER_HANDLER_BAD: ' . $handler); + } + [$class, $method] = explode('::', $target, 2); + if (!class_exists($class) && class_exists('App\\Controllers\\' . ltrim($class, '\\'))) { + $class = 'App\\Controllers\\' . ltrim($class, '\\'); + } + $controller = new $class(); + return call_user_func_array([$controller, $method], $args ?: [$payload]); +} + +function __nyx_dispatch_codeigniter(string $payload, string $method) { + $routes = __nyx_codeigniter_routes(); + $registrar = __nyx_require_registrar(); + $registrar($routes); + if (method_exists($routes, 'setHTTPVerb')) { + $routes->setHTTPVerb($method); + } + $path = ltrim(__nyx_request_path(__NYX_ROUTE_PATH, $payload), '/'); + $map = method_exists($routes, 'getRoutes') ? $routes->getRoutes($method) : []; + foreach ($map as $pattern => $handler) { + if (preg_match(__nyx_ci_pattern_regex((string) $pattern), $path, $matches)) { + array_shift($matches); + $decoded = array_map('rawurldecode', $matches); + return __nyx_response_text(__nyx_ci_invoke_handler($handler, $decoded, $payload)); + } + } + throw new RuntimeException('NYX_CODEIGNITER_ROUTE_NOT_FOUND: ' . $method . ' ' . $path); +} +"#, + ), + _ => {} + } + out +} + fn build_route_methods_fn(spec: &HarnessSpec) -> String { let mut methods = spec .framework @@ -2100,14 +2377,30 @@ fn build_pre_call(spec: &HarnessSpec, shape: PhpShape) -> String { out } -fn build_entry_block(_shape: PhpShape) -> String { - r#"try { +fn build_entry_block(shape: PhpShape) -> String { + let autoload = if matches!( + shape, + PhpShape::LaravelRoute | PhpShape::SymfonyRoute | PhpShape::CodeIgniterRoute + ) { + r#"$__nyx_autoload = __DIR__ . '/vendor/autoload.php'; +if (!is_file($__nyx_autoload)) { + fwrite(STDERR, 'NYX_IMPORT_ERROR: missing Composer autoload; run composer install for framework route replay' . "\n"); + exit(77); +} +require_once $__nyx_autoload; +"# + } else { + "" + }; + format!( + r#"{autoload}try {{ require_once __DIR__ . '/entry.php'; -} catch (Throwable $e) { +}} catch (Throwable $e) {{ fwrite(STDERR, 'NYX_IMPORT_ERROR: ' . $e->getMessage() . "\n"); exit(77); -}"# - .to_owned() +}}"#, + autoload = autoload + ) } /// Phase 11 (Track J.9) CRYPTO harness for PHP. @@ -2899,7 +3192,7 @@ fn build_call_expr(spec: &HarnessSpec, shape: PhpShape, func: &str) -> String { "null".to_owned() } } - PhpShape::RouteClosure | PhpShape::LaravelRoute | PhpShape::CodeIgniterRoute => { + PhpShape::RouteClosure => { // Entry script publishes the route closure via // `$GLOBALS['__nyx_route']`. When the global is missing, // fall back to calling the named function directly. @@ -2907,17 +3200,10 @@ fn build_call_expr(spec: &HarnessSpec, shape: PhpShape, func: &str) -> String { "(isset($GLOBALS['__nyx_route']) && is_callable($GLOBALS['__nyx_route'])) ? call_user_func($GLOBALS['__nyx_route'], $payload) : (function_exists({func:?}) ? {func}($payload) : null)" ) } - PhpShape::SymfonyRoute => { - // Symfony controllers are normally reached through - // `HttpKernel::handle`. The Phase 16 v1 harness drives - // the action directly: the entry file publishes a - // controller instance via `$GLOBALS['__nyx_controller']` - // and the harness reflectively invokes the action method. - // Falls back to calling a bare function when no - // controller class was published. - format!( - "(isset($GLOBALS['__nyx_controller']) && is_object($GLOBALS['__nyx_controller'])) ? $GLOBALS['__nyx_controller']->{func}($payload) : (function_exists({func:?}) ? {func}($payload) : null)" - ) + PhpShape::LaravelRoute => "__nyx_dispatch_laravel($payload, $__nyx_method)".to_owned(), + PhpShape::SymfonyRoute => "__nyx_dispatch_symfony($payload, $__nyx_method)".to_owned(), + PhpShape::CodeIgniterRoute => { + "__nyx_dispatch_codeigniter($payload, $__nyx_method)".to_owned() } PhpShape::Generic => build_generic_call(spec, func), } @@ -3118,7 +3404,9 @@ mod tests { let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php"); let src = generate_source(&spec, PhpShape::LaravelRoute); assert!(src.contains("NYX_LARAVEL_TEST=1")); - assert!(src.contains("$GLOBALS['__nyx_route']")); + assert!(src.contains("vendor/autoload.php")); + assert!(src.contains("__nyx_dispatch_laravel($payload, $__nyx_method)")); + assert!(!src.contains("$GLOBALS['__nyx_route']")); } #[test] @@ -3146,8 +3434,10 @@ mod tests { let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php"); let src = generate_source(&spec, PhpShape::SymfonyRoute); assert!(src.contains("NYX_SYMFONY_TEST=1")); - assert!(src.contains("$GLOBALS['__nyx_controller']")); - assert!(src.contains("->run($payload)")); + assert!(src.contains("vendor/autoload.php")); + assert!(src.contains("Symfony\\Component\\HttpKernel\\HttpKernel")); + assert!(src.contains("__nyx_dispatch_symfony($payload, $__nyx_method)")); + assert!(!src.contains("$GLOBALS['__nyx_controller']")); } #[test] @@ -3155,7 +3445,10 @@ mod tests { let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php"); let src = generate_source(&spec, PhpShape::CodeIgniterRoute); assert!(src.contains("NYX_CODEIGNITER_TEST=1")); - assert!(src.contains("$GLOBALS['__nyx_route']")); + assert!(src.contains("vendor/autoload.php")); + assert!(src.contains("CodeIgniter\\Router\\RouteCollection")); + assert!(src.contains("__nyx_dispatch_codeigniter($payload, $__nyx_method)")); + assert!(!src.contains("$GLOBALS['__nyx_route']")); } #[test] diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index d6222b9f..2dda8f0e 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -544,10 +544,14 @@ impl RustShape { let has_actix_strong = source.contains("use actix_web") || source.contains("actix_web::") || source.contains("// nyx-shape: actix"); - let has_axum_strong = source.contains("use axum::") + let has_axum_import = source.contains("use axum::") || source.contains("axum::Router") + || source.contains("axum::routing"); + let has_axum_route = source.contains("axum::Router") + || source.contains("Router::new") || source.contains("axum::routing") - || source.contains("// nyx-shape: axum"); + || source.contains("// nyx-shape: axum") + || source.contains("// nyx-shape: axum-route"); let has_attribute_route = source.contains("#[get(") || source.contains("#[post(") || source.contains("#[put(") @@ -578,13 +582,14 @@ impl RustShape { Self::ActixWebRoute }; } - if has_axum_strong { + if has_axum_route { return Self::AxumRoute; } // Legacy weak detectors: HttpResponse / IntoResponse may // appear in code that does not import a known framework. let has_actix_weak = source.contains("HttpResponse") || source.contains("HttpRequest"); - let has_axum_weak = source.contains("IntoResponse") + let has_axum_weak = has_axum_import + || source.contains("IntoResponse") || source.contains("Json(") || source.contains("Query("); if has_axum_weak { @@ -2018,7 +2023,7 @@ pub fn emit(spec: &HarnessSpec) -> Result { _ => return Err(UnsupportedReason::PayloadSlotUnsupported), } - let cargo_toml = generate_cargo_toml(spec.expected_cap); + let cargo_toml = generate_cargo_toml_for_shape(spec.expected_cap, shape); let main_rs = generate_main_rs(spec, shape); Ok(HarnessSource { @@ -2045,6 +2050,7 @@ fn emit_class_method_harness(spec: &HarnessSpec, class: &str, method: &str) -> H let entry_label = format!("{class}::{method}"); let entry_src = read_entry_source(&spec.entry_file); let receiver_expr = rust_receiver_expr(&entry_src, class, 3); + let method_invocation = rust_class_method_invocation(&entry_src, class, method); let body = format!( r#"//! Nyx dynamic harness — class method (Phase 19 / Track M.1). mod entry; @@ -2054,7 +2060,7 @@ fn main() {{ let _ = &payload; __nyx_install_crash_guard("{entry_label}"); let instance = {receiver_expr}; - let _ = instance.{method}(&payload); + {method_invocation} println!("__NYX_SINK_HIT__"); }} @@ -2100,9 +2106,9 @@ fn b64_decode(input: &[u8]) -> Option> {{ Some(out) }} "#, - method = method, entry_label = entry_label, receiver_expr = receiver_expr, + method_invocation = method_invocation, ); HarnessSource { source: body, @@ -2147,6 +2153,76 @@ fn class_has_new(entry_src: &str, class: &str) -> bool { } } +fn rust_class_method_invocation(entry_src: &str, class: &str, method: &str) -> String { + if rust_method_returns_printable_stdout(entry_src, class, method) { + format!( + "let __nyx_result = instance.{method}(&payload);\n print!(\"{{}}\", __nyx_result);" + ) + } else { + format!("let _ = instance.{method}(&payload);") + } +} + +fn rust_method_returns_printable_stdout(entry_src: &str, class: &str, method: &str) -> bool { + let impl_marker = format!("impl {class}"); + let mut search_from = 0usize; + while let Some(rel) = entry_src[search_from..].find(&impl_marker) { + let impl_start = search_from + rel; + let after_class = impl_start + impl_marker.len(); + if entry_src[after_class..] + .chars() + .next() + .is_some_and(is_ident_char) + { + search_from = after_class; + continue; + } + let Some(open_rel) = entry_src[after_class..].find('{') else { + return false; + }; + let block_start = after_class + open_rel; + let Some(block) = balanced_block(&entry_src[block_start..]) else { + return false; + }; + if method_body_returns_printable_stdout(&block[1..block.len() - 1], method) { + return true; + } + search_from = block_start + block.len(); + } + false +} + +fn method_body_returns_printable_stdout(impl_body: &str, method: &str) -> bool { + let method_marker = format!("fn {method}"); + let mut search_from = 0usize; + while let Some(rel) = impl_body[search_from..].find(&method_marker) { + let method_start = search_from + rel; + let after_name = method_start + method_marker.len(); + if impl_body[after_name..] + .chars() + .next() + .is_some_and(is_ident_char) + { + search_from = after_name; + continue; + } + let Some(body_open_rel) = impl_body[after_name..].find('{') else { + return false; + }; + let sig = &impl_body[after_name..after_name + body_open_rel]; + if let Some(ret) = sig.split("->").nth(1) { + let ret = ret.trim(); + return ret.starts_with("String") + || ret.starts_with("std::string::String") + || ret.starts_with("&str") + || ret.starts_with("&'static str") + || ret.starts_with("& 'static str"); + } + search_from = after_name; + } + false +} + fn rust_struct_literal(entry_src: &str, class: &str, depth: usize) -> Option { if depth == 0 { return None; @@ -2456,6 +2532,10 @@ fn word_in_text(text: &str, kw: &str) -> bool { false } +fn is_ident_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' +} + /// Generate `Cargo.toml` for the harness crate. /// /// Dependencies are driven by `expected_cap`: @@ -2470,6 +2550,27 @@ pub fn generate_cargo_toml(cap: Cap) -> String { generate_cargo_toml_with_extras(cap, false) } +fn generate_cargo_toml_for_shape(cap: Cap, shape: RustShape) -> String { + let mut cargo = generate_cargo_toml_with_extras(cap, false); + let deps = match shape { + RustShape::AxumRoute => Some( + "axum = \"0.7\"\nserde = { version = \"1\", features = [\"derive\"] }\ntokio = { version = \"1\", features = [\"full\"] }\ntower = { version = \"0.5\", features = [\"util\"] }\n", + ), + RustShape::ActixRoute => { + Some("actix-web = \"4\"\nserde = { version = \"1\", features = [\"derive\"] }\n") + } + RustShape::RocketRoute => Some("rocket = \"0.5\"\n"), + RustShape::WarpRoute => Some( + "serde = { version = \"1\", features = [\"derive\"] }\ntokio = { version = \"1\", features = [\"full\"] }\nwarp = \"0.3\"\n", + ), + _ => None, + }; + if let Some(deps) = deps { + cargo.push_str(deps); + } + cargo +} + /// Variant of [`generate_cargo_toml`] that conditionally pulls in /// `percent-encoding` for the HEADER_INJECTION benign control fixture /// (it routes the value through `utf8_percent_encode` to land CRLF as @@ -2548,6 +2649,44 @@ fn nyx_payload() -> String {{ String::new() }} +fn nyx_route_uri(path: &str, query: Option<&str>, payload: &str) -> String {{ + let mut uri = if path.starts_with('/') {{ + path.to_owned() + }} else {{ + format!("/{{path}}") + }}; + if let Some(name) = query {{ + uri.push('?'); + uri.push_str(name); + uri.push('='); + uri.push_str(&nyx_url_encode(payload)); + }} + uri +}} + +fn nyx_route_payload(payload: &str) -> String {{ + let trimmed = payload.trim_start(); + if trimmed.starts_with(';') || trimmed.starts_with("&&") || trimmed.starts_with("||") {{ + format!("true {{payload}}") + }} else {{ + payload.to_owned() + }} +}} + +fn nyx_url_encode(input: &str) -> String {{ + let mut out = String::with_capacity(input.len()); + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for b in input.bytes() {{ + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {{ + out.push(b as char); + }} else {{ + out.push('%'); + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + }} + }} + out +}} /// Minimal base64 decoder (no external deps). fn b64_decode(input: &[u8]) -> Option> {{ const TABLE: [u8; 128] = {{ @@ -2604,25 +2743,119 @@ fn build_call(spec: &HarnessSpec, func: &str, shape: RustShape) -> (String, Stri } RustShape::ActixWebRoute => actix_invocation(spec, func), RustShape::AxumHandler => axum_invocation(spec, func), - // Phase 17 framework dispatchers. Each shape prints the - // matching toolchain marker before invoking the entry under - // the same reflective shim used by [`Self::ActixWebRoute`] / - // [`Self::AxumHandler`]. Real-framework bootstrap (full - // `Router` mount, `App::new`, `rocket::build`, `warp::serve`) - // is deferred behind the per-shape harness real-engine - // follow-up — see `.pitboss/play/deferred.md`. - RustShape::ActixRoute => framework_route_invocation(spec, func, "NYX_ACTIX_TEST=1"), - RustShape::AxumRoute => framework_route_invocation(spec, func, "NYX_AXUM_TEST=1"), - RustShape::RocketRoute => framework_route_invocation(spec, func, "NYX_ROCKET_TEST=1"), - RustShape::WarpRoute => framework_route_invocation(spec, func, "NYX_WARP_TEST=1"), + RustShape::ActixRoute => actix_route_invocation(spec, func), + RustShape::AxumRoute => axum_route_invocation(spec, func), + RustShape::RocketRoute => rocket_route_invocation(spec, func), + RustShape::WarpRoute => warp_route_invocation(spec, func), RustShape::ClapCli => clap_invocation(spec, func), } } -fn framework_route_invocation(spec: &HarnessSpec, func: &str, marker: &str) -> (String, String) { - let pre = format!(" println!(\"{marker}\");\n"); - let (inner_pre, call) = actix_invocation(spec, func); - (format!("{pre}{inner_pre}"), call) +fn framework_route_uri_expr(spec: &HarnessSpec) -> String { + let path = spec + .framework + .as_ref() + .and_then(|binding| binding.route.as_ref()) + .map(|route| route.path.as_str()) + .unwrap_or("/run"); + match &spec.payload_slot { + PayloadSlot::QueryParam(name) => { + let payload_expr = if spec.expected_cap.contains(Cap::CODE_EXEC) { + "&nyx_route_payload(&payload)" + } else { + "&payload" + }; + format!( + "nyx_route_uri({}, Some({}), {})", + rust_string_literal(path), + rust_string_literal(name), + payload_expr + ) + } + _ => format!( + "nyx_route_uri({}, None, &payload)", + rust_string_literal(path) + ), + } +} + +fn axum_route_invocation(spec: &HarnessSpec, _func: &str) -> (String, String) { + let uri = framework_route_uri_expr(spec); + ( + " println!(\"NYX_AXUM_TEST=1\");\n".to_owned(), + format!( + r#"let __nyx_rt = tokio::runtime::Builder::new_current_thread().enable_all().build().expect("tokio runtime"); + __nyx_rt.block_on(async {{ + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + let app = entry::build(); + let uri = {uri}; + let request = Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty()) + .expect("axum request"); + let _ = app.oneshot(request).await; + }}); + println!("__NYX_SINK_HIT__");"# + ), + ) +} + +fn actix_route_invocation(spec: &HarnessSpec, func: &str) -> (String, String) { + let uri = framework_route_uri_expr(spec); + ( + " println!(\"NYX_ACTIX_TEST=1\");\n".to_owned(), + format!( + r#"actix_web::rt::System::new().block_on(async {{ + let app = actix_web::test::init_service(actix_web::App::new().service(entry::{func})).await; + let uri = {uri}; + let req = actix_web::test::TestRequest::get().uri(&uri).to_request(); + let _ = actix_web::test::call_service(&app, req).await; + }}); + println!("__NYX_SINK_HIT__");"# + ), + ) +} + +fn rocket_route_invocation(spec: &HarnessSpec, func: &str) -> (String, String) { + let uri = framework_route_uri_expr(spec); + ( + " println!(\"NYX_ROCKET_TEST=1\");\n".to_owned(), + format!( + r#"let __nyx_rt = rocket::tokio::runtime::Builder::new_current_thread().enable_all().build().expect("rocket runtime"); + __nyx_rt.block_on(async {{ + let rocket = rocket::build().mount("/", rocket::routes![entry::{func}]); + let client = rocket::local::asynchronous::Client::tracked(rocket) + .await + .expect("rocket client"); + let uri = {uri}; + let _ = client.get(uri).dispatch().await; + }}); + println!("__NYX_SINK_HIT__");"# + ), + ) +} + +fn warp_route_invocation(spec: &HarnessSpec, _func: &str) -> (String, String) { + let uri = framework_route_uri_expr(spec); + ( + " println!(\"NYX_WARP_TEST=1\");\n".to_owned(), + format!( + r#"let __nyx_rt = tokio::runtime::Builder::new_current_thread().enable_all().build().expect("tokio runtime"); + __nyx_rt.block_on(async {{ + let filter = entry::build(); + let uri = {uri}; + let _ = warp::test::request() + .method("GET") + .path(&uri) + .reply(&filter) + .await; + }}); + println!("__NYX_SINK_HIT__");"# + ), + ) } fn actix_invocation(spec: &HarnessSpec, func: &str) -> (String, String) { @@ -2787,6 +3020,35 @@ mod tests { assert!(!class_derives_default(src, "UserService")); } + #[test] + fn class_method_string_return_is_printed_for_oracle_visibility() { + let src = r#" + pub struct UserService; + impl UserService { + pub fn run(&self, input: &str) -> String { + input.to_owned() + } + } + "#; + let invocation = rust_class_method_invocation(src, "UserService", "run"); + assert!(invocation.contains("let __nyx_result = instance.run(&payload);")); + assert!(invocation.contains("print!(\"{}\", __nyx_result);")); + } + + #[test] + fn class_method_void_return_keeps_direct_invocation() { + let src = r#" + pub struct UserService; + impl UserService { + pub fn run(&self, input: &str) { + let _ = input; + } + } + "#; + let invocation = rust_class_method_invocation(src, "UserService", "run"); + assert_eq!(invocation, "let _ = instance.run(&payload);"); + } + #[test] fn emit_env_var_slot() { let spec = make_spec(PayloadSlot::EnvVar("NYX_INPUT".into())); @@ -2838,14 +3100,19 @@ mod tests { #[test] fn shape_detect_axum_handler() { - // Phase 17 — Track L.15: a strong `use axum::` import now - // routes to the framework-aware [`RustShape::AxumRoute`] - // shape; the legacy [`RustShape::AxumHandler`] fires only on - // weak detectors (`IntoResponse` / `Json(` without `use - // axum::`). + // Importing an extractor is a handler hint, not proof that the + // fixture exports an app builder. Native Axum request replay + // requires a router shape. let src = "use axum::extract::Query; pub fn handler(payload: &str) -> String { String::new() }"; let spec = make_spec_with(EntryKind::HttpRoute, "handler", "src/entry.rs"); + assert_eq!(RustShape::detect(&spec, src), RustShape::AxumHandler); + } + + #[test] + fn shape_detect_axum_router() { + let src = "use axum::Router; use axum::routing::get; pub async fn run() -> String { String::new() } pub fn build() -> Router { Router::new().route(\"/run\", get(run)) }"; + let spec = make_spec_with(EntryKind::HttpRoute, "run", "src/entry.rs"); assert_eq!(RustShape::detect(&spec, src), RustShape::AxumRoute); } @@ -2953,6 +3220,7 @@ mod tests { src.contains("NYX_AXUM_TEST=1"), "AxumRoute must print NYX_AXUM_TEST=1 marker, got: {src}", ); + assert!(src.contains("println!(\"__NYX_SINK_HIT__\");")); } #[test] @@ -2960,6 +3228,19 @@ mod tests { let spec = make_spec_with(EntryKind::HttpRoute, "run", "src/entry.rs"); let src = generate_main_rs(&spec, RustShape::ActixRoute); assert!(src.contains("NYX_ACTIX_TEST=1")); + assert!(src.contains("println!(\"__NYX_SINK_HIT__\");")); + } + + #[test] + fn code_exec_query_routes_through_shell_safe_payload_helper() { + let mut spec = make_spec_with(EntryKind::HttpRoute, "run", "src/entry.rs"); + spec.expected_cap = Cap::CODE_EXEC; + spec.payload_slot = PayloadSlot::QueryParam("cmd".to_owned()); + let src = generate_main_rs(&spec, RustShape::AxumRoute); + assert!( + src.contains("nyx_route_uri(\"/run\", Some(\"cmd\"), &nyx_route_payload(&payload))") + ); + assert!(src.contains("fn nyx_route_payload(payload: &str) -> String")); } #[test] @@ -2967,6 +3248,7 @@ mod tests { let spec = make_spec_with(EntryKind::HttpRoute, "run", "src/entry.rs"); let src = generate_main_rs(&spec, RustShape::RocketRoute); assert!(src.contains("NYX_ROCKET_TEST=1")); + assert!(src.contains("println!(\"__NYX_SINK_HIT__\");")); } #[test] @@ -2974,6 +3256,7 @@ mod tests { let spec = make_spec_with(EntryKind::HttpRoute, "run", "src/entry.rs"); let src = generate_main_rs(&spec, RustShape::WarpRoute); assert!(src.contains("NYX_WARP_TEST=1")); + assert!(src.contains("println!(\"__NYX_SINK_HIT__\");")); } #[test] diff --git a/tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php b/tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php index 38aaab6c..e7448797 100644 --- a/tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php +++ b/tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php @@ -1,44 +1,24 @@ get('run/(:any)', 'App\\Controllers\\UserController::run'); } -class NyxRoutes extends RouteCollection +class UserController extends Controller { - public function get(string $path, string $callable) - { - $GLOBALS['__nyx_route'] = function (string $payload) use ($callable) { - [$class, $method] = explode('::', $callable, 2); - $controller = new $class(); - return $controller->$method($payload); - }; - return $this; - } -} - -$routes = new NyxRoutes(); -$routes->get('run', 'UserController::run'); - -class UserController extends BaseController -{ - public function run($payload) + public function run(string $payload): string { echo "__NYX_SINK_HIT__\n"; $cmd = "true " . escapeshellarg($payload); - $out = shell_exec($cmd); + $out = shell_exec($cmd) ?? ''; echo $out; return $out; } } -} diff --git a/tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php b/tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php index a890f28b..8b881bdd 100644 --- a/tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php +++ b/tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php @@ -1,46 +1,24 @@ get('run', 'UserController::run')` references the -// controller method whose body shells out without sanitisation. +// CodeIgniter-style route, vulnerable. -namespace CodeIgniter\Router { - class RouteCollection - { - } -} +namespace App\Controllers; -namespace { +use CodeIgniter\Controller; use CodeIgniter\Router\RouteCollection; -class BaseController +function nyx_register_routes(RouteCollection $routes): void { + $routes->get('run/(:any)', 'App\\Controllers\\UserController::run'); } -class NyxRoutes extends RouteCollection +class UserController extends Controller { - public function get(string $path, string $callable) - { - $GLOBALS['__nyx_route'] = function (string $payload) use ($callable) { - [$class, $method] = explode('::', $callable, 2); - $controller = new $class(); - return $controller->$method($payload); - }; - return $this; - } -} - -$routes = new NyxRoutes(); -$routes->get('run', 'UserController::run'); - -class UserController extends BaseController -{ - public function run($payload) + public function run(string $payload): string { echo "__NYX_SINK_HIT__\n"; $cmd = "echo hello " . $payload; - $out = shell_exec($cmd); + $out = shell_exec($cmd) ?? ''; echo $out; return $out; } } -} diff --git a/tests/dynamic_fixtures/php_frameworks/laravel/benign.php b/tests/dynamic_fixtures/php_frameworks/laravel/benign.php index e7cffb72..b659e474 100644 --- a/tests/dynamic_fixtures/php_frameworks/laravel/benign.php +++ b/tests/dynamic_fixtures/php_frameworks/laravel/benign.php @@ -1,40 +1,23 @@ $method($payload); - }; - return new class { - public function middleware($value) - { - return $this; - } - }; - } - } +namespace App\Http\Controllers; + +use Illuminate\Routing\Router; + +function nyx_register_routes(Router $router): void +{ + $router->get('/run/{payload}', [UserController::class, 'run']); } -namespace { -use Illuminate\Support\Facades\Route; - -Route::get('/run', 'UserController@run'); - class UserController { - public function run($payload) + public function run(string $payload): string { echo "__NYX_SINK_HIT__\n"; $cmd = "true " . escapeshellarg($payload); - $out = shell_exec($cmd); + $out = shell_exec($cmd) ?? ''; echo $out; return $out; } } -} diff --git a/tests/dynamic_fixtures/php_frameworks/laravel/vuln.php b/tests/dynamic_fixtures/php_frameworks/laravel/vuln.php index 717dd93c..74142dfa 100644 --- a/tests/dynamic_fixtures/php_frameworks/laravel/vuln.php +++ b/tests/dynamic_fixtures/php_frameworks/laravel/vuln.php @@ -1,42 +1,23 @@ $method($payload); - }; - return new class { - public function middleware($value) - { - return $this; - } - }; - } - } +namespace App\Http\Controllers; + +use Illuminate\Routing\Router; + +function nyx_register_routes(Router $router): void +{ + $router->get('/run/{payload}', [UserController::class, 'run']); } -namespace { -use Illuminate\Support\Facades\Route; - -Route::get('/run', 'UserController@run'); - class UserController { - public function run($payload) + public function run(string $payload): string { echo "__NYX_SINK_HIT__\n"; $cmd = "echo hello " . $payload; - $out = shell_exec($cmd); + $out = shell_exec($cmd) ?? ''; echo $out; return $out; } } -} diff --git a/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/benign.php b/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/benign.php index e5b5976a..d6e4bcfd 100644 --- a/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/benign.php +++ b/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/benign.php @@ -2,53 +2,27 @@ // Same route shape as vuln.php, but quotes the payload before invoking // the shell so the command-injection marker remains inert. -namespace Illuminate\Support\Facades { - class Route - { - public static function match(array $methods, string $path, array $callable) - { - \NyxLaravelMultiVerb\register_route($methods, $callable); - return new class { - public function middleware($value) - { - return $this; - } - }; - } - } +namespace App\Http\Controllers; + +use Illuminate\Routing\Router; + +function nyx_register_routes(Router $router): void +{ + $router->match(['GET', 'POST'], '/run/{payload}', [UserController::class, 'run']); } -namespace NyxLaravelMultiVerb { - use Illuminate\Support\Facades\Route; - - function register_route(array $methods, array $callable): void +class UserController +{ + public function run(string $payload): ?string { - $GLOBALS['__nyx_route'] = function (string $payload) use ($methods, $callable) { - $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET'; - if (!in_array($requestMethod, $methods, true)) { - return null; - } - [$class, $method] = $callable; - $controller = new $class(); - return $controller->$method($payload); - }; - } - - Route::match(['GET', 'POST'], '/run', [UserController::class, 'run']); - - class UserController - { - public function run(string $payload) - { - if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { - echo "__NYX_METHOD_SKIP__\n"; - return null; - } - echo "__NYX_SINK_HIT__\n"; - $cmd = "true " . escapeshellarg($payload); - $out = shell_exec($cmd); - echo $out; - return $out; + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + echo "__NYX_METHOD_SKIP__\n"; + return null; } + echo "__NYX_SINK_HIT__\n"; + $cmd = "true " . escapeshellarg($payload); + $out = shell_exec($cmd) ?? ''; + echo $out; + return $out; } } diff --git a/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/composer.json b/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/composer.json new file mode 100644 index 00000000..c4d00fa9 --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/composer.json @@ -0,0 +1,7 @@ +{ + "name": "nyx/fixture-laravel-multi-verb", + "require": { + "php": ">=8.1", + "laravel/framework": "^11.0" + } +} diff --git a/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/vuln.php b/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/vuln.php index d3cf084f..e42d11c7 100644 --- a/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/vuln.php +++ b/tests/dynamic_fixtures/php_frameworks/laravel_multi_verb/vuln.php @@ -1,55 +1,28 @@ match(['GET', 'POST'], '/run/{payload}', [UserController::class, 'run']); } -namespace NyxLaravelMultiVerb { - use Illuminate\Support\Facades\Route; - - function register_route(array $methods, array $callable): void +class UserController +{ + public function run(string $payload): ?string { - $GLOBALS['__nyx_route'] = function (string $payload) use ($methods, $callable) { - $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET'; - if (!in_array($requestMethod, $methods, true)) { - return null; - } - [$class, $method] = $callable; - $controller = new $class(); - return $controller->$method($payload); - }; - } - - Route::match(['GET', 'POST'], '/run', [UserController::class, 'run']); - - class UserController - { - public function run(string $payload) - { - if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { - echo "__NYX_METHOD_SKIP__\n"; - return null; - } - echo "__NYX_SINK_HIT__\n"; - $cmd = "true " . $payload; - $out = shell_exec($cmd); - echo $out; - return $out; + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + echo "__NYX_METHOD_SKIP__\n"; + return null; } + echo "__NYX_SINK_HIT__\n"; + $cmd = "true " . $payload; + $out = shell_exec($cmd) ?? ''; + echo $out; + return $out; } } diff --git a/tests/dynamic_fixtures/php_frameworks/symfony/benign.php b/tests/dynamic_fixtures/php_frameworks/symfony/benign.php index a788acf8..681606a2 100644 --- a/tests/dynamic_fixtures/php_frameworks/symfony/benign.php +++ b/tests/dynamic_fixtures/php_frameworks/symfony/benign.php @@ -1,48 +1,35 @@ content; - } - } -} - -namespace Symfony\Component\Routing\Annotation { - #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] - class Route - { - public function __construct(...$args) - { - } - } -} - -namespace App\Controller { +namespace App\Controller; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Route as SymfonyRoute; +use Symfony\Component\Routing\RouteCollection; + +function nyx_register_routes(RouteCollection $routes): void +{ + $routes->add('nyx_run', new SymfonyRoute( + '/run/{payload}', + ['_controller' => [new UserController(), 'run']], + [], + [], + '', + [], + ['GET'] + )); +} class UserController { - #[Route('/run', methods: ['GET'])] - public function run($payload) + #[Route('/run/{payload}', methods: ['GET'])] + public function run(string $payload): Response { echo "__NYX_SINK_HIT__\n"; $cmd = "true " . escapeshellarg($payload); - $out = shell_exec($cmd); + $out = shell_exec($cmd) ?? ''; echo $out; return new Response($out); } } - -$GLOBALS['__nyx_controller'] = new UserController(); -} diff --git a/tests/dynamic_fixtures/php_frameworks/symfony/vuln.php b/tests/dynamic_fixtures/php_frameworks/symfony/vuln.php index 62b0d6c5..ff9e0a5f 100644 --- a/tests/dynamic_fixtures/php_frameworks/symfony/vuln.php +++ b/tests/dynamic_fixtures/php_frameworks/symfony/vuln.php @@ -1,48 +1,35 @@ content; - } - } -} - -namespace Symfony\Component\Routing\Annotation { - #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] - class Route - { - public function __construct(...$args) - { - } - } -} - -namespace App\Controller { +namespace App\Controller; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Route as SymfonyRoute; +use Symfony\Component\Routing\RouteCollection; + +function nyx_register_routes(RouteCollection $routes): void +{ + $routes->add('nyx_run', new SymfonyRoute( + '/run/{payload}', + ['_controller' => [new UserController(), 'run']], + [], + [], + '', + [], + ['GET'] + )); +} class UserController { - #[Route('/run', methods: ['GET'])] - public function run($payload) + #[Route('/run/{payload}', methods: ['GET'])] + public function run(string $payload): Response { echo "__NYX_SINK_HIT__\n"; $cmd = "echo hello " . $payload; - $out = shell_exec($cmd); + $out = shell_exec($cmd) ?? ''; echo $out; return new Response($out); } } - -$GLOBALS['__nyx_controller'] = new UserController(); -} diff --git a/tests/php_frameworks_corpus.rs b/tests/php_frameworks_corpus.rs index 15889778..51e05010 100644 --- a/tests/php_frameworks_corpus.rs +++ b/tests/php_frameworks_corpus.rs @@ -48,14 +48,14 @@ fn laravel_vuln_fixture_binds_route() { assert_eq!(binding.adapter, "php-laravel"); assert_eq!(binding.kind, EntryKind::HttpRoute); let route = binding.route.as_ref().expect("route"); - assert_eq!(route.path, "/run"); + assert_eq!(route.path, "/run/{payload}"); assert_eq!(route.method, HttpMethod::GET); let payload = binding .request_params .iter() .find(|p| p.name == "payload") .expect("payload formal"); - assert!(matches!(payload.source, ParamSource::QueryParam(_))); + assert!(matches!(payload.source, ParamSource::PathSegment(_))); } #[test] @@ -68,7 +68,7 @@ fn laravel_benign_fixture_binds_same_route_shape() { .expect("laravel adapter must bind benign fixture"); assert_eq!(binding.adapter, "php-laravel"); let route = binding.route.as_ref().expect("route"); - assert_eq!(route.path, "/run"); + assert_eq!(route.path, "/run/{payload}"); assert_eq!(route.method, HttpMethod::GET); } @@ -82,7 +82,7 @@ fn laravel_multi_verb_fixture_preserves_match_methods() { .expect("laravel adapter must bind multi-verb fixture"); assert_eq!(binding.adapter, "php-laravel"); let route = binding.route.as_ref().expect("route"); - assert_eq!(route.path, "/run"); + assert_eq!(route.path, "/run/{payload}"); assert_eq!(route.method, HttpMethod::GET); assert_eq!( route.reachable_methods(), @@ -101,7 +101,7 @@ fn symfony_vuln_fixture_binds_route_via_attribute() { assert_eq!(binding.adapter, "php-symfony"); assert_eq!(binding.kind, EntryKind::HttpRoute); let route = binding.route.as_ref().expect("route"); - assert_eq!(route.path, "/run"); + assert_eq!(route.path, "/run/{payload}"); assert_eq!(route.method, HttpMethod::GET); } @@ -115,7 +115,7 @@ fn symfony_benign_fixture_binds_same_route_shape() { .expect("symfony adapter must bind benign fixture"); assert_eq!(binding.adapter, "php-symfony"); let route = binding.route.as_ref().expect("route"); - assert_eq!(route.path, "/run"); + assert_eq!(route.path, "/run/{payload}"); } #[test] @@ -153,7 +153,7 @@ fn codeigniter_vuln_fixture_binds_route() { assert_eq!(binding.adapter, "php-codeigniter"); assert_eq!(binding.kind, EntryKind::HttpRoute); let route = binding.route.as_ref().expect("route"); - assert_eq!(route.path, "run"); + assert_eq!(route.path, "run/(:any)"); assert_eq!(route.method, HttpMethod::GET); } @@ -167,7 +167,7 @@ fn codeigniter_benign_fixture_binds_same_route_shape() { .expect("codeigniter adapter must bind benign fixture"); assert_eq!(binding.adapter, "php-codeigniter"); let route = binding.route.as_ref().expect("route"); - assert_eq!(route.path, "run"); + assert_eq!(route.path, "run/(:any)"); } #[test] @@ -270,6 +270,13 @@ mod e2e_phase_16_framework_dispatchers { let tmp = TempDir::new().expect("create tempdir"); let dst = tmp.path().join(file); std::fs::copy(&src, &dst).expect("copy fixture into tempdir"); + for manifest in ["composer.json", "composer.lock"] { + let candidate = src.parent().expect("fixture parent").join(manifest); + if candidate.exists() { + std::fs::copy(&candidate, tmp.path().join(manifest)) + .expect("copy composer manifest into tempdir"); + } + } let entry_file = dst.to_string_lossy().into_owned(); let bytes = std::fs::read(&dst).expect("copied fixture readable"); let tree = parse_php(&bytes); @@ -425,6 +432,13 @@ mod e2e_phase_16_laravel_multi_verb { let tmp = TempDir::new().expect("create tempdir"); let dst = tmp.path().join(file); std::fs::copy(&src, &dst).expect("copy fixture into tempdir"); + for manifest in ["composer.json", "composer.lock"] { + let candidate = src.parent().expect("fixture parent").join(manifest); + if candidate.exists() { + std::fs::copy(&candidate, tmp.path().join(manifest)) + .expect("copy composer manifest into tempdir"); + } + } let entry_file = dst.to_string_lossy().into_owned(); let bytes = std::fs::read(&dst).expect("copied fixture readable"); let tree = parse_php(&bytes);