apisec-code-bolt 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,559 @@
1
+ """
2
+ gRPC framework plugin (server-side).
3
+
4
+ Detects gRPC service implementations built on top of:
5
+ - `Grpc.Core` — classic gRPC for .NET (Grpc.Core.Server)
6
+ - `Grpc.AspNetCore.Server` — ASP.NET Core integration (AddGrpc / MapGrpcService)
7
+
8
+ A gRPC service is a class that inherits from a `.proto`-generated
9
+ abstract base whose name ends in `Base` (e.g. `Greeter.GreeterBase`).
10
+ Each RPC is an `override` method that accepts a `ServerCallContext`.
11
+
12
+ Routes are emitted with:
13
+ - `method = POST` — gRPC is always POST over HTTP/2
14
+ - `path = /{package}.{Service}/{Method}` (wire path)
15
+ - `kind = grpc_unary | grpc_server_stream | grpc_client_stream | grpc_bidi_stream`
16
+ - `tags = ["grpc"]`
17
+
18
+ Streaming is inferred from parameter types:
19
+ - `IAsyncStreamReader<TReq>` parameter → client streaming
20
+ - `IServerStreamWriter<TRes>` parameter → server streaming
21
+ - Both → bidirectional streaming
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from pathlib import Path
28
+ from typing import ClassVar
29
+
30
+ from ...core.types import (
31
+ AuthDependencyType,
32
+ AuthSchemeType,
33
+ CodeLocation,
34
+ Confidence,
35
+ Framework,
36
+ HttpMethod,
37
+ Language,
38
+ QualifiedName,
39
+ )
40
+ from ...parsing.base import ParsedClass, ParsedDecorator, ParsedFile, ParsedFunction
41
+ from ...parsing.services import AnalysisContext
42
+ from ..base import (
43
+ BaseFrameworkPlugin,
44
+ ExtractedAuthDependency,
45
+ ExtractedAuthScheme,
46
+ ExtractedBody,
47
+ ExtractedDependency,
48
+ ExtractedMiddleware,
49
+ ExtractedResponse,
50
+ ExtractedRoute,
51
+ FrameworkPluginRegistry,
52
+ )
53
+
54
+ # =============================================================================
55
+ # Constants
56
+ # =============================================================================
57
+
58
+ _GRPC_IMPORTS: frozenset[str] = frozenset(
59
+ {
60
+ "Grpc.Core",
61
+ "Grpc.Core.Interceptors",
62
+ "Grpc.AspNetCore.Server",
63
+ "Grpc.AspNetCore.Server.ClientFactory",
64
+ "Google.Protobuf",
65
+ }
66
+ )
67
+
68
+ # Parameter type names that mark a method as a gRPC RPC override.
69
+ _SERVER_CALL_CONTEXT = "ServerCallContext"
70
+
71
+ # Generic-typed streaming parameter base names (before `<...>`).
72
+ _CLIENT_STREAM_READER = "IAsyncStreamReader"
73
+ _SERVER_STREAM_WRITER = "IServerStreamWriter"
74
+
75
+ # Methods inherited from Object that may also be `override` — never RPCs.
76
+ _OBJECT_METHOD_NAMES: frozenset[str] = frozenset(
77
+ {
78
+ "Equals",
79
+ "GetHashCode",
80
+ "ToString",
81
+ "Finalize",
82
+ }
83
+ )
84
+
85
+ _GENERIC_INNER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_\.]*<(.+)>\??$")
86
+
87
+
88
+ def _extract_authorize_roles(dec: ParsedDecorator) -> list[str]:
89
+ """Extract comma-separated role names from [Authorize(Roles = "Admin,User")].
90
+
91
+ The C# parser stores named arguments in dec.arguments (a dict), not in
92
+ dec.positional_args. Mirrors aspnet_plugin._extract_roles().
93
+ """
94
+ roles_val = dec.arguments.get("Roles", dec.arguments.get("roles", ""))
95
+ if not roles_val or not isinstance(roles_val, str):
96
+ return []
97
+ return [r.strip() for r in roles_val.split(",") if r.strip()]
98
+
99
+
100
+ # Parsing `package foo.bar;` and `service Greeter { ... }` from .proto files.
101
+ _PROTO_PACKAGE_RE = re.compile(r"^\s*package\s+([\w\.]+)\s*;", re.MULTILINE)
102
+ _PROTO_SERVICE_RE = re.compile(r"^\s*service\s+(\w+)\s*\{", re.MULTILINE)
103
+
104
+
105
+ # =============================================================================
106
+ # GrpcPlugin
107
+ # =============================================================================
108
+
109
+
110
+ class GrpcPlugin(BaseFrameworkPlugin):
111
+ """Framework plugin for server-side gRPC services in C#."""
112
+
113
+ FRAMEWORK: ClassVar[Framework] = Framework.GRPC
114
+ LANGUAGE: ClassVar[Language] = Language.CSHARP
115
+ DETECTION_IMPORTS: ClassVar[frozenset[str]] = _GRPC_IMPORTS
116
+ DETECTION_IMPORT_PREFIXES: ClassVar[tuple[str, ...]] = ("Grpc.",)
117
+
118
+ def __init__(self) -> None:
119
+ # Maps str(project_root) → {service_name: proto_package}.
120
+ # Populated lazily on first extract_routes call per project, so
121
+ # back-to-back scans don't re-read every .proto file.
122
+ self._proto_packages_cache: dict[str, dict[str, str]] = {}
123
+
124
+ # -------------------------------------------------------------------------
125
+ # Detection
126
+ # -------------------------------------------------------------------------
127
+
128
+ def detect(self, parsed_file: ParsedFile) -> bool:
129
+ for imp in parsed_file.imports:
130
+ module = imp.module or ""
131
+ if module.startswith("Grpc."):
132
+ return True
133
+ # Attribute-free fallback: a class extending *Base with an override
134
+ # method accepting ServerCallContext is almost certainly a gRPC service.
135
+ return any(self._is_grpc_service_by_shape(cls) for cls in parsed_file.classes)
136
+
137
+ # -------------------------------------------------------------------------
138
+ # Route extraction
139
+ # -------------------------------------------------------------------------
140
+
141
+ def extract_routes(
142
+ self,
143
+ parsed_file: ParsedFile,
144
+ context: AnalysisContext | None = None,
145
+ ) -> list[ExtractedRoute]:
146
+ routes: list[ExtractedRoute] = []
147
+ proto_map = self._load_proto_packages(context)
148
+
149
+ for cls in parsed_file.classes:
150
+ if not self._is_grpc_service(cls):
151
+ continue
152
+
153
+ package, service = self._extract_service_id(cls, parsed_file, proto_map)
154
+
155
+ for method in cls.methods:
156
+ if not self._is_rpc_method(method):
157
+ continue
158
+ route = self._extract_rpc_route(method, cls, package, service)
159
+ if route is not None:
160
+ routes.append(route)
161
+
162
+ return routes
163
+
164
+ # -------------------------------------------------------------------------
165
+ # .proto-file discovery
166
+ # -------------------------------------------------------------------------
167
+
168
+ def _load_proto_packages(self, context: AnalysisContext | None) -> dict[str, str]:
169
+ """
170
+ Build `{service_name: proto_package}` by scanning the project for
171
+ `.proto` files. Cached per project_root because extract_routes is
172
+ called once per C# file but the proto layout is shared across them.
173
+ """
174
+ if context is None or context.project_root is None:
175
+ return {}
176
+ key = str(context.project_root)
177
+ if key not in self._proto_packages_cache:
178
+ self._proto_packages_cache[key] = _scan_proto_files(context.project_root)
179
+ return self._proto_packages_cache[key]
180
+
181
+ def extract_auth_schemes(self, parsed_file: ParsedFile) -> list[ExtractedAuthScheme]:
182
+ """
183
+ Detect gRPC auth schemes.
184
+
185
+ gRPC services on ASP.NET Core use the same [Authorize] attribute as
186
+ regular controllers:
187
+ - AddJwtBearer calls → JWT_BEARER
188
+ - AddAuthentication / AddBearerToken calls → CUSTOM
189
+ - [Authorize] on service classes → CUSTOM
190
+ """
191
+ schemes: list[ExtractedAuthScheme] = []
192
+ seen: set[str] = set()
193
+
194
+ for call in parsed_file.call_sites:
195
+ name_lower = call.callee_name.lower()
196
+ line = call.location.line if call.location else 1
197
+ if name_lower == "addjwtbearer":
198
+ if "JwtBearer" not in seen:
199
+ seen.add("JwtBearer")
200
+ schemes.append(
201
+ ExtractedAuthScheme(
202
+ scheme_type=AuthSchemeType.JWT_BEARER,
203
+ name="JwtBearer",
204
+ location=CodeLocation(file=parsed_file.path, line=line),
205
+ confidence=Confidence.HIGH,
206
+ )
207
+ )
208
+ elif name_lower in ("addauthentication", "addbearertoken"):
209
+ # AddBearerToken is the .NET 8 opaque bearer-token API, not JWT
210
+ if "Authentication" not in seen:
211
+ seen.add("Authentication")
212
+ schemes.append(
213
+ ExtractedAuthScheme(
214
+ scheme_type=AuthSchemeType.CUSTOM,
215
+ name="GrpcAuthentication",
216
+ location=CodeLocation(file=parsed_file.path, line=line),
217
+ confidence=Confidence.MEDIUM,
218
+ )
219
+ )
220
+
221
+ # [Authorize] on gRPC service class → auth is active
222
+ for cls in parsed_file.classes:
223
+ if self._is_grpc_service(cls):
224
+ if any(d.name == "Authorize" for d in cls.decorators):
225
+ if "Authorize" not in seen:
226
+ seen.add("Authorize")
227
+ schemes.append(
228
+ ExtractedAuthScheme(
229
+ scheme_type=AuthSchemeType.CUSTOM,
230
+ name="GrpcAuthorize",
231
+ location=cls.location
232
+ or CodeLocation(file=parsed_file.path, line=1),
233
+ confidence=Confidence.HIGH,
234
+ )
235
+ )
236
+
237
+ return schemes
238
+
239
+ def extract_auth_dependencies(
240
+ self, parsed_file: ParsedFile, known_scheme_names: set[str] | None = None, **kwargs
241
+ ) -> list[ExtractedAuthDependency]:
242
+ """
243
+ Extract [Authorize] and [AllowAnonymous] on gRPC service RPC methods.
244
+
245
+ Uses dec.arguments (named dict) not positional_args — the C# parser
246
+ stores [Authorize(Roles = "Admin,User")] as arguments["Roles"], not as
247
+ a positional arg. Mirrors the pattern in aspnet_plugin._extract_roles().
248
+ Only emits deps for methods that pass _is_rpc_method() to exclude
249
+ lifecycle / helper overrides.
250
+ """
251
+ deps: list[ExtractedAuthDependency] = []
252
+
253
+ for cls in parsed_file.classes:
254
+ if not self._is_grpc_service(cls):
255
+ continue
256
+
257
+ # Class-level [Authorize] / [AllowAnonymous]
258
+ class_roles: list[str] = []
259
+ class_has_authorize = False
260
+ class_is_anonymous = any(d.name == "AllowAnonymous" for d in cls.decorators)
261
+ if not class_is_anonymous:
262
+ for dec in cls.decorators:
263
+ if dec.name == "Authorize":
264
+ class_has_authorize = True
265
+ class_roles.extend(_extract_authorize_roles(dec))
266
+
267
+ for method in cls.methods:
268
+ # Only RPC methods, not lifecycle overrides
269
+ if not self._is_rpc_method(method):
270
+ continue
271
+
272
+ # Method-level [AllowAnonymous] takes precedence over class [Authorize]
273
+ if any(d.name == "AllowAnonymous" for d in method.decorators):
274
+ continue
275
+
276
+ method_roles = list(class_roles)
277
+ has_method_authorize = False
278
+ for dec in method.decorators:
279
+ if dec.name == "Authorize":
280
+ has_method_authorize = True
281
+ method_roles.extend(_extract_authorize_roles(dec))
282
+
283
+ # Skip if neither class nor method has any [Authorize]
284
+ if not class_has_authorize and not has_method_authorize:
285
+ continue
286
+
287
+ handler = f"{cls.name}.{method.name}"
288
+ deps.append(
289
+ ExtractedAuthDependency(
290
+ name=handler,
291
+ qualified_name=QualifiedName(module=parsed_file.path.stem, name=handler),
292
+ location=method.location or CodeLocation(file=parsed_file.path, line=1),
293
+ dependency_type=AuthDependencyType.ANNOTATION,
294
+ uses_schemes=["GrpcAuthorize"],
295
+ requires_roles=method_roles,
296
+ confidence=Confidence.HIGH,
297
+ )
298
+ )
299
+
300
+ return deps
301
+
302
+ def extract_dependencies(self, parsed_file: ParsedFile) -> list[ExtractedDependency]:
303
+ return []
304
+
305
+ def extract_middleware(self, parsed_file: ParsedFile) -> list[ExtractedMiddleware]:
306
+ return []
307
+
308
+ # -------------------------------------------------------------------------
309
+ # Service-class detection
310
+ # -------------------------------------------------------------------------
311
+
312
+ def _is_grpc_service(self, cls: ParsedClass) -> bool:
313
+ """A non-abstract class inheriting from a `*Base` parent."""
314
+ if not cls.base_classes:
315
+ return False
316
+ for base in cls.base_classes:
317
+ simple = base.rsplit(".", 1)[-1]
318
+ if simple.endswith("Base") and simple != "Base":
319
+ return True
320
+ return False
321
+
322
+ def _is_grpc_service_by_shape(self, cls: ParsedClass) -> bool:
323
+ """Detection fallback used when no `using Grpc.*` is present."""
324
+ if not self._is_grpc_service(cls):
325
+ return False
326
+ return any(self._is_rpc_method(m) for m in cls.methods)
327
+
328
+ def _is_rpc_method(self, method: ParsedFunction) -> bool:
329
+ if method.name in _OBJECT_METHOD_NAMES:
330
+ return False
331
+ # gRPC overrides always take ServerCallContext as their final parameter.
332
+ for param in method.parameters:
333
+ base = (param.type_annotation or "").split("<")[0].strip().rstrip("?")
334
+ if base.rsplit(".", 1)[-1] == _SERVER_CALL_CONTEXT:
335
+ return True
336
+ return False
337
+
338
+ # -------------------------------------------------------------------------
339
+ # Service-ID extraction
340
+ # -------------------------------------------------------------------------
341
+
342
+ def _extract_service_id(
343
+ self,
344
+ cls: ParsedClass,
345
+ parsed_file: ParsedFile,
346
+ proto_map: dict[str, str] | None = None,
347
+ ) -> tuple[str, str]:
348
+ """
349
+ Derive (proto_package, service_name) for emission of the wire path.
350
+
351
+ Strategy:
352
+ 1. Inspect the base-class spelling. `Helloworld.Greeter.GreeterBase`
353
+ → package=Helloworld, service=Greeter.
354
+ `Greeter.GreeterBase` (no package) → package="", service=Greeter.
355
+ 2. Fall back to a service-name lookup in the `.proto`-derived package
356
+ map: that's where the *actual* wire-path package lives whenever
357
+ the .proto uses `option csharp_namespace = "...";` to decouple
358
+ C# from the proto package.
359
+ 3. Last resort: file's C# namespace.
360
+ """
361
+ for base in cls.base_classes:
362
+ parts = base.split(".")
363
+ if not parts[-1].endswith("Base") or parts[-1] == "Base":
364
+ continue
365
+
366
+ # The conventional shape is `<Package>.<Service>.<Service>Base` or
367
+ # `<Service>.<Service>Base`. Strip the trailing `Base` from the
368
+ # last part to recover the service name; everything before the
369
+ # second-to-last part is the package.
370
+ service_with_base = parts[-1]
371
+ service = service_with_base[: -len("Base")]
372
+
373
+ if len(parts) >= 2:
374
+ # `...<Service>.<Service>Base` (canonical) — strip both the
375
+ # trailing `*Base` and its preceding `<Service>` segment.
376
+ # Anything else keeps whatever's before the Base part.
377
+ preceding = parts[-2]
378
+ package = ".".join(parts[:-2]) if preceding == service else ".".join(parts[:-1])
379
+ else:
380
+ package = ""
381
+
382
+ if not package and proto_map:
383
+ package = proto_map.get(service, "")
384
+
385
+ if not package:
386
+ package = self._csharp_namespace(parsed_file) or ""
387
+
388
+ return package, service
389
+
390
+ return "", cls.name
391
+
392
+ def _csharp_namespace(self, parsed_file: ParsedFile) -> str | None:
393
+ ns = getattr(parsed_file, "namespace", None)
394
+ if isinstance(ns, str) and ns:
395
+ return ns
396
+ return None
397
+
398
+ # -------------------------------------------------------------------------
399
+ # Per-RPC emission
400
+ # -------------------------------------------------------------------------
401
+
402
+ def _extract_rpc_route(
403
+ self,
404
+ method: ParsedFunction,
405
+ cls: ParsedClass,
406
+ package: str,
407
+ service: str,
408
+ ) -> ExtractedRoute | None:
409
+ request_type, response_type, kind = self._classify_streaming(method)
410
+
411
+ path = f"/{package}.{service}/{method.name}" if package else f"/{service}/{method.name}"
412
+
413
+ body: ExtractedBody | None = None
414
+ if request_type:
415
+ body = ExtractedBody(
416
+ content_type="application/grpc",
417
+ model_name=request_type,
418
+ required=True,
419
+ )
420
+
421
+ response = ExtractedResponse(
422
+ status_code=200,
423
+ model_name=response_type,
424
+ )
425
+
426
+ handler_qname = method.qualified_name or QualifiedName(
427
+ module=cls.qualified_name.module if cls.qualified_name else "",
428
+ name=f"{cls.name}.{method.name}",
429
+ )
430
+
431
+ return ExtractedRoute(
432
+ method=HttpMethod.POST,
433
+ path=path,
434
+ handler_function=handler_qname,
435
+ handler_location=method.location,
436
+ path_params=[],
437
+ query_params=[],
438
+ header_params=[],
439
+ cookie_params=[],
440
+ body=body,
441
+ response=response,
442
+ tags=["grpc"],
443
+ dependency_refs=[],
444
+ confidence=Confidence.HIGH,
445
+ kind=kind,
446
+ )
447
+
448
+ def _classify_streaming(self, method: ParsedFunction) -> tuple[str | None, str | None, str]:
449
+ """
450
+ Return (request_model, response_model, kind) from method signature.
451
+
452
+ The four gRPC method shapes:
453
+ - Unary: TRes Method(TReq req, ServerCallContext ctx)
454
+ - Server streaming: Task Method(TReq req, IServerStreamWriter<TRes> rs, ServerCallContext ctx)
455
+ - Client streaming: Task<TRes> Method(IAsyncStreamReader<TReq> rs, ServerCallContext ctx)
456
+ - Bidi streaming: Task Method(IAsyncStreamReader<TReq> rr, IServerStreamWriter<TRes> rw, ServerCallContext ctx)
457
+ """
458
+ client_stream_req: str | None = None
459
+ server_stream_res: str | None = None
460
+ first_data_param_type: str | None = None
461
+
462
+ for param in method.parameters:
463
+ ty = (param.type_annotation or "").strip()
464
+ if not ty:
465
+ continue
466
+ base = ty.split("<")[0].strip()
467
+ simple = base.rsplit(".", 1)[-1]
468
+
469
+ if simple == _SERVER_CALL_CONTEXT:
470
+ continue
471
+ if simple == _CLIENT_STREAM_READER:
472
+ client_stream_req = _generic_inner(ty)
473
+ continue
474
+ if simple == _SERVER_STREAM_WRITER:
475
+ server_stream_res = _generic_inner(ty)
476
+ continue
477
+ if first_data_param_type is None:
478
+ first_data_param_type = ty
479
+
480
+ # Response: prefer the stream writer's type; otherwise unwrap Task<T>.
481
+ response_type = server_stream_res or _unwrap_task(method.return_type)
482
+
483
+ # Request: streaming reader's type wins; otherwise the first non-context param.
484
+ request_type = client_stream_req or first_data_param_type
485
+
486
+ if client_stream_req is not None and server_stream_res is not None:
487
+ kind = "grpc_bidi_stream"
488
+ elif client_stream_req is not None:
489
+ kind = "grpc_client_stream"
490
+ elif server_stream_res is not None:
491
+ kind = "grpc_server_stream"
492
+ else:
493
+ kind = "grpc_unary"
494
+
495
+ return request_type, response_type, kind
496
+
497
+
498
+ # =============================================================================
499
+ # Module-level helpers
500
+ # =============================================================================
501
+
502
+
503
+ def _scan_proto_files(root: Path) -> dict[str, str]:
504
+ """
505
+ Walk `root` for `*.proto` files and return `{service_name: package}`.
506
+
507
+ A service name is the identifier in `service Foo { ... }`; the
508
+ package is the value of the first `package …;` declaration in the
509
+ same file (or empty if none). When a service name appears in more
510
+ than one proto, the first match wins — duplicates are rare in
511
+ practice (the canonical case is a `.proto` checked into both the
512
+ server and a client project; the package matches either way).
513
+ """
514
+ mapping: dict[str, str] = {}
515
+ try:
516
+ proto_paths = sorted(root.rglob("*.proto"))
517
+ except (OSError, ValueError):
518
+ return mapping
519
+
520
+ for proto in proto_paths:
521
+ try:
522
+ text = proto.read_text(encoding="utf-8", errors="ignore")
523
+ except OSError:
524
+ continue
525
+ pkg_match = _PROTO_PACKAGE_RE.search(text)
526
+ package = pkg_match.group(1) if pkg_match else ""
527
+ for service in _PROTO_SERVICE_RE.findall(text):
528
+ if service not in mapping:
529
+ mapping[service] = package
530
+ return mapping
531
+
532
+
533
+ def _generic_inner(type_annotation: str) -> str | None:
534
+ """Return `T` from `Foo<T>` (handles trailing nullable `?`)."""
535
+ m = _GENERIC_INNER_RE.match(type_annotation.strip())
536
+ if m:
537
+ return m.group(1).strip()
538
+ return None
539
+
540
+
541
+ def _unwrap_task(return_type: str | None) -> str | None:
542
+ """`Task<T>` / `ValueTask<T>` → `T`; `Task` / `void` → None."""
543
+ if not return_type:
544
+ return None
545
+ ret = return_type.strip()
546
+ for wrapper in ("Task<", "ValueTask<"):
547
+ if ret.startswith(wrapper) and ret.endswith(">"):
548
+ return ret[len(wrapper) : -1].strip() or None
549
+ if ret in ("Task", "ValueTask", "void"):
550
+ return None
551
+ return ret
552
+
553
+
554
+ # =============================================================================
555
+ # Self-registration
556
+ # =============================================================================
557
+
558
+ _grpc_plugin = GrpcPlugin()
559
+ FrameworkPluginRegistry.register(_grpc_plugin)