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.
- apisec_code_bolt/__init__.py +42 -0
- apisec_code_bolt/__main__.py +11 -0
- apisec_code_bolt/analysis/__init__.py +96 -0
- apisec_code_bolt/analysis/analyzer.py +2309 -0
- apisec_code_bolt/analysis/binding_tracker.py +341 -0
- apisec_code_bolt/analysis/call_graph.py +1197 -0
- apisec_code_bolt/analysis/call_graph_types.py +332 -0
- apisec_code_bolt/analysis/call_resolver.py +988 -0
- apisec_code_bolt/analysis/capability_tagger.py +322 -0
- apisec_code_bolt/analysis/config_scanner.py +197 -0
- apisec_code_bolt/analysis/data_flow.py +1883 -0
- apisec_code_bolt/analysis/dependency_extractor.py +959 -0
- apisec_code_bolt/analysis/flow_analysis.py +1406 -0
- apisec_code_bolt/analysis/hof_catalog.py +61 -0
- apisec_code_bolt/analysis/integration_detector.py +1399 -0
- apisec_code_bolt/analysis/literal_scanner.py +300 -0
- apisec_code_bolt/analysis/path_normalizer.py +55 -0
- apisec_code_bolt/analysis/read_site_detector.py +310 -0
- apisec_code_bolt/analysis/request_patterns.py +162 -0
- apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
- apisec_code_bolt/analysis/sink_evidence.py +333 -0
- apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
- apisec_code_bolt/cli/__init__.py +5 -0
- apisec_code_bolt/cli/exit_codes.py +17 -0
- apisec_code_bolt/cli/main.py +1069 -0
- apisec_code_bolt/cloud/__init__.py +1 -0
- apisec_code_bolt/cloud/apisec_client.py +118 -0
- apisec_code_bolt/cloud/client.py +255 -0
- apisec_code_bolt/core/__init__.py +75 -0
- apisec_code_bolt/core/config.py +528 -0
- apisec_code_bolt/core/credentials.py +65 -0
- apisec_code_bolt/core/discovery.py +433 -0
- apisec_code_bolt/core/log_format.py +115 -0
- apisec_code_bolt/core/manifest.py +1009 -0
- apisec_code_bolt/core/repo.py +280 -0
- apisec_code_bolt/core/state.py +59 -0
- apisec_code_bolt/core/telemetry.py +451 -0
- apisec_code_bolt/core/types.py +587 -0
- apisec_code_bolt/fingerprinting/__init__.py +1 -0
- apisec_code_bolt/frameworks/__init__.py +29 -0
- apisec_code_bolt/frameworks/_jwt_common.py +50 -0
- apisec_code_bolt/frameworks/auth_helpers.py +437 -0
- apisec_code_bolt/frameworks/base.py +608 -0
- apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
- apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
- apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
- apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
- apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
- apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
- apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
- apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
- apisec_code_bolt/frameworks/java/__init__.py +6 -0
- apisec_code_bolt/frameworks/java/_annotations.py +167 -0
- apisec_code_bolt/frameworks/java/_constraints.py +128 -0
- apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
- apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
- apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
- apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
- apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
- apisec_code_bolt/frameworks/js/__init__.py +8 -0
- apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
- apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
- apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
- apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
- apisec_code_bolt/frameworks/python/__init__.py +19 -0
- apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
- apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
- apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
- apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
- apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
- apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
- apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
- apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
- apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
- apisec_code_bolt/parsing/__init__.py +62 -0
- apisec_code_bolt/parsing/base.py +554 -0
- apisec_code_bolt/parsing/csharp/__init__.py +5 -0
- apisec_code_bolt/parsing/csharp/language_services.py +203 -0
- apisec_code_bolt/parsing/csharp/literals.py +72 -0
- apisec_code_bolt/parsing/csharp/parser.py +1158 -0
- apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
- apisec_code_bolt/parsing/js/__init__.py +5 -0
- apisec_code_bolt/parsing/js/language_services.py +118 -0
- apisec_code_bolt/parsing/js/parser.py +622 -0
- apisec_code_bolt/parsing/jvm/__init__.py +7 -0
- apisec_code_bolt/parsing/jvm/language_services.py +270 -0
- apisec_code_bolt/parsing/jvm/parser.py +774 -0
- apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
- apisec_code_bolt/parsing/python/__init__.py +150 -0
- apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
- apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
- apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
- apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
- apisec_code_bolt/parsing/python/expression_utils.py +221 -0
- apisec_code_bolt/parsing/python/extraction_types.py +271 -0
- apisec_code_bolt/parsing/python/language_services.py +487 -0
- apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
- apisec_code_bolt/parsing/python/parser.py +719 -0
- apisec_code_bolt/parsing/python/path_resolver.py +576 -0
- apisec_code_bolt/parsing/python/router_registry.py +806 -0
- apisec_code_bolt/parsing/python/type_resolver.py +730 -0
- apisec_code_bolt/parsing/python/visitors.py +1544 -0
- apisec_code_bolt/parsing/services.py +544 -0
- apisec_code_bolt/query/__init__.py +1 -0
- apisec_code_bolt/query/ast_cache.py +182 -0
- apisec_code_bolt/query/executor.py +283 -0
- apisec_code_bolt/query/handlers.py +832 -0
- apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
- apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
- apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
- apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WCF (Windows Communication Foundation) framework plugin.
|
|
3
|
+
|
|
4
|
+
Detects service contracts and emits one route per operation:
|
|
5
|
+
|
|
6
|
+
[ServiceContract(Name = "Orders")]
|
|
7
|
+
public interface IOrderService {
|
|
8
|
+
[OperationContract]
|
|
9
|
+
Order GetOrder(string id);
|
|
10
|
+
|
|
11
|
+
[OperationContract(IsOneWay = true)]
|
|
12
|
+
void CancelOrder(string id);
|
|
13
|
+
|
|
14
|
+
[OperationContract]
|
|
15
|
+
[WebGet(UriTemplate = "/orders/{id}")]
|
|
16
|
+
Order GetOrderRest(string id);
|
|
17
|
+
|
|
18
|
+
[OperationContract]
|
|
19
|
+
[WebInvoke(Method = "POST", UriTemplate = "/orders")]
|
|
20
|
+
Order CreateOrderRest(CreateOrderRequest req);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
Per-operation route emission:
|
|
24
|
+
|
|
25
|
+
| Decorator(s) | method | path | kind |
|
|
26
|
+
|---------------------------------|------------|-----------------------------------|-------------|
|
|
27
|
+
| `[OperationContract]` only | `POST` | `/{ServiceName}/{Operation}` | `wcf_soap` |
|
|
28
|
+
| `[OperationContract(IsOneWay)]` | `POST` | `/{ServiceName}/{Operation}` | `wcf_oneway`|
|
|
29
|
+
| `[WebGet]` | `GET` | `UriTemplate` arg, or `/Operation`| `http` |
|
|
30
|
+
| `[WebInvoke]` | `Method`* | `UriTemplate` arg, or `/Operation`| `http` |
|
|
31
|
+
|
|
32
|
+
* `[WebInvoke]` defaults to POST when its `Method` argument is omitted.
|
|
33
|
+
|
|
34
|
+
The plugin walks **interfaces** (where `[ServiceContract]` lives) rather than
|
|
35
|
+
implementation classes. The interface IS the contract — every operation
|
|
36
|
+
defined on it is callable surface, whether or not we can also see the
|
|
37
|
+
implementing class in the same parse.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import contextlib
|
|
43
|
+
import re
|
|
44
|
+
import xml.etree.ElementTree as ET
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import ClassVar
|
|
48
|
+
|
|
49
|
+
from ...core.types import (
|
|
50
|
+
AuthDependencyType,
|
|
51
|
+
AuthSchemeType,
|
|
52
|
+
CodeLocation,
|
|
53
|
+
Confidence,
|
|
54
|
+
Framework,
|
|
55
|
+
HttpMethod,
|
|
56
|
+
Language,
|
|
57
|
+
ParameterLocation,
|
|
58
|
+
QualifiedName,
|
|
59
|
+
)
|
|
60
|
+
from ...parsing.base import ParsedClass, ParsedFile, ParsedFunction
|
|
61
|
+
from ...parsing.csharp.literals import parse_csharp_string_literal
|
|
62
|
+
from ...parsing.services import AnalysisContext
|
|
63
|
+
from ..base import (
|
|
64
|
+
BaseFrameworkPlugin,
|
|
65
|
+
ExtractedAuthDependency,
|
|
66
|
+
ExtractedAuthScheme,
|
|
67
|
+
ExtractedBody,
|
|
68
|
+
ExtractedDependency,
|
|
69
|
+
ExtractedMiddleware,
|
|
70
|
+
ExtractedParameter,
|
|
71
|
+
ExtractedResponse,
|
|
72
|
+
ExtractedRoute,
|
|
73
|
+
FrameworkPluginRegistry,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# =============================================================================
|
|
77
|
+
# Constants
|
|
78
|
+
# =============================================================================
|
|
79
|
+
|
|
80
|
+
_WCF_IMPORTS: frozenset[str] = frozenset(
|
|
81
|
+
{
|
|
82
|
+
"System.ServiceModel",
|
|
83
|
+
"System.ServiceModel.Web",
|
|
84
|
+
"System.ServiceModel.Channels",
|
|
85
|
+
"System.ServiceModel.Description",
|
|
86
|
+
"CoreWCF",
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
_SERVICE_CONTRACT_ATTR = "ServiceContract"
|
|
91
|
+
_OPERATION_CONTRACT_ATTR = "OperationContract"
|
|
92
|
+
|
|
93
|
+
# REST-style WCF attributes (WebHttpBinding).
|
|
94
|
+
_WEB_GET_ATTR = "WebGet"
|
|
95
|
+
_WEB_INVOKE_ATTR = "WebInvoke"
|
|
96
|
+
|
|
97
|
+
# Security + fault attributes.
|
|
98
|
+
_PRINCIPAL_PERMISSION_ATTR = "PrincipalPermission"
|
|
99
|
+
_FAULT_CONTRACT_ATTR = "FaultContract"
|
|
100
|
+
|
|
101
|
+
# `typeof(X)` argument syntax from C# attribute args — used by [FaultContract].
|
|
102
|
+
# The C# parser typically surfaces the expression as the string `"typeof(X)"`.
|
|
103
|
+
_TYPEOF_RE = re.compile(r"typeof\s*\(\s*([A-Za-z_][\w\.]*)\s*\)")
|
|
104
|
+
|
|
105
|
+
# Method-string arg on [WebInvoke(Method = "POST", ...)] → HttpMethod.
|
|
106
|
+
# [WebInvoke] defaults to POST when Method is omitted (per WCF docs).
|
|
107
|
+
_WEB_INVOKE_METHOD_MAP: dict[str, HttpMethod] = {
|
|
108
|
+
"GET": HttpMethod.GET,
|
|
109
|
+
"POST": HttpMethod.POST,
|
|
110
|
+
"PUT": HttpMethod.PUT,
|
|
111
|
+
"DELETE": HttpMethod.DELETE,
|
|
112
|
+
"PATCH": HttpMethod.PATCH,
|
|
113
|
+
"HEAD": HttpMethod.HEAD,
|
|
114
|
+
"OPTIONS": HttpMethod.OPTIONS,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# UriTemplate path placeholders: `{name}` for segments, `{*name}` for catch-all.
|
|
118
|
+
# WCF UriTemplate doesn't support route constraints like ASP.NET — simpler regex.
|
|
119
|
+
_URI_TEMPLATE_PARAM_RE = re.compile(r"\{\*?([A-Za-z_][\w]*)\}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# =============================================================================
|
|
123
|
+
# Config-derived endpoint metadata
|
|
124
|
+
# =============================================================================
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class _WcfEndpoint:
|
|
129
|
+
"""A single endpoint resolved from Web.config / App.config or a
|
|
130
|
+
CoreWCF `Startup.cs` / `Program.cs` registration.
|
|
131
|
+
|
|
132
|
+
`project_root` is the directory of the enclosing `.csproj` (or None
|
|
133
|
+
when no enclosing project is found — e.g. unit-test setups without
|
|
134
|
+
project files). Endpoints are matched only against contracts in
|
|
135
|
+
the same project, which prevents simple-name collisions across
|
|
136
|
+
unrelated `IFoo` interfaces in different projects."""
|
|
137
|
+
|
|
138
|
+
address: str
|
|
139
|
+
binding: str
|
|
140
|
+
base_address: str | None = None
|
|
141
|
+
project_root: str | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Methods inherited from System.Object — never WCF operations.
|
|
145
|
+
_OBJECT_METHOD_NAMES: frozenset[str] = frozenset(
|
|
146
|
+
{
|
|
147
|
+
"Equals",
|
|
148
|
+
"GetHashCode",
|
|
149
|
+
"ToString",
|
|
150
|
+
"Finalize",
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Parameter types that are part of the WCF plumbing layer, not the request body.
|
|
155
|
+
_PLUMBING_PARAM_TYPES: frozenset[str] = frozenset(
|
|
156
|
+
{
|
|
157
|
+
"Message", # System.ServiceModel.Channels.Message
|
|
158
|
+
"MessageHeaders",
|
|
159
|
+
"OperationContext",
|
|
160
|
+
"AsyncCallback", # Begin/End pattern callback
|
|
161
|
+
"Object", # AsyncState in Begin pattern
|
|
162
|
+
"IAsyncResult", # End* parameter
|
|
163
|
+
"CancellationToken",
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# =============================================================================
|
|
169
|
+
# WcfPlugin
|
|
170
|
+
# =============================================================================
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class WcfPlugin(BaseFrameworkPlugin):
|
|
174
|
+
"""Framework plugin for WCF service contracts (SOAP)."""
|
|
175
|
+
|
|
176
|
+
FRAMEWORK: ClassVar[Framework] = Framework.WCF
|
|
177
|
+
LANGUAGE: ClassVar[Language] = Language.CSHARP
|
|
178
|
+
DETECTION_IMPORTS: ClassVar[frozenset[str]] = _WCF_IMPORTS
|
|
179
|
+
|
|
180
|
+
def __init__(self) -> None:
|
|
181
|
+
# {id(AnalysisContext): {contract_fqn: [_WcfEndpoint, ...]}}. Built
|
|
182
|
+
# lazily on the first extract_routes call per project so the cross-
|
|
183
|
+
# file config scan runs once per analysis run.
|
|
184
|
+
# Keyed by the resolved project_root path string — stable across
|
|
185
|
+
# `AnalysisContext` instances pointing at the same project, and
|
|
186
|
+
# immune to Python memory-address recycling (which `id(context)`
|
|
187
|
+
# is not). Same project root → same scan result → same cache.
|
|
188
|
+
self._endpoint_cache: dict[str, dict[str, list[_WcfEndpoint]]] = {}
|
|
189
|
+
|
|
190
|
+
# -------------------------------------------------------------------------
|
|
191
|
+
# Detection
|
|
192
|
+
# -------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def detect(self, parsed_file: ParsedFile) -> bool:
|
|
195
|
+
# Primary signal: System.ServiceModel / CoreWCF imports.
|
|
196
|
+
for imp in parsed_file.imports:
|
|
197
|
+
module = imp.module or ""
|
|
198
|
+
if module.startswith("System.ServiceModel") or module.startswith("CoreWCF"):
|
|
199
|
+
return True
|
|
200
|
+
# Fallback: a class carries [ServiceContract] even without the import
|
|
201
|
+
# (rare — usually means global usings or fully-qualified attribute).
|
|
202
|
+
return any(self._is_service_contract(cls) for cls in parsed_file.classes)
|
|
203
|
+
|
|
204
|
+
# -------------------------------------------------------------------------
|
|
205
|
+
# Route extraction
|
|
206
|
+
# -------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def extract_routes(
|
|
209
|
+
self,
|
|
210
|
+
parsed_file: ParsedFile,
|
|
211
|
+
context: AnalysisContext | None = None,
|
|
212
|
+
) -> list[ExtractedRoute]:
|
|
213
|
+
routes: list[ExtractedRoute] = []
|
|
214
|
+
endpoint_map = self._load_endpoint_map(context)
|
|
215
|
+
# The project that owns this file scopes which programmatic
|
|
216
|
+
# endpoints apply — distinct projects can declare same-name
|
|
217
|
+
# interfaces (e.g. multiple `IEchoService`s across scenario
|
|
218
|
+
# projects in corewcf-samples).
|
|
219
|
+
file_project_root = _find_enclosing_csproj(
|
|
220
|
+
Path(parsed_file.path) if parsed_file.path else None
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
for cls in parsed_file.classes:
|
|
224
|
+
if not self._is_service_contract(cls):
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
service_name = self._extract_service_name(cls)
|
|
228
|
+
# When config defines endpoints for this contract, emit one route
|
|
229
|
+
# per (operation, endpoint) pair — the same contract can be
|
|
230
|
+
# exposed via multiple bindings with different addresses /
|
|
231
|
+
# security models.
|
|
232
|
+
scoped = self._endpoints_for_contract(cls, endpoint_map, file_project_root)
|
|
233
|
+
if scoped:
|
|
234
|
+
endpoints = scoped
|
|
235
|
+
elif self._contract_is_registered_elsewhere(cls, endpoint_map):
|
|
236
|
+
# This contract IS registered — but not in this file's
|
|
237
|
+
# project. That's the client-side declaration pattern
|
|
238
|
+
# (e.g. `*_client/IEchoService.cs`): the interface mirrors
|
|
239
|
+
# the server contract for proxy generation, not for hosting.
|
|
240
|
+
# Skip — no routes from a client-side contract file.
|
|
241
|
+
continue
|
|
242
|
+
else:
|
|
243
|
+
# Standalone contract — no Web.config / Startup.cs
|
|
244
|
+
# registration anywhere. Emit a single placeholder route
|
|
245
|
+
# per operation (PR A/B behaviour).
|
|
246
|
+
endpoints = [None]
|
|
247
|
+
|
|
248
|
+
for method in cls.methods:
|
|
249
|
+
if not self._is_operation_contract(method):
|
|
250
|
+
continue
|
|
251
|
+
if method.name in _OBJECT_METHOD_NAMES:
|
|
252
|
+
continue
|
|
253
|
+
if self._is_end_async_helper(method, cls):
|
|
254
|
+
continue
|
|
255
|
+
for endpoint in endpoints:
|
|
256
|
+
route = self._extract_operation_route(
|
|
257
|
+
method,
|
|
258
|
+
cls,
|
|
259
|
+
service_name,
|
|
260
|
+
endpoint=endpoint,
|
|
261
|
+
)
|
|
262
|
+
if route is not None:
|
|
263
|
+
routes.append(route)
|
|
264
|
+
|
|
265
|
+
return routes
|
|
266
|
+
|
|
267
|
+
def extract_auth_schemes(self, parsed_file: ParsedFile) -> list[ExtractedAuthScheme]:
|
|
268
|
+
"""
|
|
269
|
+
Detect WCF auth schemes from:
|
|
270
|
+
1. [PrincipalPermission] on operation methods → declarative role auth
|
|
271
|
+
2. wsHttpBinding call sites → CUSTOM (Windows/Kerberos/Message security)
|
|
272
|
+
3. basicHttpBinding → CUSTOM (defaults to no auth; explicit transport
|
|
273
|
+
security config is needed for actual HTTP Basic — emit CUSTOM only)
|
|
274
|
+
4. TransportSecurity / MessageSecurity call sites → CUSTOM
|
|
275
|
+
"""
|
|
276
|
+
schemes: list[ExtractedAuthScheme] = []
|
|
277
|
+
seen: set[str] = set()
|
|
278
|
+
|
|
279
|
+
def _add(name: str, scheme_type: AuthSchemeType, line: int = 1) -> None:
|
|
280
|
+
if name not in seen:
|
|
281
|
+
seen.add(name)
|
|
282
|
+
schemes.append(
|
|
283
|
+
ExtractedAuthScheme(
|
|
284
|
+
scheme_type=scheme_type,
|
|
285
|
+
name=name,
|
|
286
|
+
location=CodeLocation(file=parsed_file.path, line=line),
|
|
287
|
+
confidence=Confidence.HIGH,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# [PrincipalPermission] decorator on any operation method → role auth active.
|
|
292
|
+
# Check decorators directly rather than import names to avoid false positives
|
|
293
|
+
# from System.Security.Claims or Microsoft.IdentityModel.Tokens imports.
|
|
294
|
+
for cls in parsed_file.classes:
|
|
295
|
+
for method in cls.methods:
|
|
296
|
+
for mdec in method.decorators:
|
|
297
|
+
if mdec.name == "PrincipalPermission":
|
|
298
|
+
_add(
|
|
299
|
+
"WcfRoleAuth",
|
|
300
|
+
AuthSchemeType.CUSTOM,
|
|
301
|
+
method.location.line if method.location else 1,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Binding-based auth from call sites (programmatic host/channel configuration)
|
|
305
|
+
for call in parsed_file.call_sites:
|
|
306
|
+
name_lower = call.callee_name.lower()
|
|
307
|
+
line = call.location.line if call.location else 1
|
|
308
|
+
if "wshttpbinding" in name_lower:
|
|
309
|
+
_add("WsHttpBinding", AuthSchemeType.CUSTOM, line)
|
|
310
|
+
elif "basichttpbinding" in name_lower:
|
|
311
|
+
# basicHttpBinding defaults to SecurityMode.None; emit CUSTOM since
|
|
312
|
+
# actual transport security requires additional configuration.
|
|
313
|
+
_add("BasicHttpBinding", AuthSchemeType.CUSTOM, line)
|
|
314
|
+
elif "transportsecurity" in name_lower or "messagesecurity" in name_lower:
|
|
315
|
+
_add("WcfSecurity", AuthSchemeType.CUSTOM, line)
|
|
316
|
+
|
|
317
|
+
return schemes
|
|
318
|
+
|
|
319
|
+
def extract_auth_dependencies(
|
|
320
|
+
self, parsed_file: ParsedFile, known_scheme_names: set[str] | None = None, **kwargs
|
|
321
|
+
) -> list[ExtractedAuthDependency]:
|
|
322
|
+
"""
|
|
323
|
+
Extract `[PrincipalPermission(SecurityAction.Demand, Role = "...")]`
|
|
324
|
+
attributes — WCF's declarative role-based authorisation.
|
|
325
|
+
|
|
326
|
+
Multiple `[PrincipalPermission]` attributes on the same operation
|
|
327
|
+
are an OR (the caller must be in *any* of the listed roles); we
|
|
328
|
+
collapse them into a single dependency whose `requires_roles` is
|
|
329
|
+
the union.
|
|
330
|
+
"""
|
|
331
|
+
auth_deps: list[ExtractedAuthDependency] = []
|
|
332
|
+
for cls in parsed_file.classes:
|
|
333
|
+
if not self._is_service_contract(cls):
|
|
334
|
+
continue
|
|
335
|
+
for method in cls.methods:
|
|
336
|
+
if not self._is_operation_contract(method):
|
|
337
|
+
continue
|
|
338
|
+
roles = self._extract_principal_permission_roles(method)
|
|
339
|
+
if not roles:
|
|
340
|
+
continue
|
|
341
|
+
handler_qname = method.qualified_name or QualifiedName(
|
|
342
|
+
module=cls.qualified_name.module if cls.qualified_name else "",
|
|
343
|
+
name=f"{cls.name}.{method.name}",
|
|
344
|
+
)
|
|
345
|
+
auth_deps.append(
|
|
346
|
+
ExtractedAuthDependency(
|
|
347
|
+
name=handler_qname.full,
|
|
348
|
+
qualified_name=handler_qname,
|
|
349
|
+
location=method.location,
|
|
350
|
+
dependency_type=AuthDependencyType.ANNOTATION,
|
|
351
|
+
requires_roles=roles,
|
|
352
|
+
confidence=Confidence.HIGH,
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
return auth_deps
|
|
356
|
+
|
|
357
|
+
def extract_dependencies(self, parsed_file: ParsedFile) -> list[ExtractedDependency]:
|
|
358
|
+
return []
|
|
359
|
+
|
|
360
|
+
def extract_middleware(self, parsed_file: ParsedFile) -> list[ExtractedMiddleware]:
|
|
361
|
+
return []
|
|
362
|
+
|
|
363
|
+
# -------------------------------------------------------------------------
|
|
364
|
+
# Service-contract detection
|
|
365
|
+
# -------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
def _is_service_contract(self, cls: ParsedClass) -> bool:
|
|
368
|
+
"""Whether the class/interface carries [ServiceContract]."""
|
|
369
|
+
return any(d.name == _SERVICE_CONTRACT_ATTR for d in cls.decorators)
|
|
370
|
+
|
|
371
|
+
def _is_operation_contract(self, method: ParsedFunction) -> bool:
|
|
372
|
+
return any(d.name == _OPERATION_CONTRACT_ATTR for d in method.decorators)
|
|
373
|
+
|
|
374
|
+
def _is_end_async_helper(self, method: ParsedFunction, cls: ParsedClass) -> bool:
|
|
375
|
+
"""A `EndFoo` method paired with a `BeginFoo` operation is plumbing.
|
|
376
|
+
|
|
377
|
+
Begin/End is the legacy WCF async pattern. Only the `BeginFoo` half
|
|
378
|
+
carries [OperationContract(AsyncPattern = true)]; the `EndFoo` half
|
|
379
|
+
usually doesn't, but defensively detect either spelling so we don't
|
|
380
|
+
double-emit.
|
|
381
|
+
"""
|
|
382
|
+
if not method.name.startswith("End"):
|
|
383
|
+
return False
|
|
384
|
+
peer = "Begin" + method.name[len("End") :]
|
|
385
|
+
return any(m.name == peer for m in cls.methods)
|
|
386
|
+
|
|
387
|
+
# -------------------------------------------------------------------------
|
|
388
|
+
# Name extraction
|
|
389
|
+
# -------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
def _extract_service_name(self, cls: ParsedClass) -> str:
|
|
392
|
+
"""
|
|
393
|
+
Service name from `[ServiceContract(Name = "...")]`, falling back to
|
|
394
|
+
the interface/class name minus the leading `I` (the established
|
|
395
|
+
.NET convention for service interfaces).
|
|
396
|
+
"""
|
|
397
|
+
for dec in cls.decorators:
|
|
398
|
+
if dec.name != _SERVICE_CONTRACT_ATTR:
|
|
399
|
+
continue
|
|
400
|
+
name_arg = dec.arguments.get("Name") if dec.arguments else None
|
|
401
|
+
if isinstance(name_arg, str) and name_arg:
|
|
402
|
+
return name_arg
|
|
403
|
+
# `IOrderService` → `OrderService`; `OrderService` (unprefixed) stays.
|
|
404
|
+
name = cls.name
|
|
405
|
+
if name.startswith("I") and len(name) > 1 and name[1].isupper():
|
|
406
|
+
return name[1:]
|
|
407
|
+
return name
|
|
408
|
+
|
|
409
|
+
def _extract_operation_name(self, method: ParsedFunction) -> str:
|
|
410
|
+
"""Operation name from `[OperationContract(Name = "...")]` or method name."""
|
|
411
|
+
for dec in method.decorators:
|
|
412
|
+
if dec.name != _OPERATION_CONTRACT_ATTR:
|
|
413
|
+
continue
|
|
414
|
+
name_arg = dec.arguments.get("Name") if dec.arguments else None
|
|
415
|
+
if isinstance(name_arg, str) and name_arg:
|
|
416
|
+
return name_arg
|
|
417
|
+
# Strip `Begin` prefix on async-pattern operations: `BeginGetOrder`
|
|
418
|
+
# → `GetOrder` (the SOAP wire operation name).
|
|
419
|
+
if method.name.startswith("Begin") and self._is_async_pattern(method):
|
|
420
|
+
return method.name[len("Begin") :]
|
|
421
|
+
return method.name
|
|
422
|
+
|
|
423
|
+
# -------------------------------------------------------------------------
|
|
424
|
+
# OperationContract flag accessors
|
|
425
|
+
# -------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
def _is_one_way(self, method: ParsedFunction) -> bool:
|
|
428
|
+
for dec in method.decorators:
|
|
429
|
+
if dec.name != _OPERATION_CONTRACT_ATTR:
|
|
430
|
+
continue
|
|
431
|
+
val = dec.arguments.get("IsOneWay") if dec.arguments else None
|
|
432
|
+
if _is_truthy(val):
|
|
433
|
+
return True
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
def _is_async_pattern(self, method: ParsedFunction) -> bool:
|
|
437
|
+
"""[OperationContract(AsyncPattern = true)] — the Begin/End style."""
|
|
438
|
+
for dec in method.decorators:
|
|
439
|
+
if dec.name != _OPERATION_CONTRACT_ATTR:
|
|
440
|
+
continue
|
|
441
|
+
val = dec.arguments.get("AsyncPattern") if dec.arguments else None
|
|
442
|
+
if _is_truthy(val):
|
|
443
|
+
return True
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
def _extract_principal_permission_roles(
|
|
447
|
+
self,
|
|
448
|
+
method: ParsedFunction,
|
|
449
|
+
) -> list[str]:
|
|
450
|
+
"""
|
|
451
|
+
Collect `Role` arguments from every `[PrincipalPermission]` on the
|
|
452
|
+
method. WCF semantics: multiple attributes form an OR — the caller
|
|
453
|
+
must be in *any* of the listed roles. We return the union, in
|
|
454
|
+
deterministic order (preserves the order the developer wrote them).
|
|
455
|
+
"""
|
|
456
|
+
roles: list[str] = []
|
|
457
|
+
seen: set[str] = set()
|
|
458
|
+
for dec in method.decorators:
|
|
459
|
+
if dec.name != _PRINCIPAL_PERMISSION_ATTR:
|
|
460
|
+
continue
|
|
461
|
+
role_arg = dec.arguments.get("Role") if dec.arguments else None
|
|
462
|
+
if isinstance(role_arg, str) and role_arg and role_arg not in seen:
|
|
463
|
+
roles.append(role_arg)
|
|
464
|
+
seen.add(role_arg)
|
|
465
|
+
return roles
|
|
466
|
+
|
|
467
|
+
def _extract_fault_types(self, method: ParsedFunction) -> list[str]:
|
|
468
|
+
"""
|
|
469
|
+
Collect referenced types from every `[FaultContract(typeof(X))]`.
|
|
470
|
+
|
|
471
|
+
The C# parser surfaces the `typeof(X)` expression as a string-typed
|
|
472
|
+
positional argument. Extract the inner type name; preserve order
|
|
473
|
+
across multiple [FaultContract] attributes.
|
|
474
|
+
"""
|
|
475
|
+
faults: list[str] = []
|
|
476
|
+
seen: set[str] = set()
|
|
477
|
+
for dec in method.decorators:
|
|
478
|
+
if dec.name != _FAULT_CONTRACT_ATTR:
|
|
479
|
+
continue
|
|
480
|
+
candidate: object | None = None
|
|
481
|
+
if dec.positional_args:
|
|
482
|
+
candidate = dec.positional_args[0]
|
|
483
|
+
elif dec.arguments:
|
|
484
|
+
candidate = dec.arguments.get("detailType") or dec.arguments.get("DetailType")
|
|
485
|
+
if not isinstance(candidate, str):
|
|
486
|
+
continue
|
|
487
|
+
match = _TYPEOF_RE.search(candidate)
|
|
488
|
+
type_name = match.group(1) if match else candidate.strip()
|
|
489
|
+
if type_name and type_name not in seen:
|
|
490
|
+
faults.append(type_name)
|
|
491
|
+
seen.add(type_name)
|
|
492
|
+
return faults
|
|
493
|
+
|
|
494
|
+
# -------------------------------------------------------------------------
|
|
495
|
+
# Per-operation emission
|
|
496
|
+
# -------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
def _extract_operation_route(
|
|
499
|
+
self,
|
|
500
|
+
method: ParsedFunction,
|
|
501
|
+
cls: ParsedClass,
|
|
502
|
+
service_name: str,
|
|
503
|
+
endpoint: _WcfEndpoint | None = None,
|
|
504
|
+
) -> ExtractedRoute | None:
|
|
505
|
+
operation_name = self._extract_operation_name(method)
|
|
506
|
+
is_one_way = self._is_one_way(method)
|
|
507
|
+
|
|
508
|
+
# REST-style WCF (WebHttpBinding) overrides SOAP defaults:
|
|
509
|
+
# `[WebGet]` / `[WebInvoke]` provide an HTTP verb and URI template
|
|
510
|
+
# that replace the conventional `/{ServiceName}/{Operation}` path.
|
|
511
|
+
web_route = self._extract_web_route_info(method, operation_name)
|
|
512
|
+
if web_route is not None:
|
|
513
|
+
http_method, template = web_route
|
|
514
|
+
path = self._apply_endpoint_to_rest(template, endpoint)
|
|
515
|
+
kind = "http"
|
|
516
|
+
tags = self._compose_tags("wcf", "rest", endpoint=endpoint)
|
|
517
|
+
else:
|
|
518
|
+
http_method = HttpMethod.POST
|
|
519
|
+
path = self._apply_endpoint_to_soap(service_name, operation_name, endpoint)
|
|
520
|
+
kind = "wcf_oneway" if is_one_way else "wcf_soap"
|
|
521
|
+
tags = self._compose_tags("wcf", "soap", endpoint=endpoint)
|
|
522
|
+
|
|
523
|
+
# [FaultContract(typeof(X))] → `fault:X` tag. Multiple are common.
|
|
524
|
+
for fault_type in self._extract_fault_types(method):
|
|
525
|
+
tags.append(f"fault:{fault_type}")
|
|
526
|
+
|
|
527
|
+
# [PrincipalPermission(..., Role="X")] → dependency_refs pointing
|
|
528
|
+
# at the auth dependency emitted by extract_auth_dependencies. The
|
|
529
|
+
# dep name is the method's fully-qualified name (same convention
|
|
530
|
+
# as MVC's [Authorize] handling).
|
|
531
|
+
dependency_refs: list[str] = []
|
|
532
|
+
if self._extract_principal_permission_roles(method):
|
|
533
|
+
handler_qname = method.qualified_name or QualifiedName(
|
|
534
|
+
module=cls.qualified_name.module if cls.qualified_name else "",
|
|
535
|
+
name=f"{cls.name}.{method.name}",
|
|
536
|
+
)
|
|
537
|
+
dependency_refs.append(handler_qname.full)
|
|
538
|
+
|
|
539
|
+
path_params, query_params, body = self._extract_params(
|
|
540
|
+
method,
|
|
541
|
+
path,
|
|
542
|
+
is_rest=web_route is not None,
|
|
543
|
+
)
|
|
544
|
+
if body is None and web_route is None:
|
|
545
|
+
# SOAP body fallback (REST handles its own body inference above).
|
|
546
|
+
body = self._extract_body(method)
|
|
547
|
+
|
|
548
|
+
response = (
|
|
549
|
+
None
|
|
550
|
+
if is_one_way and web_route is None
|
|
551
|
+
else ExtractedResponse(
|
|
552
|
+
status_code=200,
|
|
553
|
+
model_name=self._extract_response_model(method),
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
handler_qname = method.qualified_name or QualifiedName(
|
|
558
|
+
module=cls.qualified_name.module if cls.qualified_name else "",
|
|
559
|
+
name=f"{cls.name}.{method.name}",
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
return ExtractedRoute(
|
|
563
|
+
method=http_method,
|
|
564
|
+
path=path,
|
|
565
|
+
handler_function=handler_qname,
|
|
566
|
+
handler_location=method.location,
|
|
567
|
+
path_params=path_params,
|
|
568
|
+
query_params=query_params,
|
|
569
|
+
header_params=[],
|
|
570
|
+
cookie_params=[],
|
|
571
|
+
body=body,
|
|
572
|
+
response=response
|
|
573
|
+
if response is not None
|
|
574
|
+
# one-way still needs *some* response shape for the dataclass;
|
|
575
|
+
# 202 Accepted is the closest SOAP equivalent ("acknowledged,
|
|
576
|
+
# no reply").
|
|
577
|
+
else ExtractedResponse(status_code=202, model_name=None),
|
|
578
|
+
tags=tags,
|
|
579
|
+
dependency_refs=dependency_refs,
|
|
580
|
+
confidence=Confidence.HIGH,
|
|
581
|
+
kind=kind,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
# -------------------------------------------------------------------------
|
|
585
|
+
# REST (WebHttpBinding) extraction
|
|
586
|
+
# -------------------------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
def _extract_web_route_info(
|
|
589
|
+
self,
|
|
590
|
+
method: ParsedFunction,
|
|
591
|
+
operation_name: str,
|
|
592
|
+
) -> tuple[HttpMethod, str] | None:
|
|
593
|
+
"""
|
|
594
|
+
If `method` carries `[WebGet]` or `[WebInvoke]`, return the
|
|
595
|
+
resolved (HttpMethod, normalised path). Otherwise None.
|
|
596
|
+
"""
|
|
597
|
+
for dec in method.decorators:
|
|
598
|
+
if dec.name == _WEB_GET_ATTR:
|
|
599
|
+
uri = dec.arguments.get("UriTemplate") if dec.arguments else None
|
|
600
|
+
return HttpMethod.GET, self._normalize_uri_template(uri, operation_name)
|
|
601
|
+
|
|
602
|
+
if dec.name == _WEB_INVOKE_ATTR:
|
|
603
|
+
args = dec.arguments or {}
|
|
604
|
+
method_arg = args.get("Method")
|
|
605
|
+
verb_name = (
|
|
606
|
+
method_arg.upper() if isinstance(method_arg, str) and method_arg else "POST"
|
|
607
|
+
)
|
|
608
|
+
http_method = _WEB_INVOKE_METHOD_MAP.get(verb_name, HttpMethod.POST)
|
|
609
|
+
uri = args.get("UriTemplate")
|
|
610
|
+
return http_method, self._normalize_uri_template(uri, operation_name)
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
def _normalize_uri_template(
|
|
614
|
+
self,
|
|
615
|
+
uri: object,
|
|
616
|
+
operation_name: str,
|
|
617
|
+
) -> str:
|
|
618
|
+
"""
|
|
619
|
+
WCF default when UriTemplate is absent: the operation name. When
|
|
620
|
+
present, ensure a leading slash and preserve the rest verbatim
|
|
621
|
+
(`{param}` and `?key={value}` portions are handled downstream).
|
|
622
|
+
"""
|
|
623
|
+
if isinstance(uri, str) and uri:
|
|
624
|
+
return uri if uri.startswith("/") else "/" + uri
|
|
625
|
+
return "/" + operation_name
|
|
626
|
+
|
|
627
|
+
def _extract_params(
|
|
628
|
+
self,
|
|
629
|
+
method: ParsedFunction,
|
|
630
|
+
path: str,
|
|
631
|
+
is_rest: bool,
|
|
632
|
+
) -> tuple[list[ExtractedParameter], list[ExtractedParameter], ExtractedBody | None]:
|
|
633
|
+
"""
|
|
634
|
+
For REST routes, classify method parameters by where they appear in
|
|
635
|
+
the URI template. For SOAP, return empty lists — the body is the
|
|
636
|
+
whole request and is handled by `_extract_body`.
|
|
637
|
+
"""
|
|
638
|
+
if not is_rest:
|
|
639
|
+
return [], [], None
|
|
640
|
+
|
|
641
|
+
path_part, _, query_part = path.partition("?")
|
|
642
|
+
path_names = set(_URI_TEMPLATE_PARAM_RE.findall(path_part))
|
|
643
|
+
query_names = set(_URI_TEMPLATE_PARAM_RE.findall(query_part))
|
|
644
|
+
|
|
645
|
+
path_params: list[ExtractedParameter] = []
|
|
646
|
+
query_params: list[ExtractedParameter] = []
|
|
647
|
+
body: ExtractedBody | None = None
|
|
648
|
+
|
|
649
|
+
for param in method.parameters:
|
|
650
|
+
type_name = (param.type_annotation or "").strip()
|
|
651
|
+
simple = type_name.split("<")[0].strip().rstrip("?").rsplit(".", 1)[-1]
|
|
652
|
+
if simple in _PLUMBING_PARAM_TYPES:
|
|
653
|
+
continue
|
|
654
|
+
|
|
655
|
+
if param.name in path_names:
|
|
656
|
+
path_params.append(
|
|
657
|
+
ExtractedParameter(
|
|
658
|
+
name=param.name,
|
|
659
|
+
location=ParameterLocation.PATH,
|
|
660
|
+
type_annotation=param.type_annotation,
|
|
661
|
+
required=True,
|
|
662
|
+
code_location=param.location,
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
elif param.name in query_names:
|
|
666
|
+
query_params.append(
|
|
667
|
+
ExtractedParameter(
|
|
668
|
+
name=param.name,
|
|
669
|
+
location=ParameterLocation.QUERY,
|
|
670
|
+
type_annotation=param.type_annotation,
|
|
671
|
+
required=(param.default_value is None),
|
|
672
|
+
default_value=param.default_value,
|
|
673
|
+
code_location=param.location,
|
|
674
|
+
)
|
|
675
|
+
)
|
|
676
|
+
elif body is None:
|
|
677
|
+
# Any remaining non-plumbing parameter is the request body —
|
|
678
|
+
# which only makes sense for POST/PUT/PATCH in REST.
|
|
679
|
+
body = ExtractedBody(
|
|
680
|
+
content_type="application/xml",
|
|
681
|
+
model_name=type_name or None,
|
|
682
|
+
required=True,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
return path_params, query_params, body
|
|
686
|
+
|
|
687
|
+
def _extract_body(self, method: ParsedFunction) -> ExtractedBody | None:
|
|
688
|
+
"""
|
|
689
|
+
First parameter whose type isn't WCF plumbing becomes the SOAP body.
|
|
690
|
+
|
|
691
|
+
WCF can also use `[MessageContract]` types or accept `Message` directly,
|
|
692
|
+
but for first-pass extraction the "first user-defined parameter" rule
|
|
693
|
+
catches the canonical pattern.
|
|
694
|
+
"""
|
|
695
|
+
for param in method.parameters:
|
|
696
|
+
type_name = (param.type_annotation or "").strip()
|
|
697
|
+
if not type_name:
|
|
698
|
+
continue
|
|
699
|
+
simple = type_name.split("<")[0].strip().rstrip("?").rsplit(".", 1)[-1]
|
|
700
|
+
if simple in _PLUMBING_PARAM_TYPES:
|
|
701
|
+
continue
|
|
702
|
+
return ExtractedBody(
|
|
703
|
+
content_type="application/soap+xml",
|
|
704
|
+
model_name=type_name,
|
|
705
|
+
required=True,
|
|
706
|
+
)
|
|
707
|
+
return None
|
|
708
|
+
|
|
709
|
+
def _extract_response_model(self, method: ParsedFunction) -> str | None:
|
|
710
|
+
"""Unwrap `Task<T>` / `IAsyncResult` / `void` to the actual response type."""
|
|
711
|
+
ret = (method.return_type or "").strip()
|
|
712
|
+
if not ret or ret in ("void", "Task", "ValueTask", "IAsyncResult"):
|
|
713
|
+
return None
|
|
714
|
+
for wrapper in ("Task<", "ValueTask<"):
|
|
715
|
+
if ret.startswith(wrapper) and ret.endswith(">"):
|
|
716
|
+
inner = ret[len(wrapper) : -1].strip()
|
|
717
|
+
return inner or None
|
|
718
|
+
return ret
|
|
719
|
+
|
|
720
|
+
# -------------------------------------------------------------------------
|
|
721
|
+
# Config-derived endpoint resolution
|
|
722
|
+
# -------------------------------------------------------------------------
|
|
723
|
+
|
|
724
|
+
def _load_endpoint_map(
|
|
725
|
+
self,
|
|
726
|
+
context: AnalysisContext | None,
|
|
727
|
+
) -> dict[str, list[_WcfEndpoint]]:
|
|
728
|
+
"""
|
|
729
|
+
Build `{contract_FQN: [_WcfEndpoint, ...]}` from every Web.config /
|
|
730
|
+
App.config and CoreWCF Startup.cs / Program.cs under the project
|
|
731
|
+
root. Cached per AnalysisContext.
|
|
732
|
+
|
|
733
|
+
XML config is authoritative: when a contract has endpoints declared
|
|
734
|
+
in both XML and programmatic CoreWCF registration, the XML entries
|
|
735
|
+
are used (legacy WCF deployments use XML as the deployment-time
|
|
736
|
+
config; CoreWCF hosts use programmatic, but rarely both at once).
|
|
737
|
+
|
|
738
|
+
Returns {} when no context is provided (unit-test invocation) — the
|
|
739
|
+
plugin then emits placeholder paths from PR A/B behaviour.
|
|
740
|
+
"""
|
|
741
|
+
if context is None or context.project_root is None:
|
|
742
|
+
return {}
|
|
743
|
+
# Cache key: resolved absolute path of the project root. Stable
|
|
744
|
+
# across `AnalysisContext` instances; avoids the `id(context)`
|
|
745
|
+
# memory-recycling hazard.
|
|
746
|
+
try:
|
|
747
|
+
key = str(Path(context.project_root).resolve())
|
|
748
|
+
except OSError:
|
|
749
|
+
key = str(context.project_root)
|
|
750
|
+
if key not in self._endpoint_cache:
|
|
751
|
+
xml_map = _scan_config_files(context.project_root)
|
|
752
|
+
startup_map = _scan_startup_files(context.project_root)
|
|
753
|
+
merged = dict(xml_map)
|
|
754
|
+
for contract, eps in startup_map.items():
|
|
755
|
+
merged.setdefault(contract, eps)
|
|
756
|
+
# Dedupe within each contract by full _WcfEndpoint equality
|
|
757
|
+
# (address + binding + base_address). CoreWCF samples commonly
|
|
758
|
+
# have multiple Startup.cs files each registering the same
|
|
759
|
+
# contract at the same address — those collapse to one route
|
|
760
|
+
# here, not one route per registration call.
|
|
761
|
+
self._endpoint_cache[key] = {
|
|
762
|
+
contract: _dedupe_preserve_order(eps) for contract, eps in merged.items()
|
|
763
|
+
}
|
|
764
|
+
return self._endpoint_cache[key]
|
|
765
|
+
|
|
766
|
+
def _contract_is_registered_elsewhere(
|
|
767
|
+
self,
|
|
768
|
+
cls: ParsedClass,
|
|
769
|
+
endpoint_map: dict[str, list[_WcfEndpoint]],
|
|
770
|
+
) -> bool:
|
|
771
|
+
"""Return True iff some endpoint anywhere in the map registers this
|
|
772
|
+
contract — used to distinguish client-side interface declarations
|
|
773
|
+
(no registrations in their own project, but the contract IS
|
|
774
|
+
registered in the server project) from genuinely standalone
|
|
775
|
+
contracts (no registrations anywhere). Independent of
|
|
776
|
+
project_root scoping."""
|
|
777
|
+
if not endpoint_map:
|
|
778
|
+
return False
|
|
779
|
+
if cls.qualified_name and cls.qualified_name.full in endpoint_map:
|
|
780
|
+
return True
|
|
781
|
+
if cls.name in endpoint_map:
|
|
782
|
+
return True
|
|
783
|
+
return any(fqn.rsplit(".", 1)[-1] == cls.name for fqn in endpoint_map)
|
|
784
|
+
|
|
785
|
+
def _endpoints_for_contract(
|
|
786
|
+
self,
|
|
787
|
+
cls: ParsedClass,
|
|
788
|
+
endpoint_map: dict[str, list[_WcfEndpoint]],
|
|
789
|
+
file_project_root: str | None = None,
|
|
790
|
+
) -> list[_WcfEndpoint]:
|
|
791
|
+
"""
|
|
792
|
+
Match a parsed `[ServiceContract]` class to its config endpoints,
|
|
793
|
+
restricted to endpoints registered in the same project.
|
|
794
|
+
|
|
795
|
+
Lookup order (each step filtered by project_root):
|
|
796
|
+
1. Fully-qualified name (Web.config typically uses FQN)
|
|
797
|
+
2. Unqualified class name (Startup.cs uses C# typeof short form)
|
|
798
|
+
3. Suffix match — any map key whose simple-name segment matches
|
|
799
|
+
|
|
800
|
+
`file_project_root` is the contract file's enclosing `.csproj`
|
|
801
|
+
directory. An endpoint with `project_root=None` is treated as
|
|
802
|
+
globally applicable (no .csproj in unit-test setups; legacy
|
|
803
|
+
deployments where Web.config sits outside any project). This
|
|
804
|
+
prevents same-simple-name interfaces in different projects from
|
|
805
|
+
sharing each other's registrations.
|
|
806
|
+
"""
|
|
807
|
+
if not endpoint_map:
|
|
808
|
+
return []
|
|
809
|
+
|
|
810
|
+
def _scope(eps: list[_WcfEndpoint]) -> list[_WcfEndpoint]:
|
|
811
|
+
return [
|
|
812
|
+
ep
|
|
813
|
+
for ep in eps
|
|
814
|
+
if ep.project_root is None
|
|
815
|
+
or file_project_root is None
|
|
816
|
+
or ep.project_root == file_project_root
|
|
817
|
+
]
|
|
818
|
+
|
|
819
|
+
if cls.qualified_name:
|
|
820
|
+
fqn = cls.qualified_name.full
|
|
821
|
+
if fqn in endpoint_map:
|
|
822
|
+
scoped = _scope(endpoint_map[fqn])
|
|
823
|
+
if scoped:
|
|
824
|
+
return scoped
|
|
825
|
+
if cls.name in endpoint_map:
|
|
826
|
+
scoped = _scope(endpoint_map[cls.name])
|
|
827
|
+
if scoped:
|
|
828
|
+
return scoped
|
|
829
|
+
# Suffix match across map keys.
|
|
830
|
+
for fqn, eps in endpoint_map.items():
|
|
831
|
+
if fqn.rsplit(".", 1)[-1] == cls.name:
|
|
832
|
+
scoped = _scope(eps)
|
|
833
|
+
if scoped:
|
|
834
|
+
return scoped
|
|
835
|
+
return []
|
|
836
|
+
|
|
837
|
+
def _apply_endpoint_to_soap(
|
|
838
|
+
self,
|
|
839
|
+
service_name: str,
|
|
840
|
+
operation_name: str,
|
|
841
|
+
endpoint: _WcfEndpoint | None,
|
|
842
|
+
) -> str:
|
|
843
|
+
"""SOAP path: replace `/{ServiceName}/{Operation}` with `<address>/{Operation}`."""
|
|
844
|
+
if endpoint is None:
|
|
845
|
+
return f"/{service_name}/{operation_name}"
|
|
846
|
+
if _is_full_url(endpoint.address):
|
|
847
|
+
return endpoint.address.rstrip("/") + "/" + operation_name
|
|
848
|
+
address = endpoint.address.strip("/")
|
|
849
|
+
if not address:
|
|
850
|
+
return f"/{operation_name}"
|
|
851
|
+
return f"/{address}/{operation_name}"
|
|
852
|
+
|
|
853
|
+
def _apply_endpoint_to_rest(
|
|
854
|
+
self,
|
|
855
|
+
uri_template: str,
|
|
856
|
+
endpoint: _WcfEndpoint | None,
|
|
857
|
+
) -> str:
|
|
858
|
+
"""REST path: prepend the endpoint address to the UriTemplate."""
|
|
859
|
+
if endpoint is None:
|
|
860
|
+
return uri_template
|
|
861
|
+
if _is_full_url(endpoint.address):
|
|
862
|
+
base = endpoint.address.rstrip("/")
|
|
863
|
+
return base + (uri_template if uri_template.startswith("/") else "/" + uri_template)
|
|
864
|
+
address = endpoint.address.strip("/")
|
|
865
|
+
if not address:
|
|
866
|
+
return uri_template
|
|
867
|
+
template = uri_template if uri_template.startswith("/") else "/" + uri_template
|
|
868
|
+
return f"/{address}{template}"
|
|
869
|
+
|
|
870
|
+
def _compose_tags(
|
|
871
|
+
self,
|
|
872
|
+
*base_tags: str,
|
|
873
|
+
endpoint: _WcfEndpoint | None = None,
|
|
874
|
+
) -> list[str]:
|
|
875
|
+
"""Build the route tag list, tagging the binding when known."""
|
|
876
|
+
tags = list(base_tags)
|
|
877
|
+
if endpoint and endpoint.binding:
|
|
878
|
+
tags.append(f"binding:{endpoint.binding}")
|
|
879
|
+
return tags
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
# =============================================================================
|
|
883
|
+
# Config file scanner
|
|
884
|
+
# =============================================================================
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _scan_config_files(root: Path) -> dict[str, list[_WcfEndpoint]]:
|
|
888
|
+
"""
|
|
889
|
+
Walk `root` for `Web.config` / `App.config` files and extract
|
|
890
|
+
`<system.serviceModel>` endpoint registrations.
|
|
891
|
+
|
|
892
|
+
Returns `{contract_full_name: [_WcfEndpoint, ...]}`. Multiple
|
|
893
|
+
endpoints per contract are common (one binding for SOAP, another for
|
|
894
|
+
REST/JSON, etc.) — preserve all of them in registration order.
|
|
895
|
+
"""
|
|
896
|
+
mapping: dict[str, list[_WcfEndpoint]] = {}
|
|
897
|
+
try:
|
|
898
|
+
config_paths = sorted(
|
|
899
|
+
{
|
|
900
|
+
*root.rglob("Web.config"),
|
|
901
|
+
*root.rglob("App.config"),
|
|
902
|
+
}
|
|
903
|
+
)
|
|
904
|
+
except (OSError, ValueError):
|
|
905
|
+
return mapping
|
|
906
|
+
|
|
907
|
+
for path in config_paths:
|
|
908
|
+
try:
|
|
909
|
+
tree = ET.parse(path)
|
|
910
|
+
except (ET.ParseError, OSError):
|
|
911
|
+
continue
|
|
912
|
+
# XML config endpoints are deployment-time configuration that
|
|
913
|
+
# applies cross-project: traditional .NET Framework WCF puts
|
|
914
|
+
# the contract interface in one assembly (`*.Contracts.csproj`)
|
|
915
|
+
# and hosts it from a separate project's App.config (e.g. a
|
|
916
|
+
# ConsoleHost / WindowsService). Leaving `project_root=None`
|
|
917
|
+
# lets `_endpoints_for_contract` apply these endpoints to
|
|
918
|
+
# contracts in any project. Programmatic endpoints stay
|
|
919
|
+
# project-scoped (see `_scan_startup_files`).
|
|
920
|
+
for service in tree.getroot().iter("service"):
|
|
921
|
+
base_addresses = [
|
|
922
|
+
add.get("baseAddress", "")
|
|
923
|
+
for add in service.findall("./host/baseAddresses/add")
|
|
924
|
+
if add.get("baseAddress")
|
|
925
|
+
]
|
|
926
|
+
base_addr = base_addresses[0] if base_addresses else None
|
|
927
|
+
for endpoint in service.findall("./endpoint"):
|
|
928
|
+
contract = endpoint.get("contract")
|
|
929
|
+
if not contract:
|
|
930
|
+
continue
|
|
931
|
+
ep = _WcfEndpoint(
|
|
932
|
+
address=endpoint.get("address", "") or "",
|
|
933
|
+
binding=endpoint.get("binding", "") or "",
|
|
934
|
+
base_address=base_addr,
|
|
935
|
+
project_root=None,
|
|
936
|
+
)
|
|
937
|
+
mapping.setdefault(contract, []).append(ep)
|
|
938
|
+
return mapping
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _is_full_url(s: str) -> bool:
|
|
942
|
+
return s.startswith("http://") or s.startswith("https://") or s.startswith("net.tcp://")
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _find_enclosing_csproj(path: Path | None) -> str | None:
|
|
946
|
+
"""Walk up `path`'s ancestors looking for a sibling `*.csproj` file.
|
|
947
|
+
Returns the containing directory as a string, or None when no
|
|
948
|
+
project file is found (unit-test setups, files outside any
|
|
949
|
+
project)."""
|
|
950
|
+
if path is None:
|
|
951
|
+
return None
|
|
952
|
+
try:
|
|
953
|
+
current = path.parent if path.is_file() else path
|
|
954
|
+
except OSError:
|
|
955
|
+
return None
|
|
956
|
+
with contextlib.suppress(OSError):
|
|
957
|
+
current = current.resolve()
|
|
958
|
+
while True:
|
|
959
|
+
try:
|
|
960
|
+
if any(current.glob("*.csproj")):
|
|
961
|
+
return str(current)
|
|
962
|
+
except OSError:
|
|
963
|
+
return None
|
|
964
|
+
if current.parent == current:
|
|
965
|
+
return None
|
|
966
|
+
current = current.parent
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _dedupe_preserve_order(items: list[_WcfEndpoint]) -> list[_WcfEndpoint]:
|
|
970
|
+
"""Remove duplicate `_WcfEndpoint` entries while preserving the
|
|
971
|
+
first-seen order — keeps deterministic output across runs.
|
|
972
|
+
|
|
973
|
+
Dedup key:
|
|
974
|
+
- address (lower-cased — CoreWCF / IIS route case-insensitively)
|
|
975
|
+
- binding (lower-cased)
|
|
976
|
+
- project_root (so programmatic endpoints from different projects
|
|
977
|
+
with same address don't collapse before scope-filtering)
|
|
978
|
+
|
|
979
|
+
`base_address` is intentionally NOT part of the key. Two endpoints
|
|
980
|
+
sharing (address, binding) but declared in different host projects'
|
|
981
|
+
`<host><baseAddresses>` blocks at different ports/hostnames produce
|
|
982
|
+
the same route URL (`_apply_endpoint_to_soap` uses `address`, not
|
|
983
|
+
`base_address`) — they're the same logical route surface, just hosted
|
|
984
|
+
on different ports/URLs. Real exposure: QIQO ships both a
|
|
985
|
+
`ConsoleHost/App.config` (port 7473) and a `WindowsServiceHost/App.config`
|
|
986
|
+
(port 7476) declaring the same endpoints.
|
|
987
|
+
"""
|
|
988
|
+
seen: set[tuple[str, str, str | None]] = set()
|
|
989
|
+
out: list[_WcfEndpoint] = []
|
|
990
|
+
for ep in items:
|
|
991
|
+
key = (
|
|
992
|
+
ep.address.lower(),
|
|
993
|
+
ep.binding.lower(),
|
|
994
|
+
ep.project_root,
|
|
995
|
+
)
|
|
996
|
+
if key not in seen:
|
|
997
|
+
seen.add(key)
|
|
998
|
+
out.append(ep)
|
|
999
|
+
return out
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
# =============================================================================
|
|
1003
|
+
# CoreWCF programmatic-endpoint scanner
|
|
1004
|
+
# =============================================================================
|
|
1005
|
+
#
|
|
1006
|
+
# CoreWCF hosts register endpoints in Startup.cs / Program.cs, not in
|
|
1007
|
+
# Web.config. Both REST (AddServiceWebEndpoint) and SOAP (AddServiceEndpoint)
|
|
1008
|
+
# share the same shape:
|
|
1009
|
+
#
|
|
1010
|
+
# builder.AddServiceWebEndpoint<TService, TContract>(
|
|
1011
|
+
# new WebHttpBinding { ... }, "api", behavior => { ... });
|
|
1012
|
+
#
|
|
1013
|
+
# We extract the contract type (2nd type parameter) and the address (2nd
|
|
1014
|
+
# positional argument, when a string literal). Non-literal addresses
|
|
1015
|
+
# (variables, configuration lookups) are out of scope for v1.
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
_ADD_ENDPOINT_RE = re.compile(
|
|
1019
|
+
r"\.(AddServiceWebEndpoint|AddServiceEndpoint)\s*"
|
|
1020
|
+
r"<\s*[\w.]+\s*,\s*([\w.]+)\s*>\s*\("
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
# Non-generic SOAP form used by `System.ServiceModel.ServiceHost`:
|
|
1024
|
+
# host.AddServiceEndpoint(contract, binding, "address");
|
|
1025
|
+
# where `contract` is either `typeof(X)` inline or a variable previously
|
|
1026
|
+
# assigned from `typeof(X)`. CoreWCF generic form covers most newer code;
|
|
1027
|
+
# this branch picks up legacy / `ServiceHost`-based hosting in
|
|
1028
|
+
# `NetFrameworkServer`-style samples.
|
|
1029
|
+
_NONGENERIC_ADD_ENDPOINT_RE = re.compile(
|
|
1030
|
+
r"\.AddServiceEndpoint\s*\(\s*"
|
|
1031
|
+
r"(typeof\s*\(\s*([\w.]+)\s*\)|[A-Za-z_]\w*)\s*,"
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
# `Type contract = typeof(Foo.IBar);` or `var contract = typeof(Foo.IBar);`
|
|
1035
|
+
# — used to resolve the contract type when AddServiceEndpoint is called
|
|
1036
|
+
# with a variable rather than an inline typeof.
|
|
1037
|
+
_TYPEOF_ASSIGN_RE = re.compile(r"\b(?:Type|var)\s+([A-Za-z_]\w*)\s*=\s*typeof\s*\(\s*([\w.]+)\s*\)")
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _scan_startup_files(root: Path) -> dict[str, list[_WcfEndpoint]]:
|
|
1041
|
+
"""
|
|
1042
|
+
Walk `root` for `.cs` files and extract `AddServiceWebEndpoint<T, C>(...)`
|
|
1043
|
+
and `AddServiceEndpoint<T, C>(...)` calls into the same shape used by
|
|
1044
|
+
XML config.
|
|
1045
|
+
|
|
1046
|
+
Returns `{contract_simple_name: [_WcfEndpoint, ...]}`. We key by the
|
|
1047
|
+
simple contract name because programmatic registration uses the
|
|
1048
|
+
`typeof(IFoo)` short form rather than the FQN — `_endpoints_for_contract`
|
|
1049
|
+
already handles both forms.
|
|
1050
|
+
"""
|
|
1051
|
+
mapping: dict[str, list[_WcfEndpoint]] = {}
|
|
1052
|
+
try:
|
|
1053
|
+
cs_paths = sorted(root.rglob("*.cs"))
|
|
1054
|
+
except (OSError, ValueError):
|
|
1055
|
+
return mapping
|
|
1056
|
+
for path in cs_paths:
|
|
1057
|
+
try:
|
|
1058
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1059
|
+
except OSError:
|
|
1060
|
+
continue
|
|
1061
|
+
# Cheap pre-filter: skip files that don't mention either method.
|
|
1062
|
+
if "AddServiceEndpoint" not in text and "AddServiceWebEndpoint" not in text:
|
|
1063
|
+
continue
|
|
1064
|
+
project_root = _find_enclosing_csproj(path)
|
|
1065
|
+
for method, contract, address in _iter_addservice_calls(text):
|
|
1066
|
+
binding = "webHttpBinding" if method == "AddServiceWebEndpoint" else ""
|
|
1067
|
+
ep = _WcfEndpoint(
|
|
1068
|
+
address=address,
|
|
1069
|
+
binding=binding,
|
|
1070
|
+
base_address=None,
|
|
1071
|
+
project_root=project_root,
|
|
1072
|
+
)
|
|
1073
|
+
mapping.setdefault(contract, []).append(ep)
|
|
1074
|
+
return mapping
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _iter_addservice_calls(source: str):
|
|
1078
|
+
"""
|
|
1079
|
+
Yield `(method_name, contract_simple_name, address_literal)` for each
|
|
1080
|
+
`AddService(Web)Endpoint(...)` call in `source`. Two shapes covered:
|
|
1081
|
+
|
|
1082
|
+
1. Generic form (CoreWCF):
|
|
1083
|
+
builder.AddServiceWebEndpoint<TService, TContract>(binding, "addr", ...)
|
|
1084
|
+
builder.AddServiceEndpoint<TService, TContract>(binding, "addr")
|
|
1085
|
+
|
|
1086
|
+
2. Non-generic form (`System.ServiceModel.ServiceHost`):
|
|
1087
|
+
host.AddServiceEndpoint(contract, binding, "addr")
|
|
1088
|
+
— where `contract` is `typeof(X)` inline or a local variable
|
|
1089
|
+
previously assigned from `typeof(X)`.
|
|
1090
|
+
|
|
1091
|
+
Skips calls where the address is not a plain string literal —
|
|
1092
|
+
interpolated strings and variable references need richer analysis.
|
|
1093
|
+
"""
|
|
1094
|
+
for match in _ADD_ENDPOINT_RE.finditer(source):
|
|
1095
|
+
contract = match.group(2)
|
|
1096
|
+
address = _second_positional_string(source, match.end())
|
|
1097
|
+
if address is None:
|
|
1098
|
+
continue
|
|
1099
|
+
yield match.group(1), contract, address
|
|
1100
|
+
|
|
1101
|
+
# Non-generic form — resolve typeof variables one pass per file.
|
|
1102
|
+
var_to_type: dict[str, str] = {}
|
|
1103
|
+
for m in _TYPEOF_ASSIGN_RE.finditer(source):
|
|
1104
|
+
var_to_type[m.group(1)] = m.group(2).rsplit(".", 1)[-1]
|
|
1105
|
+
|
|
1106
|
+
for match in _NONGENERIC_ADD_ENDPOINT_RE.finditer(source):
|
|
1107
|
+
# Skip when this is actually a generic call — `AddServiceEndpoint<T,C>(`
|
|
1108
|
+
# would also match the bare `(` portion otherwise.
|
|
1109
|
+
head = source[match.start() : match.end()]
|
|
1110
|
+
if "<" in head:
|
|
1111
|
+
continue
|
|
1112
|
+
inline_typeof = match.group(2)
|
|
1113
|
+
var_name = match.group(1)
|
|
1114
|
+
if inline_typeof:
|
|
1115
|
+
contract = inline_typeof.rsplit(".", 1)[-1]
|
|
1116
|
+
else:
|
|
1117
|
+
contract = var_to_type.get(var_name)
|
|
1118
|
+
if contract is None:
|
|
1119
|
+
continue
|
|
1120
|
+
# The regex already consumed `(<contract>,` — cursor is at the start
|
|
1121
|
+
# of the binding arg. `_second_positional_string` skips one arg and
|
|
1122
|
+
# returns the next string literal — which here is the address.
|
|
1123
|
+
address = _second_positional_string(source, match.end())
|
|
1124
|
+
if address is None:
|
|
1125
|
+
continue
|
|
1126
|
+
yield "AddServiceEndpoint", contract, address
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _second_positional_string(source: str, start: int) -> str | None:
|
|
1130
|
+
"""
|
|
1131
|
+
Given `source` positioned just past the opening `(` of a method call,
|
|
1132
|
+
return the contents of the second positional argument iff it is a plain
|
|
1133
|
+
`"..."` string literal. Returns None for any other case
|
|
1134
|
+
(verbatim strings, interpolated strings, non-literals, only one arg).
|
|
1135
|
+
"""
|
|
1136
|
+
depth = 1
|
|
1137
|
+
i = start
|
|
1138
|
+
in_str = False
|
|
1139
|
+
in_chr = False
|
|
1140
|
+
in_line_comment = False
|
|
1141
|
+
in_block_comment = False
|
|
1142
|
+
# Phase 1: scan past the first argument to the first top-level comma.
|
|
1143
|
+
while i < len(source):
|
|
1144
|
+
c = source[i]
|
|
1145
|
+
nxt = source[i + 1] if i + 1 < len(source) else ""
|
|
1146
|
+
if in_line_comment:
|
|
1147
|
+
if c == "\n":
|
|
1148
|
+
in_line_comment = False
|
|
1149
|
+
i += 1
|
|
1150
|
+
continue
|
|
1151
|
+
if in_block_comment:
|
|
1152
|
+
if c == "*" and nxt == "/":
|
|
1153
|
+
in_block_comment = False
|
|
1154
|
+
i += 2
|
|
1155
|
+
continue
|
|
1156
|
+
i += 1
|
|
1157
|
+
continue
|
|
1158
|
+
if in_str:
|
|
1159
|
+
if c == "\\":
|
|
1160
|
+
i += 2
|
|
1161
|
+
continue
|
|
1162
|
+
if c == '"':
|
|
1163
|
+
in_str = False
|
|
1164
|
+
i += 1
|
|
1165
|
+
continue
|
|
1166
|
+
if in_chr:
|
|
1167
|
+
if c == "\\":
|
|
1168
|
+
i += 2
|
|
1169
|
+
continue
|
|
1170
|
+
if c == "'":
|
|
1171
|
+
in_chr = False
|
|
1172
|
+
i += 1
|
|
1173
|
+
continue
|
|
1174
|
+
if c == "/" and nxt == "/":
|
|
1175
|
+
in_line_comment = True
|
|
1176
|
+
i += 2
|
|
1177
|
+
continue
|
|
1178
|
+
if c == "/" and nxt == "*":
|
|
1179
|
+
in_block_comment = True
|
|
1180
|
+
i += 2
|
|
1181
|
+
continue
|
|
1182
|
+
if c == '"':
|
|
1183
|
+
in_str = True
|
|
1184
|
+
i += 1
|
|
1185
|
+
continue
|
|
1186
|
+
if c == "'":
|
|
1187
|
+
in_chr = True
|
|
1188
|
+
i += 1
|
|
1189
|
+
continue
|
|
1190
|
+
if c in "([{":
|
|
1191
|
+
depth += 1
|
|
1192
|
+
elif c in ")]}":
|
|
1193
|
+
depth -= 1
|
|
1194
|
+
if depth == 0:
|
|
1195
|
+
return None # call ended before a second argument
|
|
1196
|
+
elif c == "," and depth == 1:
|
|
1197
|
+
i += 1
|
|
1198
|
+
break
|
|
1199
|
+
i += 1
|
|
1200
|
+
else:
|
|
1201
|
+
return None
|
|
1202
|
+
# Phase 2: parse the second positional as a C# string literal —
|
|
1203
|
+
# plain `"..."` or verbatim `@"..."` (where `""` is an embedded
|
|
1204
|
+
# quote, decoded back to a single `"` in the returned content).
|
|
1205
|
+
#
|
|
1206
|
+
# Interpolated `$"..."` (and the verbatim-interpolated `$@"..."` /
|
|
1207
|
+
# `@$"..."`) are intentionally deferred: emitting interpolated
|
|
1208
|
+
# templates with `{placeholder}` segments would create routes whose
|
|
1209
|
+
# paths can't match a resolved-value GT. Closing that gap is v7
|
|
1210
|
+
# charter work: needs a constant-resolution pass over `const string`
|
|
1211
|
+
# / `static readonly string` fields in scope, plus paired GT
|
|
1212
|
+
# updates for any newly-emitted resolved URLs.
|
|
1213
|
+
parsed = parse_csharp_string_literal(source, i)
|
|
1214
|
+
if parsed is None:
|
|
1215
|
+
return None
|
|
1216
|
+
content, _open, _after = parsed
|
|
1217
|
+
return content
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
# =============================================================================
|
|
1221
|
+
# Helpers
|
|
1222
|
+
# =============================================================================
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _is_truthy(val: object) -> bool:
|
|
1226
|
+
"""Accept Python True or the C# attribute-arg string `"true"` (case-insensitive)."""
|
|
1227
|
+
if val is True:
|
|
1228
|
+
return True
|
|
1229
|
+
if isinstance(val, str) and val.strip().lower() == "true":
|
|
1230
|
+
return True
|
|
1231
|
+
return False
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
# =============================================================================
|
|
1235
|
+
# Self-registration
|
|
1236
|
+
# =============================================================================
|
|
1237
|
+
|
|
1238
|
+
_wcf_plugin = WcfPlugin()
|
|
1239
|
+
FrameworkPluginRegistry.register(_wcf_plugin)
|