use crate::error::{BenchError, Result};
use crate::spec_parser::ApiOperation;
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::Path;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ExtractField {
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(default)]
pub body: bool,
pub store_as: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub match_mode: Option<String>,
}
impl ExtractField {
pub fn simple(field: String) -> Self {
Self {
store_as: field.clone(),
field: Some(field),
body: false,
exclude: Vec::new(),
match_mode: None,
}
}
pub fn aliased(field: String, store_as: String) -> Self {
Self {
field: Some(field),
store_as,
body: false,
exclude: Vec::new(),
match_mode: None,
}
}
pub fn aliased_with_match(field: String, store_as: String, match_mode: Option<String>) -> Self {
Self {
field: Some(field),
store_as,
body: false,
exclude: Vec::new(),
match_mode,
}
}
pub fn full_body(store_as: String, exclude: Vec<String>) -> Self {
Self {
field: None,
body: true,
store_as,
exclude,
match_mode: None,
}
}
}
impl<'de> Deserialize<'de> for ExtractField {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, MapAccess, Visitor};
struct ExtractFieldVisitor;
impl<'de> Visitor<'de> for ExtractFieldVisitor {
type Value = ExtractField;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string, an object with 'field' and optional 'as' keys, or an object with 'body: true' and 'as' keys")
}
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
Ok(ExtractField::simple(value.to_string()))
}
fn visit_map<M>(self, mut map: M) -> std::result::Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut field: Option<String> = None;
let mut store_as: Option<String> = None;
let mut body: bool = false;
let mut exclude: Vec<String> = Vec::new();
let mut match_mode: Option<String> = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"field" => {
field = Some(map.next_value()?);
}
"as" => {
store_as = Some(map.next_value()?);
}
"body" => {
body = map.next_value()?;
}
"exclude" => {
exclude = map.next_value()?;
}
"match" | "match_mode" => {
match_mode = Some(map.next_value()?);
}
_ => {
let _: de::IgnoredAny = map.next_value()?;
}
}
}
if body {
let store_as = store_as.ok_or_else(|| de::Error::missing_field("as"))?;
Ok(ExtractField {
field: None,
body: true,
store_as,
exclude,
match_mode: None,
})
} else {
let field = field.ok_or_else(|| de::Error::missing_field("field"))?;
let store_as = store_as.unwrap_or_else(|| field.clone());
Ok(ExtractField {
field: Some(field),
body: false,
store_as,
exclude: Vec::new(),
match_mode,
})
}
}
}
deserializer.deserialize_any(ExtractFieldVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowStep {
pub operation: String,
#[serde(default)]
pub extract: Vec<ExtractField>,
#[serde(default)]
pub use_values: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_body: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub merge_body: HashMap<String, serde_json::Value>,
#[serde(default)]
pub inject_attacks: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attack_types: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl FlowStep {
pub fn new(operation: String) -> Self {
Self {
operation,
extract: Vec::new(),
use_values: HashMap::new(),
use_body: None,
merge_body: HashMap::new(),
inject_attacks: false,
attack_types: Vec::new(),
description: None,
}
}
pub fn with_extract(mut self, fields: Vec<String>) -> Self {
self.extract = fields.into_iter().map(ExtractField::simple).collect();
self
}
pub fn with_extract_fields(mut self, fields: Vec<ExtractField>) -> Self {
self.extract = fields;
self
}
pub fn with_values(mut self, values: HashMap<String, String>) -> Self {
self.use_values = values;
self
}
pub fn with_use_body(mut self, body_name: String) -> Self {
self.use_body = Some(body_name);
self
}
pub fn with_merge_body(mut self, merge: HashMap<String, serde_json::Value>) -> Self {
self.merge_body = merge;
self
}
pub fn with_inject_attacks(mut self, inject: bool) -> Self {
self.inject_attacks = inject;
self
}
pub fn with_attack_types(mut self, types: Vec<String>) -> Self {
self.attack_types = types;
self
}
pub fn with_description(mut self, description: String) -> Self {
self.description = Some(description);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrudFlow {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_path: Option<String>,
pub steps: Vec<FlowStep>,
}
impl CrudFlow {
pub fn new(name: String) -> Self {
Self {
name,
base_path: None,
steps: Vec::new(),
}
}
pub fn with_base_path(mut self, path: String) -> Self {
self.base_path = Some(path);
self
}
pub fn add_step(&mut self, step: FlowStep) {
self.steps.push(step);
}
pub fn get_all_extract_fields(&self) -> HashSet<String> {
self.steps
.iter()
.flat_map(|step| step.extract.iter().filter_map(|e| e.field.clone()))
.collect()
}
pub fn get_all_extract_configs(&self) -> Vec<&ExtractField> {
self.steps.iter().flat_map(|step| step.extract.iter()).collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrudFlowConfig {
pub flows: Vec<CrudFlow>,
#[serde(default)]
pub default_extract_fields: Vec<String>,
}
impl Default for CrudFlowConfig {
fn default() -> Self {
Self {
flows: Vec::new(),
default_extract_fields: vec!["id".to_string(), "uuid".to_string()],
}
}
}
impl CrudFlowConfig {
pub fn from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.map_err(|e| BenchError::Other(format!("Failed to read flow config: {}", e)))?;
Self::from_yaml(&content)
}
pub fn from_yaml(yaml: &str) -> Result<Self> {
serde_yaml::from_str(yaml)
.map_err(|e| BenchError::Other(format!("Failed to parse flow config: {}", e)))
}
pub fn single_flow(flow: CrudFlow) -> Self {
Self {
flows: vec![flow],
..Default::default()
}
}
}
#[derive(Debug, Clone)]
pub struct ResourceOperations {
pub base_path: String,
pub create: Option<ApiOperation>,
pub read: Option<ApiOperation>,
pub update: Option<ApiOperation>,
pub delete: Option<ApiOperation>,
pub list: Option<ApiOperation>,
}
impl ResourceOperations {
pub fn new(base_path: String) -> Self {
Self {
base_path,
create: None,
read: None,
update: None,
delete: None,
list: None,
}
}
pub fn has_crud_operations(&self) -> bool {
self.create.is_some()
&& (self.read.is_some() || self.update.is_some() || self.delete.is_some())
}
pub fn get_id_param_name(&self) -> Option<String> {
let path = self
.read
.as_ref()
.or(self.update.as_ref())
.or(self.delete.as_ref())
.map(|op| &op.path)?;
extract_id_param_from_path(path)
}
}
fn extract_id_param_from_path(path: &str) -> Option<String> {
for segment in path.split('/').rev() {
if segment.starts_with('{') && segment.ends_with('}') {
return Some(segment[1..segment.len() - 1].to_string());
}
}
None
}
fn get_base_path(path: &str) -> String {
let segments: Vec<&str> = path.split('/').collect();
let mut base_segments = Vec::new();
for segment in segments {
if segment.starts_with('{') {
break;
}
if !segment.is_empty() {
base_segments.push(segment);
}
}
if base_segments.is_empty() {
"/".to_string()
} else {
format!("/{}", base_segments.join("/"))
}
}
fn is_detail_path(path: &str) -> bool {
path.contains('{') && path.contains('}')
}
pub struct CrudFlowDetector;
impl CrudFlowDetector {
pub fn detect_flows(operations: &[ApiOperation]) -> Vec<CrudFlow> {
let mut resources: HashMap<String, ResourceOperations> = HashMap::new();
for op in operations {
let base_path = get_base_path(&op.path);
let is_detail = is_detail_path(&op.path);
let method = op.method.to_lowercase();
let resource = resources
.entry(base_path.clone())
.or_insert_with(|| ResourceOperations::new(base_path));
match (method.as_str(), is_detail) {
("post", false) => resource.create = Some(op.clone()),
("get", false) => resource.list = Some(op.clone()),
("get", true) => resource.read = Some(op.clone()),
("put", true) | ("patch", true) => resource.update = Some(op.clone()),
("delete", true) => resource.delete = Some(op.clone()),
_ => {}
}
}
resources
.into_values()
.filter(|r| r.has_crud_operations())
.map(|r| Self::build_flow_from_resource(&r))
.collect()
}
fn build_flow_from_resource(resource: &ResourceOperations) -> CrudFlow {
let name = resource.base_path.trim_start_matches('/').replace('/', "_").to_string();
let mut flow =
CrudFlow::new(format!("{} CRUD", name)).with_base_path(resource.base_path.clone());
let id_param = resource.get_id_param_name().unwrap_or_else(|| "id".to_string());
if let Some(create_op) = &resource.create {
let step =
FlowStep::new(format!("{} {}", create_op.method.to_uppercase(), create_op.path))
.with_extract(vec!["id".to_string(), "uuid".to_string()])
.with_description("Create resource".to_string());
flow.add_step(step);
}
if let Some(read_op) = &resource.read {
let mut values = HashMap::new();
values.insert(id_param.clone(), "id".to_string());
let step = FlowStep::new(format!("{} {}", read_op.method.to_uppercase(), read_op.path))
.with_values(values)
.with_description("Read created resource".to_string());
flow.add_step(step);
}
if let Some(update_op) = &resource.update {
let mut values = HashMap::new();
values.insert(id_param.clone(), "id".to_string());
let step =
FlowStep::new(format!("{} {}", update_op.method.to_uppercase(), update_op.path))
.with_values(values)
.with_description("Update resource".to_string());
flow.add_step(step);
}
if let Some(read_op) = &resource.read {
let mut values = HashMap::new();
values.insert(id_param.clone(), "id".to_string());
let step = FlowStep::new(format!("{} {}", read_op.method.to_uppercase(), read_op.path))
.with_values(values)
.with_description("Verify update".to_string());
flow.add_step(step);
}
if let Some(delete_op) = &resource.delete {
let mut values = HashMap::new();
values.insert(id_param.clone(), "id".to_string());
let step =
FlowStep::new(format!("{} {}", delete_op.method.to_uppercase(), delete_op.path))
.with_values(values)
.with_description("Delete resource".to_string());
flow.add_step(step);
}
flow
}
pub fn merge_with_config(detected: Vec<CrudFlow>, config: &CrudFlowConfig) -> Vec<CrudFlow> {
if !config.flows.is_empty() {
config.flows.clone()
} else {
detected
}
}
}
#[derive(Debug, Clone, Default)]
pub struct FlowExecutionContext {
pub extracted_values: HashMap<String, String>,
pub current_step: usize,
pub errors: Vec<String>,
}
impl FlowExecutionContext {
pub fn new() -> Self {
Self::default()
}
pub fn store_value(&mut self, key: String, value: String) {
self.extracted_values.insert(key, value);
}
pub fn get_value(&self, key: &str) -> Option<&String> {
self.extracted_values.get(key)
}
pub fn next_step(&mut self) {
self.current_step += 1;
}
pub fn record_error(&mut self, error: String) {
self.errors.push(error);
}
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use openapiv3::Operation;
fn create_test_operation(method: &str, path: &str, operation_id: Option<&str>) -> ApiOperation {
ApiOperation {
method: method.to_string(),
path: path.to_string(),
operation: Operation::default(),
operation_id: operation_id.map(|s| s.to_string()),
}
}
#[test]
fn test_get_base_path() {
assert_eq!(get_base_path("/users"), "/users");
assert_eq!(get_base_path("/users/{id}"), "/users");
assert_eq!(get_base_path("/api/v1/users/{userId}"), "/api/v1/users");
assert_eq!(get_base_path("/resources/{resourceId}/items/{itemId}"), "/resources");
assert_eq!(get_base_path("/"), "/");
}
#[test]
fn test_is_detail_path() {
assert!(!is_detail_path("/users"));
assert!(is_detail_path("/users/{id}"));
assert!(is_detail_path("/users/{userId}"));
assert!(!is_detail_path("/users/list"));
}
#[test]
fn test_extract_id_param_from_path() {
assert_eq!(extract_id_param_from_path("/users/{id}"), Some("id".to_string()));
assert_eq!(extract_id_param_from_path("/users/{userId}"), Some("userId".to_string()));
assert_eq!(extract_id_param_from_path("/a/{b}/{c}"), Some("c".to_string()));
assert_eq!(extract_id_param_from_path("/users"), None);
}
#[test]
fn test_crud_flow_detection() {
let operations = vec![
create_test_operation("post", "/users", Some("createUser")),
create_test_operation("get", "/users", Some("listUsers")),
create_test_operation("get", "/users/{id}", Some("getUser")),
create_test_operation("put", "/users/{id}", Some("updateUser")),
create_test_operation("delete", "/users/{id}", Some("deleteUser")),
];
let flows = CrudFlowDetector::detect_flows(&operations);
assert_eq!(flows.len(), 1);
let flow = &flows[0];
assert!(flow.name.contains("users"));
assert_eq!(flow.steps.len(), 5);
assert!(flow.steps[0].operation.starts_with("POST"));
assert!(flow.steps[0].extract.iter().any(|e| e.field.as_deref() == Some("id")));
}
#[test]
fn test_multiple_resources_detection() {
let operations = vec![
create_test_operation("post", "/users", Some("createUser")),
create_test_operation("get", "/users/{id}", Some("getUser")),
create_test_operation("delete", "/users/{id}", Some("deleteUser")),
create_test_operation("post", "/posts", Some("createPost")),
create_test_operation("get", "/posts/{id}", Some("getPost")),
create_test_operation("put", "/posts/{id}", Some("updatePost")),
];
let flows = CrudFlowDetector::detect_flows(&operations);
assert_eq!(flows.len(), 2);
}
#[test]
fn test_no_crud_without_create() {
let operations = vec![
create_test_operation("get", "/users", Some("listUsers")),
create_test_operation("get", "/users/{id}", Some("getUser")),
];
let flows = CrudFlowDetector::detect_flows(&operations);
assert!(flows.is_empty());
}
#[test]
fn test_flow_step_builder() {
let step = FlowStep::new("POST /users".to_string())
.with_extract(vec!["id".to_string(), "uuid".to_string()])
.with_description("Create a new user".to_string());
assert_eq!(step.operation, "POST /users");
assert_eq!(step.extract.len(), 2);
assert_eq!(step.description, Some("Create a new user".to_string()));
}
#[test]
fn test_flow_step_use_values() {
let mut values = HashMap::new();
values.insert("id".to_string(), "user_id".to_string());
let step = FlowStep::new("GET /users/{id}".to_string()).with_values(values);
assert_eq!(step.use_values.get("id"), Some(&"user_id".to_string()));
}
#[test]
fn test_crud_flow_config_from_yaml() {
let yaml = r#"
flows:
- name: "User CRUD"
base_path: "/users"
steps:
- operation: "POST /users"
extract: ["id"]
description: "Create user"
- operation: "GET /users/{id}"
use_values:
id: "id"
description: "Get user"
default_extract_fields:
- id
- uuid
"#;
let config = CrudFlowConfig::from_yaml(yaml).expect("Should parse YAML");
assert_eq!(config.flows.len(), 1);
assert_eq!(config.flows[0].name, "User CRUD");
assert_eq!(config.flows[0].steps.len(), 2);
assert_eq!(config.default_extract_fields.len(), 2);
}
#[test]
fn test_execution_context() {
let mut ctx = FlowExecutionContext::new();
ctx.store_value("id".to_string(), "12345".to_string());
assert_eq!(ctx.get_value("id"), Some(&"12345".to_string()));
ctx.next_step();
assert_eq!(ctx.current_step, 1);
ctx.record_error("Something went wrong".to_string());
assert!(ctx.has_errors());
}
#[test]
fn test_resource_operations_has_crud() {
let mut resource = ResourceOperations::new("/users".to_string());
assert!(!resource.has_crud_operations());
resource.create = Some(create_test_operation("post", "/users", Some("createUser")));
assert!(!resource.has_crud_operations());
resource.read = Some(create_test_operation("get", "/users/{id}", Some("getUser")));
assert!(resource.has_crud_operations());
}
#[test]
fn test_get_id_param_name() {
let mut resource = ResourceOperations::new("/users".to_string());
resource.read = Some(create_test_operation("get", "/users/{userId}", Some("getUser")));
assert_eq!(resource.get_id_param_name(), Some("userId".to_string()));
}
#[test]
fn test_merge_with_config_user_provided() {
let detected = vec![CrudFlow::new("detected_flow".to_string())];
let mut config = CrudFlowConfig::default();
config.flows.push(CrudFlow::new("user_flow".to_string()));
let result = CrudFlowDetector::merge_with_config(detected, &config);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "user_flow");
}
#[test]
fn test_merge_with_config_auto_detected() {
let detected = vec![CrudFlow::new("detected_flow".to_string())];
let config = CrudFlowConfig::default();
let result = CrudFlowDetector::merge_with_config(detected, &config);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "detected_flow");
}
#[test]
fn test_crud_flow_get_all_extract_fields() {
let mut flow = CrudFlow::new("test".to_string());
flow.add_step(FlowStep::new("POST /test".to_string()).with_extract(vec!["id".to_string()]));
flow.add_step(
FlowStep::new("GET /test/{id}".to_string()).with_extract(vec!["uuid".to_string()]),
);
let fields = flow.get_all_extract_fields();
assert!(fields.contains("id"));
assert!(fields.contains("uuid"));
assert_eq!(fields.len(), 2);
}
#[test]
fn test_flow_step_with_merge_body() {
let mut merge = HashMap::new();
merge.insert("enabled".to_string(), serde_json::json!(false));
merge.insert("name".to_string(), serde_json::json!("updated-name"));
let step = FlowStep::new("PUT /resource/{id}".to_string())
.with_use_body("resource_body".to_string())
.with_merge_body(merge);
assert_eq!(step.use_body, Some("resource_body".to_string()));
assert_eq!(step.merge_body.get("enabled"), Some(&serde_json::json!(false)));
assert_eq!(step.merge_body.get("name"), Some(&serde_json::json!("updated-name")));
}
#[test]
fn test_flow_config_with_merge_body() {
let yaml = r#"
flows:
- name: "Update Flow"
steps:
- operation: "GET /resource/{id}"
use_values:
id: "resource_id"
extract:
- body: true
as: resource_body
exclude: ["_last_modified"]
description: "Get resource"
- operation: "PUT /resource/{id}"
use_values:
id: "resource_id"
use_body: "resource_body"
merge_body:
enabled: false
name: "updated"
description: "Update resource"
"#;
let config = CrudFlowConfig::from_yaml(yaml).expect("Should parse YAML");
assert_eq!(config.flows.len(), 1);
assert_eq!(config.flows[0].steps.len(), 2);
let put_step = &config.flows[0].steps[1];
assert_eq!(put_step.use_body, Some("resource_body".to_string()));
assert!(!put_step.merge_body.is_empty());
assert_eq!(put_step.merge_body.get("enabled"), Some(&serde_json::json!(false)));
assert_eq!(put_step.merge_body.get("name"), Some(&serde_json::json!("updated")));
}
#[test]
fn test_flow_step_with_inject_attacks() {
let step = FlowStep::new("POST /resource".to_string())
.with_inject_attacks(true)
.with_attack_types(vec!["sqli".to_string(), "xss".to_string()]);
assert!(step.inject_attacks);
assert_eq!(step.attack_types, vec!["sqli".to_string(), "xss".to_string()]);
}
#[test]
fn test_flow_config_with_inject_attacks() {
let yaml = r#"
flows:
- name: "Security Test Flow"
steps:
- operation: "POST /pool"
inject_attacks: true
attack_types:
- sqli
- xss
description: "Create pool with attack payloads"
"#;
let config = CrudFlowConfig::from_yaml(yaml).expect("Should parse YAML");
assert_eq!(config.flows.len(), 1);
assert_eq!(config.flows[0].steps.len(), 1);
let step = &config.flows[0].steps[0];
assert!(step.inject_attacks);
assert_eq!(step.attack_types, vec!["sqli".to_string(), "xss".to_string()]);
}
}