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