1use std::path::{Path, PathBuf};
8
9use crate::config::ScanPathFilter;
10use crate::error::Result;
11use crate::ir::execution_surface::{ExecutionSurface, NetworkOperation};
12use crate::ir::taint_builder::build_data_surface;
13use crate::ir::tool_surface::ToolSurface;
14use crate::ir::*;
15
16const OPENAPI_FILENAMES: &[&str] = &[
18 "openapi.json",
19 "openapi.yaml",
20 "openapi.yml",
21 "swagger.json",
22 "swagger.yaml",
23 "swagger.yml",
24];
25
26const PLUGIN_MANIFEST_FILENAMES: &[&str] = &["ai-plugin.json", "actions.json"];
28
29pub struct GptActionsAdapter;
38
39impl super::Adapter for GptActionsAdapter {
40 fn framework(&self) -> Framework {
41 Framework::GptActions
42 }
43
44 fn detect(&self, root: &Path) -> bool {
45 for filename in PLUGIN_MANIFEST_FILENAMES {
47 if root.join(filename).exists() {
48 return true;
49 }
50 }
51 if root.join(".well-known").join("ai-plugin.json").exists() {
52 return true;
53 }
54
55 for filename in OPENAPI_FILENAMES {
57 let path = root.join(filename);
58 if path.exists() {
59 if let Ok(content) = std::fs::read_to_string(&path) {
60 if content.contains("x-openai-") || content.contains("x-openai") {
61 return true;
62 }
63 if content.contains("\"openapi\"") || content.contains("openapi:") {
65 if has_plugin_manifest(root) {
67 return true;
68 }
69 }
70 }
71 }
72 }
73
74 false
75 }
76
77 fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
78 let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
79 self.load_with_filter(root, &filter)
80 }
81
82 fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
83 let name = root
84 .file_name()
85 .map(|n| n.to_string_lossy().to_string())
86 .unwrap_or_else(|| "gpt-actions".into());
87
88 let mut tools: Vec<ToolSurface> = Vec::new();
89 let mut execution = ExecutionSurface::default();
90
91 let spec_path = find_openapi_spec(root, filter);
93
94 if let Some(spec_path) = spec_path {
95 if let Ok(content) = std::fs::read_to_string(&spec_path) {
96 if let Ok(spec) = serde_json::from_str::<serde_json::Value>(&content) {
97 extract_server_urls(&spec, &spec_path, &mut execution);
99
100 extract_path_tools(&spec, &spec_path, &mut tools);
102 }
103 }
104 }
105
106 let source_files = collect_spec_source_files(root, filter);
107 let dependencies = super::mcp::parse_dependencies(root, filter);
108 let provenance = super::mcp::parse_provenance(root, filter);
109 let data = build_data_surface(&tools, &execution);
110
111 Ok(vec![ScanTarget {
112 name,
113 framework: Framework::GptActions,
114 root_path: root.to_path_buf(),
115 tools,
116 execution,
117 data,
118 dependencies,
119 provenance,
120 source_files,
121 }])
122 }
123}
124
125fn has_plugin_manifest(root: &Path) -> bool {
127 for filename in PLUGIN_MANIFEST_FILENAMES {
128 if root.join(filename).exists() {
129 return true;
130 }
131 }
132 root.join(".well-known").join("ai-plugin.json").exists()
133}
134
135fn find_openapi_spec(root: &Path, filter: &ScanPathFilter) -> Option<PathBuf> {
137 for filename in OPENAPI_FILENAMES {
138 let path = root.join(filename);
139 if path.exists() && filter.allows_path(root, &path) {
140 return Some(path);
141 }
142 }
143 None
144}
145
146fn extract_server_urls(
150 spec: &serde_json::Value,
151 spec_path: &Path,
152 execution: &mut ExecutionSurface,
153) {
154 let servers = match spec.get("servers").and_then(|v| v.as_array()) {
155 Some(s) => s,
156 None => return,
157 };
158
159 for (idx, server) in servers.iter().enumerate() {
160 let url = server
161 .get("url")
162 .and_then(|v| v.as_str())
163 .unwrap_or("")
164 .to_string();
165
166 if url.is_empty() {
167 continue;
168 }
169
170 execution.network_operations.push(NetworkOperation {
171 function: "openapi_server".to_string(),
172 url_arg: ArgumentSource::Literal(url),
173 method: None,
174 sends_data: false,
175 location: SourceLocation {
176 file: spec_path.to_path_buf(),
177 line: idx + 1,
179 column: 0,
180 end_line: None,
181 end_column: None,
182 },
183 });
184 }
185}
186
187fn extract_path_tools(spec: &serde_json::Value, spec_path: &Path, tools: &mut Vec<ToolSurface>) {
192 let paths = match spec.get("paths").and_then(|v| v.as_object()) {
193 Some(p) => p,
194 None => return,
195 };
196
197 const HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
198
199 for (path_str, path_item) in paths {
200 let path_obj = match path_item.as_object() {
201 Some(o) => o,
202 None => continue,
203 };
204
205 for method in HTTP_METHODS {
206 let operation = match path_obj.get(*method) {
207 Some(op) => op,
208 None => continue,
209 };
210
211 let tool_name = format!("{}_{}", method, path_str);
212 let description = operation
213 .get("summary")
214 .or_else(|| operation.get("description"))
215 .and_then(|v| v.as_str())
216 .map(|s| s.to_string());
217
218 let input_schema = build_input_schema_from_operation(operation);
219
220 tools.push(ToolSurface {
221 name: tool_name,
222 description,
223 input_schema: Some(input_schema),
224 output_schema: None,
225 declared_permissions: vec![],
226 defined_at: Some(SourceLocation {
227 file: spec_path.to_path_buf(),
228 line: 1,
229 column: 0,
230 end_line: None,
231 end_column: None,
232 }),
233 });
234 }
235 }
236}
237
238fn build_input_schema_from_operation(operation: &serde_json::Value) -> serde_json::Value {
241 let mut properties = serde_json::Map::new();
242 let mut required: Vec<serde_json::Value> = Vec::new();
243
244 if let Some(params) = operation.get("parameters").and_then(|v| v.as_array()) {
246 for param in params {
247 let name = match param.get("name").and_then(|v| v.as_str()) {
248 Some(n) => n,
249 None => continue,
250 };
251 let schema = param
252 .get("schema")
253 .cloned()
254 .unwrap_or_else(|| serde_json::json!({"type": "string"}));
255 properties.insert(name.to_string(), schema);
256
257 if param
258 .get("required")
259 .and_then(|v| v.as_bool())
260 .unwrap_or(false)
261 {
262 required.push(serde_json::Value::String(name.to_string()));
263 }
264 }
265 }
266
267 if let Some(rb_schema) = operation
269 .get("requestBody")
270 .and_then(|rb| rb.get("content"))
271 .and_then(|c| c.get("application/json"))
272 .and_then(|m| m.get("schema"))
273 {
274 if let Some(props) = rb_schema.get("properties").and_then(|v| v.as_object()) {
275 for (k, v) in props {
276 properties.insert(k.clone(), v.clone());
277 }
278 }
279 if let Some(req_arr) = rb_schema.get("required").and_then(|v| v.as_array()) {
280 required.extend(req_arr.iter().cloned());
281 }
282 }
283
284 let mut schema = serde_json::json!({
285 "type": "object",
286 "properties": serde_json::Value::Object(properties)
287 });
288 if !required.is_empty() {
289 schema["required"] = serde_json::Value::Array(required);
290 }
291 schema
292}
293
294fn collect_spec_source_files(root: &Path, filter: &ScanPathFilter) -> Vec<SourceFile> {
299 let mut files = Vec::new();
300
301 let candidates: Vec<PathBuf> = OPENAPI_FILENAMES
302 .iter()
303 .chain(PLUGIN_MANIFEST_FILENAMES.iter())
304 .map(|f| root.join(f))
305 .chain(std::iter::once(
306 root.join(".well-known").join("ai-plugin.json"),
307 ))
308 .collect();
309
310 for path in candidates {
311 if !path.exists() {
312 continue;
313 }
314 if !filter.allows_path(root, &path) {
315 continue;
316 }
317 let Ok(metadata) = std::fs::metadata(&path) else {
318 continue;
319 };
320 let Ok(content) = std::fs::read_to_string(&path) else {
321 continue;
322 };
323
324 let ext = path
325 .extension()
326 .map(|e| e.to_string_lossy().to_string())
327 .unwrap_or_default();
328 let lang = Language::from_extension(&ext);
329
330 let hash = format!(
331 "{:x}",
332 sha2::Digest::finalize(sha2::Sha256::new().chain_update(content.as_bytes()))
333 );
334
335 files.push(SourceFile {
336 path,
337 language: lang,
338 size_bytes: metadata.len(),
339 content_hash: hash,
340 content,
341 });
342 }
343
344 files
345}
346
347use sha2::Digest;
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::adapter::Adapter;
353
354 fn fixture_dir() -> PathBuf {
355 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/gpt_actions")
356 }
357
358 #[test]
359 fn test_detect_gpt_actions() {
360 let dir = fixture_dir();
361 let adapter = GptActionsAdapter;
362 assert!(
363 adapter.detect(&dir),
364 "should detect GPT Actions fixture with ai-plugin.json + openapi.json"
365 );
366 }
367
368 #[test]
369 fn test_detect_non_gpt_project() {
370 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
371 .join("tests/fixtures/mcp_servers/safe_calculator");
372 let adapter = GptActionsAdapter;
373 assert!(
374 !adapter.detect(&dir),
375 "should not detect GPT Actions in an MCP calculator fixture"
376 );
377 }
378
379 #[test]
380 fn test_load_gpt_actions_tools() {
381 let dir = fixture_dir();
382 let adapter = GptActionsAdapter;
383 let targets = adapter.load(&dir, false).unwrap();
384 assert_eq!(targets.len(), 1);
385
386 let target = &targets[0];
387 assert_eq!(target.framework, Framework::GptActions);
388
389 assert!(
391 target.tools.len() >= 2,
392 "expected at least 2 tools from openapi.json paths, got {}",
393 target.tools.len()
394 );
395
396 let tool_names: Vec<&str> = target.tools.iter().map(|t| t.name.as_str()).collect();
398 assert!(
399 tool_names.contains(&"get_/forecast"),
400 "expected 'get_/forecast' tool"
401 );
402 assert!(
403 tool_names.contains(&"get_/alerts"),
404 "expected 'get_/alerts' tool"
405 );
406 }
407
408 #[test]
409 fn test_load_gpt_actions_input_schema() {
410 let dir = fixture_dir();
411 let adapter = GptActionsAdapter;
412 let targets = adapter.load(&dir, false).unwrap();
413 let target = &targets[0];
414
415 let forecast_tool = target
417 .tools
418 .iter()
419 .find(|t| t.name == "get_/forecast")
420 .expect("get_/forecast tool not found");
421
422 let schema = forecast_tool
423 .input_schema
424 .as_ref()
425 .expect("input_schema should be present");
426 let props = schema
427 .get("properties")
428 .and_then(|v| v.as_object())
429 .expect("properties should be an object");
430
431 assert!(
432 props.contains_key("location"),
433 "expected 'location' parameter"
434 );
435 assert!(props.contains_key("days"), "expected 'days' parameter");
436 }
437
438 #[test]
439 fn test_load_gpt_actions_network_operations() {
440 let dir = fixture_dir();
441 let adapter = GptActionsAdapter;
442 let targets = adapter.load(&dir, false).unwrap();
443 let target = &targets[0];
444
445 assert!(
447 !target.execution.network_operations.is_empty(),
448 "expected network operations from servers array"
449 );
450
451 let server_url = target
452 .execution
453 .network_operations
454 .iter()
455 .find(|op| matches!(&op.url_arg, ArgumentSource::Literal(u) if u.contains("weather.example.com")));
456 assert!(
457 server_url.is_some(),
458 "expected weather.example.com server URL"
459 );
460 }
461
462 #[test]
463 fn test_load_gpt_actions_source_files() {
464 let dir = fixture_dir();
465 let adapter = GptActionsAdapter;
466 let targets = adapter.load(&dir, false).unwrap();
467 let target = &targets[0];
468
469 assert!(
471 !target.source_files.is_empty(),
472 "expected source files from fixture directory"
473 );
474
475 let file_names: Vec<String> = target
476 .source_files
477 .iter()
478 .map(|sf| {
479 sf.path
480 .file_name()
481 .unwrap_or_default()
482 .to_string_lossy()
483 .to_string()
484 })
485 .collect();
486
487 assert!(
488 file_names.contains(&"openapi.json".to_string()),
489 "expected openapi.json in source files"
490 );
491 }
492}