mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
refactor(dynamic): enhance resolver detection for frameworks, refine SSA receiver validation, and expand test coverage
This commit is contained in:
parent
3027c1afa7
commit
f49211d788
38 changed files with 1198 additions and 137 deletions
|
|
@ -39,6 +39,19 @@ fn extract_resolver(summary: &FuncSummary) -> (String, String) {
|
|||
("Query".to_owned(), summary.name.clone())
|
||||
}
|
||||
|
||||
fn name_is_gqlgen_resolver(name: &str, file_bytes: &[u8]) -> bool {
|
||||
if name.starts_with("Resolve") {
|
||||
return true;
|
||||
}
|
||||
let text = match std::str::from_utf8(file_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
text.contains(&format!("*queryResolver) {name}("))
|
||||
|| text.contains(&format!("*mutationResolver) {name}("))
|
||||
|| text.contains(&format!("*subscriptionResolver) {name}("))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for GraphqlGqlgenAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -56,7 +69,7 @@ impl FrameworkAdapter for GraphqlGqlgenAdapter {
|
|||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_gqlgen);
|
||||
let matches_source = source_imports_gqlgen(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
if matches_source && (name_is_gqlgen_resolver(&summary.name, file_bytes) || matches_call) {
|
||||
let (type_name, field) = extract_resolver(summary);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -100,4 +113,22 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "graphql-gqlgen");
|
||||
assert!(matches!(binding.kind, EntryKind::GraphQLResolver { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_gqlgen_file() {
|
||||
let src: &[u8] = b"package graph\n\
|
||||
import \"github.com/99designs/gqlgen/graphql\"\n\
|
||||
type queryResolver struct{}\n\
|
||||
func NormalizeID(id string) string { return id }\n";
|
||||
let tree = parse_go(src);
|
||||
let summary = FuncSummary {
|
||||
name: "NormalizeID".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
GraphqlGqlgenAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ fn extract_resolver(summary: &FuncSummary) -> (String, String) {
|
|||
("Query".to_owned(), summary.name.clone())
|
||||
}
|
||||
|
||||
fn name_is_graphene_resolver(name: &str) -> bool {
|
||||
name.starts_with("resolve_")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for GraphqlGrapheneAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -59,7 +63,7 @@ impl FrameworkAdapter for GraphqlGrapheneAdapter {
|
|||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_graphene);
|
||||
let matches_source = source_imports_graphene(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
if matches_source && (name_is_graphene_resolver(&summary.name) || matches_call) {
|
||||
let (type_name, field) = extract_resolver(summary);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -104,4 +108,21 @@ mod tests {
|
|||
assert_eq!(field, "user");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_graphene_file() {
|
||||
let src: &[u8] = b"import graphene\n\
|
||||
class Query(graphene.ObjectType):\n user = graphene.String()\n\
|
||||
def normalize_id(raw):\n return str(raw)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_id".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
GraphqlGrapheneAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,39 @@ fn extract_resolver(summary: &FuncSummary) -> (String, String) {
|
|||
("Query".to_owned(), summary.name.clone())
|
||||
}
|
||||
|
||||
fn name_is_juniper_resolver(name: &str, file_bytes: &[u8]) -> bool {
|
||||
if name.starts_with("resolve_") {
|
||||
return true;
|
||||
}
|
||||
let text = match std::str::from_utf8(file_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let needle = format!("fn {name}(");
|
||||
let mut search_from = 0;
|
||||
while let Some(rel_idx) = text[search_from..].find(&needle) {
|
||||
let fn_idx = search_from + rel_idx;
|
||||
let before = &text[..fn_idx];
|
||||
let Some(impl_idx) = before.rfind("impl ") else {
|
||||
search_from = fn_idx + needle.len();
|
||||
continue;
|
||||
};
|
||||
if before[impl_idx..].contains('}') {
|
||||
search_from = fn_idx + needle.len();
|
||||
continue;
|
||||
}
|
||||
let scope_start = before[..impl_idx]
|
||||
.rfind('}')
|
||||
.map(|idx| idx + 1)
|
||||
.unwrap_or(0);
|
||||
if before[scope_start..impl_idx].contains("#[graphql_object") {
|
||||
return true;
|
||||
}
|
||||
search_from = fn_idx + needle.len();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for GraphqlJuniperAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -54,7 +87,7 @@ impl FrameworkAdapter for GraphqlJuniperAdapter {
|
|||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_juniper);
|
||||
let matches_source = source_imports_juniper(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
if matches_source && (name_is_juniper_resolver(&summary.name, file_bytes) || matches_call) {
|
||||
let (type_name, field) = extract_resolver(summary);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -98,4 +131,39 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "graphql-juniper");
|
||||
assert!(matches!(binding.kind, EntryKind::GraphQLResolver { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_juniper_file() {
|
||||
let src: &[u8] = b"use juniper::RootNode;\n\
|
||||
pub fn normalize_id(id: &str) -> String { id.to_string() }\n";
|
||||
let tree = parse_rust(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_id".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
GraphqlJuniperAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_free_helper_next_to_graphql_object_impl() {
|
||||
let src: &[u8] = b"use juniper::graphql_object;\n\
|
||||
pub struct Query;\n\
|
||||
#[graphql_object]\n\
|
||||
impl Query {\n fn user(&self, id: String) -> String { id }\n}\n\
|
||||
pub fn normalize_id(id: &str) -> String { id.to_string() }\n";
|
||||
let tree = parse_rust(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_id".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
GraphqlJuniperAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ fn extract_resolver(summary: &FuncSummary) -> (String, String) {
|
|||
("Node".to_owned(), summary.name.clone())
|
||||
}
|
||||
|
||||
fn name_is_relay_resolver(name: &str) -> bool {
|
||||
name.starts_with("resolve") || name.ends_with("Resolver")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for GraphqlRelayAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -66,7 +70,7 @@ impl FrameworkAdapter for GraphqlRelayAdapter {
|
|||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_relay);
|
||||
let matches_source = source_imports_relay(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
if matches_source && (name_is_relay_resolver(&summary.name) || matches_call) {
|
||||
let (type_name, field) = extract_resolver(summary);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -108,4 +112,20 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "graphql-relay");
|
||||
assert!(matches!(binding.kind, EntryKind::GraphQLResolver { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_relay_file() {
|
||||
let src: &[u8] = b"const { nodeDefinitions } = require('graphql-relay');\n\
|
||||
function normalizeId(id) { return String(id); }\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalizeId".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
GraphqlRelayAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,6 @@ pub struct MiddlewareDjangoAdapter;
|
|||
|
||||
const ADAPTER_NAME: &str = "middleware-django";
|
||||
|
||||
fn callee_is_django_middleware(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"process_request" | "process_response" | "process_view" | "process_exception"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_has_middleware_shape(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"django.utils.deprecation",
|
||||
|
|
@ -38,6 +30,31 @@ fn source_has_middleware_shape(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn name_is_django_middleware_entry(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"__call__" | "process_request" | "process_response" | "process_view" | "process_exception"
|
||||
)
|
||||
}
|
||||
|
||||
fn name_wraps_django_middleware(name: &str, file_bytes: &[u8]) -> bool {
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let text = match std::str::from_utf8(file_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let needle = format!("def {name}(");
|
||||
let Some(start) = text.find(&needle) else {
|
||||
return false;
|
||||
};
|
||||
let rest = &text[start..];
|
||||
let end = rest.find("\ndef ").unwrap_or(rest.len());
|
||||
let body = &rest[..end];
|
||||
body.contains("Middleware(") && body.contains("return ")
|
||||
}
|
||||
|
||||
fn looks_like_settings_module(file_bytes: &[u8]) -> bool {
|
||||
// Heuristic: settings.py declares MIDDLEWARE / INSTALLED_APPS / DATABASES at
|
||||
// module scope. A real middleware module declares none of these (it carries
|
||||
|
|
@ -75,9 +92,11 @@ impl FrameworkAdapter for MiddlewareDjangoAdapter {
|
|||
if looks_like_settings_module(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_django_middleware);
|
||||
let matches_source = source_has_middleware_shape(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
if matches_source
|
||||
&& (name_is_django_middleware_entry(&summary.name)
|
||||
|| name_wraps_django_middleware(&summary.name, file_bytes))
|
||||
{
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Middleware {
|
||||
|
|
@ -136,4 +155,38 @@ mod tests {
|
|||
"settings.py-shaped module must not bind as middleware",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_django_middleware_file() {
|
||||
let src: &[u8] = b"from django.utils.deprecation import MiddlewareMixin\n\
|
||||
class AuditMiddleware(MiddlewareMixin):\n def process_request(self, request):\n pass\n\
|
||||
def normalize_request(request):\n return request\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_request".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MiddlewareDjangoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binds_module_level_factory_wrapper() {
|
||||
let src: &[u8] = b"from django.utils.deprecation import MiddlewareMixin\n\
|
||||
class AuditMiddleware(MiddlewareMixin):\n def __call__(self, request):\n pass\n\
|
||||
def audit(get_response):\n return AuditMiddleware(get_response)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "audit".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MiddlewareDjangoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ impl FrameworkAdapter for MiddlewareLaravelAdapter {
|
|||
let has_shape = source_has_middleware_shape(file_bytes);
|
||||
let name_matches = name_is_middleware_entry(&summary.name);
|
||||
let body_mounts_middleware =
|
||||
super::any_callee_matches(summary, callee_is_laravel_middleware);
|
||||
has_shape && super::any_callee_matches(summary, callee_is_laravel_middleware);
|
||||
let binds = (name_matches && has_shape) || body_mounts_middleware;
|
||||
if !binds {
|
||||
return None;
|
||||
|
|
@ -118,4 +118,25 @@ mod tests {
|
|||
"controller method must not bind as middleware just because the file imports Request",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_bind_with_middleware_call_without_contract_shape() {
|
||||
let src: &[u8] = b"<?php\nclass Bootstrapper {\n public function configure($app) { return $app->withMiddleware([]); }\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let mut summary = FuncSummary {
|
||||
name: "configure".into(),
|
||||
..Default::default()
|
||||
};
|
||||
summary.callees.push(crate::summary::CalleeSite {
|
||||
name: "app.withMiddleware".to_owned(),
|
||||
receiver: Some("app".to_owned()),
|
||||
ordinal: 0,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(
|
||||
MiddlewareLaravelAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,14 +14,6 @@ pub struct MiddlewareSpringAdapter;
|
|||
|
||||
const ADAPTER_NAME: &str = "middleware-spring";
|
||||
|
||||
fn callee_is_spring_middleware(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"preHandle" | "postHandle" | "afterCompletion" | "doFilter" | "addInterceptors"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_spring_middleware(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"HandlerInterceptor",
|
||||
|
|
@ -36,6 +28,13 @@ fn source_imports_spring_middleware(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn name_is_spring_middleware_entry(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"preHandle" | "postHandle" | "afterCompletion" | "doFilter" | "addInterceptors"
|
||||
)
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MiddlewareSpringAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -51,9 +50,9 @@ impl FrameworkAdapter for MiddlewareSpringAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_spring_middleware);
|
||||
let matches_source = source_imports_spring_middleware(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
if source_imports_spring_middleware(file_bytes)
|
||||
&& name_is_spring_middleware_entry(&summary.name)
|
||||
{
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Middleware {
|
||||
|
|
@ -95,4 +94,19 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "middleware-spring");
|
||||
assert!(matches!(binding.kind, EntryKind::Middleware { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_spring_middleware_file() {
|
||||
let src: &[u8] = b"public class AuditInterceptor implements HandlerInterceptor {\n public boolean preHandle(Object req, Object res, Object handler) { return true; }\n public String normalize(String payload) { return payload; }\n}\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MiddlewareSpringAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,20 +12,6 @@ pub struct MigrationDjangoAdapter;
|
|||
|
||||
const ADAPTER_NAME: &str = "migration-django";
|
||||
|
||||
fn callee_is_django_migration(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"CreateModel"
|
||||
| "AddField"
|
||||
| "AlterField"
|
||||
| "DeleteModel"
|
||||
| "RunPython"
|
||||
| "RunSQL"
|
||||
| "migrate"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_django_migration(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"django.db.migrations",
|
||||
|
|
@ -56,6 +42,13 @@ fn extract_version(file_bytes: &[u8]) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
fn name_is_django_migration_entry(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"Migration" | "upgrade" | "downgrade" | "forwards" | "backwards"
|
||||
)
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MigrationDjangoAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -71,9 +64,8 @@ impl FrameworkAdapter for MigrationDjangoAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_django_migration);
|
||||
let matches_source = source_imports_django_migration(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
if matches_source && name_is_django_migration_entry(&summary.name) {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Migration {
|
||||
|
|
@ -116,4 +108,21 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "migration-django");
|
||||
assert!(matches!(binding.kind, EntryKind::Migration { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_django_migration_file() {
|
||||
let src: &[u8] = b"from django.db import migrations\n\
|
||||
class Migration(migrations.Migration):\n operations = [migrations.CreateModel(name='User', fields=[])]\n\
|
||||
def normalize_name(name):\n return str(name)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_name".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MigrationDjangoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,20 +13,6 @@ pub struct MigrationFlaskAdapter;
|
|||
|
||||
const ADAPTER_NAME: &str = "migration-flask";
|
||||
|
||||
fn callee_is_flask_migration(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"upgrade"
|
||||
| "downgrade"
|
||||
| "execute"
|
||||
| "create_table"
|
||||
| "add_column"
|
||||
| "drop_table"
|
||||
| "alter_column"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_flask_migration(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"from alembic",
|
||||
|
|
@ -57,6 +43,10 @@ fn extract_version(file_bytes: &[u8]) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
fn name_is_flask_migration_entry(name: &str) -> bool {
|
||||
matches!(name, "upgrade" | "downgrade")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MigrationFlaskAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -72,9 +62,8 @@ impl FrameworkAdapter for MigrationFlaskAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_flask_migration);
|
||||
let matches_source = source_imports_flask_migration(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
if matches_source && name_is_flask_migration_entry(&summary.name) {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Migration {
|
||||
|
|
@ -119,4 +108,21 @@ mod tests {
|
|||
assert_eq!(version.as_deref(), Some("abc123"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_alembic_file() {
|
||||
let src: &[u8] = b"from alembic import op\nrevision = 'abc123'\n\
|
||||
def upgrade():\n op.create_table('users')\n\
|
||||
def normalize_name(name):\n return str(name)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_name".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MigrationFlaskAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,6 @@ pub struct MigrationSequelizeAdapter;
|
|||
|
||||
const ADAPTER_NAME: &str = "migration-sequelize";
|
||||
|
||||
fn callee_is_sequelize_migration(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"up" | "down" | "createTable" | "addColumn" | "dropTable" | "removeColumn" | "addIndex"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_sequelize_migration(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"require('sequelize')",
|
||||
|
|
@ -37,6 +29,10 @@ fn source_imports_sequelize_migration(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn name_is_sequelize_migration_entry(name: &str) -> bool {
|
||||
matches!(name, "up" | "down")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MigrationSequelizeAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -52,9 +48,8 @@ impl FrameworkAdapter for MigrationSequelizeAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_sequelize_migration);
|
||||
let matches_source = source_imports_sequelize_migration(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
if matches_source && name_is_sequelize_migration_entry(&summary.name) {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Migration { version: None },
|
||||
|
|
@ -94,4 +89,19 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "migration-sequelize");
|
||||
assert!(matches!(binding.kind, EntryKind::Migration { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_sequelize_migration_file() {
|
||||
let src: &[u8] = b"module.exports = {\n async up(queryInterface, Sequelize) { await queryInterface.createTable('users', {}); },\n};\nfunction normalizeName(name) { return String(name); }\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalizeName".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MigrationSequelizeAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct ScheduledCeleryAdapter;
|
||||
|
|
@ -53,6 +54,36 @@ fn extract_schedule(file_bytes: &[u8]) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
fn name_registered_as_celery_task(name: &str, file_bytes: &[u8]) -> bool {
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let text = match std::str::from_utf8(file_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let needle = format!("def {name}(");
|
||||
let Some(def_idx) = text.find(&needle) else {
|
||||
return false;
|
||||
};
|
||||
let before = &text[..def_idx];
|
||||
let since_prev_def = before
|
||||
.rfind("\ndef ")
|
||||
.map(|idx| &before[idx + 1..])
|
||||
.unwrap_or(before);
|
||||
since_prev_def.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.contains("@shared_task")
|
||||
|| trimmed.contains("@app.task")
|
||||
|| trimmed.contains("@celery.task")
|
||||
})
|
||||
}
|
||||
|
||||
fn typed_container_allows_celery(container: &str) -> bool {
|
||||
let lc = container.to_ascii_lowercase();
|
||||
lc.contains("celery") || lc.contains("task") || lc.contains("signature")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for ScheduledCeleryAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -65,31 +96,59 @@ impl FrameworkAdapter for ScheduledCeleryAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_celery);
|
||||
let matches_source = source_imports_celery(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::ScheduledJob {
|
||||
schedule: extract_schedule(file_bytes),
|
||||
},
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
detect_celery(summary, None, ast, file_bytes)
|
||||
}
|
||||
|
||||
fn detect_with_context(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
detect_celery(summary, ssa_summary, ast, file_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_celery(
|
||||
summary: &FuncSummary,
|
||||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
if !source_imports_celery(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let registered = name_registered_as_celery_task(&summary.name, file_bytes);
|
||||
let celery_call = super::any_callee_matches(summary, callee_is_celery)
|
||||
&& super::typed_receiver_facts_allow(
|
||||
summary,
|
||||
ssa_summary,
|
||||
callee_is_celery,
|
||||
typed_container_allows_celery,
|
||||
);
|
||||
if !(registered || celery_call) {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::ScheduledJob {
|
||||
schedule: extract_schedule(file_bytes),
|
||||
},
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::summary::CalleeSite;
|
||||
|
||||
fn parse_python(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
|
|
@ -114,4 +173,46 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "scheduled-celery");
|
||||
assert!(matches!(binding.kind, EntryKind::ScheduledJob { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unregistered_helper_in_celery_file() {
|
||||
let src: &[u8] = b"from celery import shared_task\n\
|
||||
@shared_task\n\
|
||||
def tick(payload):\n print(payload)\n\
|
||||
def format_payload(payload):\n return str(payload)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "format_payload".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
ScheduledCeleryAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssa_receiver_type_rejects_non_celery_delay_collision() {
|
||||
let src: &[u8] = b"from celery import shared_task\n\
|
||||
def enqueue(payload):\n mailer.delay(payload)\n";
|
||||
let tree = parse_python(src);
|
||||
let mut summary = FuncSummary {
|
||||
name: "enqueue".into(),
|
||||
..Default::default()
|
||||
};
|
||||
summary.callees.push(CalleeSite {
|
||||
name: "mailer.delay".to_owned(),
|
||||
receiver: Some("mailer".to_owned()),
|
||||
ordinal: 0,
|
||||
..Default::default()
|
||||
});
|
||||
let mut ssa = SsaFuncSummary::default();
|
||||
ssa.typed_call_receivers.push((0, "Mailer".to_owned()));
|
||||
assert!(
|
||||
ScheduledCeleryAdapter
|
||||
.detect_with_context(&summary, Some(&ssa), tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct ScheduledQuartzAdapter;
|
||||
|
|
@ -53,6 +54,46 @@ fn extract_schedule(file_bytes: &[u8]) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
fn name_is_quartz_entry(name: &str) -> bool {
|
||||
name == "execute"
|
||||
}
|
||||
|
||||
fn name_annotated_as_scheduled(name: &str, file_bytes: &[u8]) -> bool {
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let text = match std::str::from_utf8(file_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
for needle in [
|
||||
format!("void {name}("),
|
||||
format!("public void {name}("),
|
||||
format!("private void {name}("),
|
||||
format!("protected void {name}("),
|
||||
] {
|
||||
if let Some(idx) = text.find(&needle) {
|
||||
let before = &text[..idx];
|
||||
let since_prev_method = before
|
||||
.rfind("\n ")
|
||||
.map(|prev| &before[prev + 1..])
|
||||
.unwrap_or(before);
|
||||
if since_prev_method.contains("@Scheduled") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn typed_container_allows_quartz(container: &str) -> bool {
|
||||
let lc = container.to_ascii_lowercase();
|
||||
lc.contains("quartz")
|
||||
|| lc.contains("scheduler")
|
||||
|| lc.contains("jobbuilder")
|
||||
|| lc.contains("triggerbuilder")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for ScheduledQuartzAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -65,31 +106,60 @@ impl FrameworkAdapter for ScheduledQuartzAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_quartz);
|
||||
let matches_source = source_imports_quartz(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::ScheduledJob {
|
||||
schedule: extract_schedule(file_bytes),
|
||||
},
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
detect_quartz(summary, None, ast, file_bytes)
|
||||
}
|
||||
|
||||
fn detect_with_context(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
detect_quartz(summary, ssa_summary, ast, file_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_quartz(
|
||||
summary: &FuncSummary,
|
||||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
if !source_imports_quartz(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let job_entry = name_is_quartz_entry(&summary.name);
|
||||
let scheduled_method = name_annotated_as_scheduled(&summary.name, file_bytes);
|
||||
let quartz_call = super::any_callee_matches(summary, callee_is_quartz)
|
||||
&& super::typed_receiver_facts_allow(
|
||||
summary,
|
||||
ssa_summary,
|
||||
callee_is_quartz,
|
||||
typed_container_allows_quartz,
|
||||
);
|
||||
if !(job_entry || scheduled_method || quartz_call) {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::ScheduledJob {
|
||||
schedule: extract_schedule(file_bytes),
|
||||
},
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::summary::CalleeSite;
|
||||
|
||||
fn parse_java(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
|
|
@ -132,4 +202,49 @@ mod tests {
|
|||
assert_eq!(schedule.as_deref(), Some("0 0 12 * * ?"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_quartz_file() {
|
||||
let src: &[u8] = b"import org.quartz.Job;\n\
|
||||
public class TickJob implements Job {\n\
|
||||
public void execute(JobExecutionContext ctx) { }\n\
|
||||
public String format(String payload) { return payload; }\n\
|
||||
}\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "format".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
ScheduledQuartzAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssa_receiver_type_rejects_non_quartz_schedule_collision() {
|
||||
let src: &[u8] = b"import org.quartz.Job;\n\
|
||||
public class TickJob implements Job {\n\
|
||||
public void enqueue(Object payload) { queue.scheduleJob(payload); }\n\
|
||||
}\n";
|
||||
let tree = parse_java(src);
|
||||
let mut summary = FuncSummary {
|
||||
name: "enqueue".into(),
|
||||
..Default::default()
|
||||
};
|
||||
summary.callees.push(CalleeSite {
|
||||
name: "queue.scheduleJob".to_owned(),
|
||||
receiver: Some("queue".to_owned()),
|
||||
ordinal: 0,
|
||||
..Default::default()
|
||||
});
|
||||
let mut ssa = SsaFuncSummary::default();
|
||||
ssa.typed_call_receivers.push((0, "MailQueue".to_owned()));
|
||||
assert!(
|
||||
ScheduledQuartzAdapter
|
||||
.detect_with_context(&summary, Some(&ssa), tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,6 @@ pub struct WebsocketActionCableAdapter;
|
|||
|
||||
const ADAPTER_NAME: &str = "websocket-actioncable";
|
||||
|
||||
fn callee_is_actioncable(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"receive" | "subscribed" | "unsubscribed" | "transmit" | "broadcast"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_actioncable(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"ApplicationCable::Channel",
|
||||
|
|
@ -54,6 +46,10 @@ fn extract_path(file_bytes: &[u8]) -> String {
|
|||
"/cable".to_owned()
|
||||
}
|
||||
|
||||
fn name_is_actioncable_entry(name: &str) -> bool {
|
||||
matches!(name, "receive" | "subscribed" | "unsubscribed")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for WebsocketActionCableAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -69,9 +65,7 @@ impl FrameworkAdapter for WebsocketActionCableAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_actioncable);
|
||||
let matches_source = source_imports_actioncable(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
if source_imports_actioncable(file_bytes) && name_is_actioncable_entry(&summary.name) {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::WebSocket {
|
||||
|
|
@ -115,4 +109,19 @@ mod tests {
|
|||
assert_eq!(path, "chat_room");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_actioncable_file() {
|
||||
let src: &[u8] = b"class ChatChannel < ApplicationCable::Channel\n def receive(data)\n end\n def normalize(data)\n data.to_s\n end\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
WebsocketActionCableAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,6 @@ pub struct WebsocketChannelsAdapter;
|
|||
|
||||
const ADAPTER_NAME: &str = "websocket-channels";
|
||||
|
||||
fn callee_is_channels(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"receive" | "receive_json" | "connect" | "disconnect" | "send" | "send_json"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_channels(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"channels.generic.websocket",
|
||||
|
|
@ -49,6 +41,13 @@ fn extract_path(file_bytes: &[u8]) -> String {
|
|||
"/ws/".to_owned()
|
||||
}
|
||||
|
||||
fn name_is_channels_entry(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"receive" | "receive_json" | "connect" | "disconnect" | "websocket_receive"
|
||||
)
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for WebsocketChannelsAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -64,9 +63,7 @@ impl FrameworkAdapter for WebsocketChannelsAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_channels);
|
||||
let matches_source = source_imports_channels(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
if source_imports_channels(file_bytes) && name_is_channels_entry(&summary.name) {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::WebSocket {
|
||||
|
|
@ -109,4 +106,21 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "websocket-channels");
|
||||
assert!(matches!(binding.kind, EntryKind::WebSocket { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_channels_file() {
|
||||
let src: &[u8] = b"from channels.generic.websocket import WebsocketConsumer\n\
|
||||
class ChatConsumer(WebsocketConsumer):\n def receive(self, text_data=None):\n pass\n\
|
||||
def normalize_frame(text_data):\n return str(text_data)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_frame".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
WebsocketChannelsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,6 @@ pub struct WebsocketSocketIoAdapter;
|
|||
|
||||
const ADAPTER_NAME: &str = "websocket-socketio";
|
||||
|
||||
fn callee_is_socketio(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"on" | "emit" | "send" | "AsyncServer" | "Server" | "event"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_socketio(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"import socketio",
|
||||
|
|
@ -49,6 +41,29 @@ fn extract_path(file_bytes: &[u8]) -> String {
|
|||
"/".to_owned()
|
||||
}
|
||||
|
||||
fn name_registered_as_socketio_event(name: &str, file_bytes: &[u8]) -> bool {
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let text = match std::str::from_utf8(file_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let def_needle = format!("def {name}(");
|
||||
let Some(def_idx) = text.find(&def_needle) else {
|
||||
return false;
|
||||
};
|
||||
let before = &text[..def_idx];
|
||||
let since_prev_def = before
|
||||
.rfind("\ndef ")
|
||||
.map(|idx| &before[idx + 1..])
|
||||
.unwrap_or(before);
|
||||
since_prev_def.contains("@sio.event")
|
||||
|| since_prev_def.contains("@socketio.event")
|
||||
|| since_prev_def.contains(&format!("@sio.on('{name}'"))
|
||||
|| since_prev_def.contains(&format!("@sio.on(\"{name}\""))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for WebsocketSocketIoAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -64,9 +79,8 @@ impl FrameworkAdapter for WebsocketSocketIoAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_socketio);
|
||||
let matches_source = source_imports_socketio(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
let registered = name_registered_as_socketio_event(&summary.name, file_bytes);
|
||||
if source_imports_socketio(file_bytes) && registered {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::WebSocket {
|
||||
|
|
@ -113,4 +127,23 @@ mod tests {
|
|||
assert_eq!(path, "message");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_socketio_file() {
|
||||
let src: &[u8] = b"import socketio\n\
|
||||
sio = socketio.Server()\n\
|
||||
@sio.on('message')\n\
|
||||
def message(sid, data):\n pass\n\
|
||||
def normalize(data):\n return str(data)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
WebsocketSocketIoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
tests/fixtures/fp_guards/broker_adapter_collisions/expectations.json
vendored
Normal file
16
tests/fixtures/fp_guards/broker_adapter_collisions/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "taint-unsanitised-flow" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
19
tests/fixtures/fp_guards/broker_adapter_collisions/node_non_sqs_send.js
vendored
Normal file
19
tests/fixtures/fp_guards/broker_adapter_collisions/node_non_sqs_send.js
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const { SQSClient } = require("@aws-sdk/client-sqs");
|
||||
|
||||
class MetricsPublisher {
|
||||
send(event) {
|
||||
return Promise.resolve({ ok: true, event });
|
||||
}
|
||||
}
|
||||
|
||||
const sqs = new SQSClient({});
|
||||
const metrics = new MetricsPublisher();
|
||||
|
||||
function handler(event) {
|
||||
return metrics.send({
|
||||
type: "delivery_attempt",
|
||||
requestId: event.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { handler, sqs };
|
||||
16
tests/fixtures/fp_guards/broker_adapter_collisions/python_non_broker_handler.py
vendored
Normal file
16
tests/fixtures/fp_guards/broker_adapter_collisions/python_non_broker_handler.py
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import boto3
|
||||
|
||||
|
||||
sqs = boto3.client("sqs")
|
||||
|
||||
|
||||
class AuditCache:
|
||||
def process_message(self, envelope):
|
||||
return {"stored": True, "id": envelope.get("id")}
|
||||
|
||||
|
||||
cache = AuditCache()
|
||||
|
||||
|
||||
def handler(envelope):
|
||||
return cache.process_message(envelope)
|
||||
13
tests/fixtures/fp_guards/broker_adapter_collisions/python_non_rabbit_process.py
vendored
Normal file
13
tests/fixtures/fp_guards/broker_adapter_collisions/python_non_rabbit_process.py
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import pika
|
||||
|
||||
|
||||
class ReportWorker:
|
||||
def process(self, report):
|
||||
return {"status": "queued", "report_id": report.get("id")}
|
||||
|
||||
|
||||
worker = ReportWorker()
|
||||
|
||||
|
||||
def process(report):
|
||||
return worker.process(report)
|
||||
16
tests/fixtures/fp_guards/phase21_adapter_collisions/expectations.json
vendored
Normal file
16
tests/fixtures/fp_guards/phase21_adapter_collisions/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "taint-unsanitised-flow" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
14
tests/fixtures/fp_guards/phase21_adapter_collisions/go_gqlgen_helper.go
vendored
Normal file
14
tests/fixtures/fp_guards/phase21_adapter_collisions/go_gqlgen_helper.go
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package graph
|
||||
|
||||
import "context"
|
||||
|
||||
// import "github.com/99designs/gqlgen/graphql"
|
||||
type queryResolver struct{}
|
||||
|
||||
func (r *queryResolver) User(ctx context.Context, id string) (string, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func NormalizeID(id string) string {
|
||||
return id
|
||||
}
|
||||
15
tests/fixtures/fp_guards/phase21_adapter_collisions/java_quartz_queue_schedule.java
vendored
Normal file
15
tests/fixtures/fp_guards/phase21_adapter_collisions/java_quartz_queue_schedule.java
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import org.quartz.Job;
|
||||
import org.quartz.JobExecutionContext;
|
||||
|
||||
class TickJob implements Job {
|
||||
public void execute(JobExecutionContext context) {}
|
||||
|
||||
public void enqueue(Object payload) {
|
||||
NotificationQueue queue = new NotificationQueue();
|
||||
queue.scheduleJob(payload);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationQueue {
|
||||
void scheduleJob(Object payload) {}
|
||||
}
|
||||
11
tests/fixtures/fp_guards/phase21_adapter_collisions/java_spring_middleware_helper.java
vendored
Normal file
11
tests/fixtures/fp_guards/phase21_adapter_collisions/java_spring_middleware_helper.java
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
class AuditInterceptor implements HandlerInterceptor {
|
||||
public boolean preHandle(Object request, Object response, Object handler) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public String normalize(String payload) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
11
tests/fixtures/fp_guards/phase21_adapter_collisions/js_relay_helper.js
vendored
Normal file
11
tests/fixtures/fp_guards/phase21_adapter_collisions/js_relay_helper.js
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const { nodeDefinitions } = require('graphql-relay');
|
||||
|
||||
function resolveNode(globalId) {
|
||||
return globalId;
|
||||
}
|
||||
|
||||
function normalizeId(id) {
|
||||
return String(id);
|
||||
}
|
||||
|
||||
module.exports = { resolveNode, normalizeId, nodeDefinitions };
|
||||
15
tests/fixtures/fp_guards/phase21_adapter_collisions/js_sequelize_helper.js
vendored
Normal file
15
tests/fixtures/fp_guards/phase21_adapter_collisions/js_sequelize_helper.js
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('users', {});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('users');
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeName(name) {
|
||||
return String(name);
|
||||
}
|
||||
|
||||
module.exports.normalizeName = normalizeName;
|
||||
9
tests/fixtures/fp_guards/phase21_adapter_collisions/php_laravel_bootstrapper.php
vendored
Normal file
9
tests/fixtures/fp_guards/phase21_adapter_collisions/php_laravel_bootstrapper.php
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
class Bootstrapper
|
||||
{
|
||||
public function configure($app)
|
||||
{
|
||||
return $app->withMiddleware([]);
|
||||
}
|
||||
}
|
||||
11
tests/fixtures/fp_guards/phase21_adapter_collisions/python_alembic_helper.py
vendored
Normal file
11
tests/fixtures/fp_guards/phase21_adapter_collisions/python_alembic_helper.py
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from alembic import op
|
||||
|
||||
revision = "abc123def4"
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table("users")
|
||||
|
||||
|
||||
def normalize_name(name):
|
||||
return str(name)
|
||||
16
tests/fixtures/fp_guards/phase21_adapter_collisions/python_celery_mailer_delay.py
vendored
Normal file
16
tests/fixtures/fp_guards/phase21_adapter_collisions/python_celery_mailer_delay.py
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from celery import shared_task
|
||||
|
||||
|
||||
@shared_task
|
||||
def tick(payload):
|
||||
return payload
|
||||
|
||||
|
||||
class Mailer:
|
||||
def delay(self, payload):
|
||||
return payload
|
||||
|
||||
|
||||
def enqueue(payload):
|
||||
mailer = Mailer()
|
||||
return mailer.delay(payload)
|
||||
10
tests/fixtures/fp_guards/phase21_adapter_collisions/python_channels_helper.py
vendored
Normal file
10
tests/fixtures/fp_guards/phase21_adapter_collisions/python_channels_helper.py
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from channels.generic.websocket import WebsocketConsumer
|
||||
|
||||
|
||||
class ChatConsumer(WebsocketConsumer):
|
||||
def receive(self, text_data=None, bytes_data=None):
|
||||
return text_data
|
||||
|
||||
|
||||
def normalize_frame(text_data):
|
||||
return str(text_data)
|
||||
10
tests/fixtures/fp_guards/phase21_adapter_collisions/python_django_middleware_helper.py
vendored
Normal file
10
tests/fixtures/fp_guards/phase21_adapter_collisions/python_django_middleware_helper.py
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
|
||||
class AuditMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
return None
|
||||
|
||||
|
||||
def normalize_request(request):
|
||||
return request
|
||||
11
tests/fixtures/fp_guards/phase21_adapter_collisions/python_django_migration_helper.py
vendored
Normal file
11
tests/fixtures/fp_guards/phase21_adapter_collisions/python_django_migration_helper.py
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.CreateModel(name="User", fields=[]),
|
||||
]
|
||||
|
||||
|
||||
def normalize_name(name):
|
||||
return str(name)
|
||||
12
tests/fixtures/fp_guards/phase21_adapter_collisions/python_graphene_helper.py
vendored
Normal file
12
tests/fixtures/fp_guards/phase21_adapter_collisions/python_graphene_helper.py
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import graphene
|
||||
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
user = graphene.String()
|
||||
|
||||
def resolve_user(self, info, id):
|
||||
return id
|
||||
|
||||
|
||||
def normalize_id(raw):
|
||||
return str(raw)
|
||||
12
tests/fixtures/fp_guards/phase21_adapter_collisions/python_socketio_helper.py
vendored
Normal file
12
tests/fixtures/fp_guards/phase21_adapter_collisions/python_socketio_helper.py
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import socketio
|
||||
|
||||
sio = socketio.Server()
|
||||
|
||||
|
||||
@sio.on("message")
|
||||
def message(sid, data):
|
||||
return data
|
||||
|
||||
|
||||
def normalize(data):
|
||||
return str(data)
|
||||
13
tests/fixtures/fp_guards/phase21_adapter_collisions/ruby_actioncable_helper.rb
vendored
Normal file
13
tests/fixtures/fp_guards/phase21_adapter_collisions/ruby_actioncable_helper.rb
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
class ChatChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
stream_from "chat_room"
|
||||
end
|
||||
|
||||
def receive(data)
|
||||
data
|
||||
end
|
||||
|
||||
def normalize(data)
|
||||
data.to_s
|
||||
end
|
||||
end
|
||||
14
tests/fixtures/fp_guards/phase21_adapter_collisions/rust_juniper_helper.rs
vendored
Normal file
14
tests/fixtures/fp_guards/phase21_adapter_collisions/rust_juniper_helper.rs
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
use juniper::graphql_object;
|
||||
|
||||
pub struct Query;
|
||||
|
||||
#[graphql_object]
|
||||
impl Query {
|
||||
fn user(&self, id: String) -> String {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_id(id: &str) -> String {
|
||||
id.to_string()
|
||||
}
|
||||
|
|
@ -926,6 +926,27 @@ fn fp_guard_framework_express_res_json() {
|
|||
validate_expectations(&diags, &dir);
|
||||
}
|
||||
|
||||
/// FP guard, broker-adapter receiver collisions: OSS-shaped handlers named
|
||||
/// `handler` / `process` and a non-SQS `.send(...)` publisher must stay
|
||||
/// ordinary helper code unless receiver facts prove the call is on a broker
|
||||
/// runtime object.
|
||||
#[test]
|
||||
fn fp_guard_broker_adapter_receiver_collisions() {
|
||||
let dir = fixture_path("fp_guards/broker_adapter_collisions");
|
||||
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
||||
validate_expectations(&diags, &dir);
|
||||
}
|
||||
|
||||
/// FP guard, Phase 21 adapter collisions: framework-marked files can contain
|
||||
/// ordinary helpers, controller bootstrappers, mailer queues, and migration
|
||||
/// formatting functions that must not be promoted to dynamic entry kinds.
|
||||
#[test]
|
||||
fn fp_guard_phase21_adapter_collisions() {
|
||||
let dir = fixture_path("fp_guards/phase21_adapter_collisions");
|
||||
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
||||
validate_expectations(&diags, &dir);
|
||||
}
|
||||
|
||||
/// FP guard, FastAPI `dependencies=[Depends(requires_access_*)]`
|
||||
/// route-level guard short-circuits `auth_check_covers_subject` so
|
||||
/// the handler body's path-param ORM calls and row-variable method
|
||||
|
|
|
|||
|
|
@ -17,11 +17,15 @@
|
|||
mod common;
|
||||
|
||||
use nyx_scanner::dynamic::framework::registry::adapters_for;
|
||||
use nyx_scanner::dynamic::framework::{FrameworkBinding, detect_binding};
|
||||
use nyx_scanner::dynamic::framework::{
|
||||
FrameworkBinding, detect_binding, detect_binding_with_context,
|
||||
};
|
||||
use nyx_scanner::dynamic::lang;
|
||||
use nyx_scanner::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot};
|
||||
use nyx_scanner::labels::Cap;
|
||||
use nyx_scanner::summary::CalleeSite;
|
||||
use nyx_scanner::summary::FuncSummary;
|
||||
use nyx_scanner::summary::ssa_summary::SsaFuncSummary;
|
||||
use nyx_scanner::symbol::Lang;
|
||||
|
||||
const SUPPORTED_LANGS: &[Lang] = &[
|
||||
|
|
@ -215,6 +219,39 @@ fn detect_from_bytes(lang: Lang, bytes: &[u8], handler: &str) -> Option<Framewor
|
|||
detect_binding(&summary, tree.root_node(), bytes, lang)
|
||||
}
|
||||
|
||||
fn detect_collision_fixture_with_receiver(
|
||||
lang: Lang,
|
||||
fixture: &str,
|
||||
handler: &str,
|
||||
callee: &str,
|
||||
receiver: &str,
|
||||
receiver_ty: &str,
|
||||
) -> Option<FrameworkBinding> {
|
||||
let bytes = std::fs::read(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/fp_guards/broker_adapter_collisions")
|
||||
.join(fixture),
|
||||
)
|
||||
.expect("collision fixture exists");
|
||||
let ts_lang = ts_language_for(lang);
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
parser.set_language(&ts_lang).unwrap();
|
||||
let tree = parser.parse(&bytes, None).unwrap();
|
||||
let mut summary = FuncSummary {
|
||||
name: handler.into(),
|
||||
..Default::default()
|
||||
};
|
||||
summary.callees.push(CalleeSite {
|
||||
name: callee.to_owned(),
|
||||
receiver: Some(receiver.to_owned()),
|
||||
ordinal: 0,
|
||||
..Default::default()
|
||||
});
|
||||
let mut ssa = SsaFuncSummary::default();
|
||||
ssa.typed_call_receivers.push((0, receiver_ty.to_owned()));
|
||||
detect_binding_with_context(&summary, Some(&ssa), tree.root_node(), &bytes, lang)
|
||||
}
|
||||
|
||||
fn middleware_names(binding: &FrameworkBinding) -> Vec<String> {
|
||||
binding
|
||||
.middleware
|
||||
|
|
@ -417,6 +454,51 @@ def on_message(ch, method, properties, body):\n validate_request(body)\n",
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase20_broker_adapter_receiver_collisions_have_fixture_anchors() {
|
||||
let cases: &[(Lang, &str, &str, &str, &str, &str)] = &[
|
||||
(
|
||||
Lang::Python,
|
||||
"python_non_broker_handler.py",
|
||||
"handler",
|
||||
"cache.process_message",
|
||||
"cache",
|
||||
"AuditCache",
|
||||
),
|
||||
(
|
||||
Lang::Python,
|
||||
"python_non_rabbit_process.py",
|
||||
"process",
|
||||
"worker.process",
|
||||
"worker",
|
||||
"ReportWorker",
|
||||
),
|
||||
(
|
||||
Lang::JavaScript,
|
||||
"node_non_sqs_send.js",
|
||||
"handler",
|
||||
"metrics.send",
|
||||
"metrics",
|
||||
"MetricsPublisher",
|
||||
),
|
||||
];
|
||||
|
||||
for (lang, fixture, handler, callee, receiver, receiver_ty) in cases {
|
||||
let binding = detect_collision_fixture_with_receiver(
|
||||
*lang,
|
||||
fixture,
|
||||
handler,
|
||||
callee,
|
||||
receiver,
|
||||
receiver_ty,
|
||||
);
|
||||
assert!(
|
||||
binding.is_none(),
|
||||
"{fixture} should not bind as a broker message handler; got {binding:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_slices_include_phase_20_adapters() {
|
||||
let java_names: Vec<&'static str> = adapters_for(Lang::Java).iter().map(|a| a.name()).collect();
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ use nyx_scanner::dynamic::lang;
|
|||
use nyx_scanner::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot};
|
||||
use nyx_scanner::evidence::EntryKind as EvEntryKind;
|
||||
use nyx_scanner::labels::Cap;
|
||||
use nyx_scanner::summary::FuncSummary;
|
||||
use nyx_scanner::summary::ssa_summary::SsaFuncSummary;
|
||||
use nyx_scanner::summary::{CalleeSite, FuncSummary};
|
||||
use nyx_scanner::symbol::Lang;
|
||||
|
||||
fn make_spec(lang: Lang, kind: EvEntryKind, entry_name: &str, entry_file: &str) -> HarnessSpec {
|
||||
|
|
@ -91,6 +92,46 @@ fn run_adapter(
|
|||
.unwrap_or_else(|| panic!("{} did not fire on {fixture}", adapter.name()))
|
||||
}
|
||||
|
||||
fn detect_phase21_fp_fixture(
|
||||
adapter: &dyn FrameworkAdapter,
|
||||
lang: Lang,
|
||||
handler: &str,
|
||||
fixture: &str,
|
||||
typed_call: Option<(&str, &str, &str)>,
|
||||
) -> Option<FrameworkBinding> {
|
||||
let bytes = std::fs::read(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/fp_guards/phase21_adapter_collisions")
|
||||
.join(fixture),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("read Phase 21 FP fixture {fixture}: {e}"));
|
||||
let tree = parse(lang, &bytes);
|
||||
let mut summary = FuncSummary {
|
||||
name: handler.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut ssa = SsaFuncSummary::default();
|
||||
if let Some((callee, receiver, receiver_ty)) = typed_call {
|
||||
summary.callees.push(CalleeSite {
|
||||
name: callee.to_owned(),
|
||||
receiver: Some(receiver.to_owned()),
|
||||
ordinal: 0,
|
||||
..Default::default()
|
||||
});
|
||||
ssa.typed_call_receivers.push((0, receiver_ty.to_owned()));
|
||||
}
|
||||
let ssa_ref = typed_call.is_some().then_some(&ssa);
|
||||
adapter.detect_with_context(&summary, ssa_ref, tree.root_node(), &bytes)
|
||||
}
|
||||
|
||||
struct Phase21FpCase<'a> {
|
||||
adapter: &'a dyn FrameworkAdapter,
|
||||
lang: Lang,
|
||||
handler: &'a str,
|
||||
fixture: &'a str,
|
||||
typed_call: Option<(&'a str, &'a str, &'a str)>,
|
||||
}
|
||||
|
||||
// ── Supported-set assertions ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
@ -447,6 +488,134 @@ fn migration_prisma_adapter_binds_vuln_fixture() {
|
|||
assert_eq!(b.adapter, "migration-prisma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase21_adapter_collision_fixtures_do_not_bind() {
|
||||
let cases = [
|
||||
Phase21FpCase {
|
||||
adapter: &ScheduledCeleryAdapter,
|
||||
lang: Lang::Python,
|
||||
handler: "enqueue",
|
||||
fixture: "python_celery_mailer_delay.py",
|
||||
typed_call: Some(("mailer.delay", "mailer", "Mailer")),
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &ScheduledQuartzAdapter,
|
||||
lang: Lang::Java,
|
||||
handler: "enqueue",
|
||||
fixture: "java_quartz_queue_schedule.java",
|
||||
typed_call: Some(("queue.scheduleJob", "queue", "NotificationQueue")),
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &GraphqlGrapheneAdapter,
|
||||
lang: Lang::Python,
|
||||
handler: "normalize_id",
|
||||
fixture: "python_graphene_helper.py",
|
||||
typed_call: None,
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &GraphqlGqlgenAdapter,
|
||||
lang: Lang::Go,
|
||||
handler: "NormalizeID",
|
||||
fixture: "go_gqlgen_helper.go",
|
||||
typed_call: None,
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &GraphqlJuniperAdapter,
|
||||
lang: Lang::Rust,
|
||||
handler: "normalize_id",
|
||||
fixture: "rust_juniper_helper.rs",
|
||||
typed_call: None,
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &GraphqlRelayAdapter,
|
||||
lang: Lang::JavaScript,
|
||||
handler: "normalizeId",
|
||||
fixture: "js_relay_helper.js",
|
||||
typed_call: None,
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &WebsocketSocketIoAdapter,
|
||||
lang: Lang::Python,
|
||||
handler: "normalize",
|
||||
fixture: "python_socketio_helper.py",
|
||||
typed_call: None,
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &WebsocketChannelsAdapter,
|
||||
lang: Lang::Python,
|
||||
handler: "normalize_frame",
|
||||
fixture: "python_channels_helper.py",
|
||||
typed_call: None,
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &WebsocketActionCableAdapter,
|
||||
lang: Lang::Ruby,
|
||||
handler: "normalize",
|
||||
fixture: "ruby_actioncable_helper.rb",
|
||||
typed_call: None,
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &MiddlewareDjangoAdapter,
|
||||
lang: Lang::Python,
|
||||
handler: "normalize_request",
|
||||
fixture: "python_django_middleware_helper.py",
|
||||
typed_call: None,
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &MiddlewareLaravelAdapter,
|
||||
lang: Lang::Php,
|
||||
handler: "configure",
|
||||
fixture: "php_laravel_bootstrapper.php",
|
||||
typed_call: Some(("app.withMiddleware", "app", "ApplicationBuilder")),
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &MiddlewareSpringAdapter,
|
||||
lang: Lang::Java,
|
||||
handler: "normalize",
|
||||
fixture: "java_spring_middleware_helper.java",
|
||||
typed_call: None,
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &MigrationDjangoAdapter,
|
||||
lang: Lang::Python,
|
||||
handler: "normalize_name",
|
||||
fixture: "python_django_migration_helper.py",
|
||||
typed_call: None,
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &MigrationFlaskAdapter,
|
||||
lang: Lang::Python,
|
||||
handler: "normalize_name",
|
||||
fixture: "python_alembic_helper.py",
|
||||
typed_call: None,
|
||||
},
|
||||
Phase21FpCase {
|
||||
adapter: &MigrationSequelizeAdapter,
|
||||
lang: Lang::JavaScript,
|
||||
handler: "normalizeName",
|
||||
fixture: "js_sequelize_helper.js",
|
||||
typed_call: None,
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let binding = detect_phase21_fp_fixture(
|
||||
case.adapter,
|
||||
case.lang,
|
||||
case.handler,
|
||||
case.fixture,
|
||||
case.typed_call,
|
||||
);
|
||||
assert!(
|
||||
binding.is_none(),
|
||||
"{fixture}::{handler} should not bind through {}; got {binding:?}",
|
||||
case.adapter.name(),
|
||||
fixture = case.fixture,
|
||||
handler = case.handler,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Harness emit shape ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue