Some checks failed
Run Check Script / check (pull_request) Failing after 1m52s
380 lines
13 KiB
Rust
380 lines
13 KiB
Rust
//! Minimal PHP controller parser for OPNsense API controllers.
|
|
//!
|
|
//! Extracts the module path, controller name, model binding, and available
|
|
//! CRUD actions from PHP source files using regex — no PHP AST required.
|
|
|
|
use heck::ToSnakeCase;
|
|
use regex::Regex;
|
|
use serde::Serialize;
|
|
use std::path::Path;
|
|
|
|
/// Parsed representation of an OPNsense API controller.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ControllerIR {
|
|
/// API module path segment (e.g., "firewall")
|
|
pub module: String,
|
|
/// API controller path segment (e.g., "d_nat")
|
|
pub controller: String,
|
|
/// Model name used as JSON envelope key (e.g., "DNat")
|
|
pub model_name: String,
|
|
/// Full PHP model class path (e.g., "OPNsense\\Firewall\\DNat")
|
|
pub model_class: String,
|
|
/// Parent controller class (e.g., "FilterBaseController")
|
|
pub parent_class: String,
|
|
/// Discovered CRUD entities with their available actions
|
|
pub entities: Vec<EntityIR>,
|
|
/// Standalone actions (apply, reconfigure, etc.)
|
|
pub standalone_actions: Vec<String>,
|
|
}
|
|
|
|
/// A CRUD entity exposed by the controller.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct EntityIR {
|
|
/// Entity name as it appears in URLs (e.g., "Rule", "Item", "Host")
|
|
pub name: String,
|
|
/// JSON envelope key used in request bodies (e.g., "rule", "vlan", "host")
|
|
pub body_key: String,
|
|
/// Available CRUD actions for this entity
|
|
pub actions: Vec<ActionKind>,
|
|
}
|
|
|
|
/// The kind of CRUD action available on an entity.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
pub enum ActionKind {
|
|
Search,
|
|
Get,
|
|
Add,
|
|
Set,
|
|
Del,
|
|
Toggle,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ControllerParseError {
|
|
#[error("IO error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("Missing required field: {0}")]
|
|
MissingField(String),
|
|
}
|
|
|
|
/// Parse a PHP API controller file and extract its IR.
|
|
pub fn parse_controller(path: &Path) -> Result<ControllerIR, ControllerParseError> {
|
|
let content = std::fs::read_to_string(path)?;
|
|
parse_controller_str(&content)
|
|
}
|
|
|
|
/// Parse a PHP controller that inherits model fields from a parent controller.
|
|
/// The caller provides the model_name and model_class from the parent.
|
|
pub fn parse_controller_with_defaults(
|
|
path: &Path,
|
|
default_model_name: &str,
|
|
default_model_class: &str,
|
|
) -> Result<ControllerIR, ControllerParseError> {
|
|
let content = std::fs::read_to_string(path)?;
|
|
parse_controller_str_with_defaults(&content, default_model_name, default_model_class)
|
|
}
|
|
|
|
/// Parse with fallback defaults for inherited model fields.
|
|
pub fn parse_controller_str_with_defaults(
|
|
php: &str,
|
|
default_model_name: &str,
|
|
default_model_class: &str,
|
|
) -> Result<ControllerIR, ControllerParseError> {
|
|
let namespace = extract_namespace(php)
|
|
.ok_or_else(|| ControllerParseError::MissingField("namespace".into()))?;
|
|
let (class_name, parent_class) =
|
|
extract_class(php).ok_or_else(|| ControllerParseError::MissingField("class".into()))?;
|
|
let model_name =
|
|
extract_static_string(php, "internalModelName").unwrap_or(default_model_name.to_string());
|
|
let model_class =
|
|
extract_static_string(php, "internalModelClass").unwrap_or(default_model_class.to_string());
|
|
|
|
let module = derive_module(&namespace);
|
|
let controller = derive_controller_path(&class_name);
|
|
|
|
let actions = extract_actions(php);
|
|
let body_keys = extract_body_keys(php);
|
|
let (entities, standalone_actions) = group_actions(&actions, &body_keys, &model_name);
|
|
|
|
Ok(ControllerIR {
|
|
module,
|
|
controller,
|
|
model_name,
|
|
model_class,
|
|
parent_class,
|
|
entities,
|
|
standalone_actions,
|
|
})
|
|
}
|
|
|
|
/// Parse PHP controller source text.
|
|
pub fn parse_controller_str(php: &str) -> Result<ControllerIR, ControllerParseError> {
|
|
let namespace = extract_namespace(php)
|
|
.ok_or_else(|| ControllerParseError::MissingField("namespace".into()))?;
|
|
let (class_name, parent_class) =
|
|
extract_class(php).ok_or_else(|| ControllerParseError::MissingField("class".into()))?;
|
|
let model_name = extract_static_string(php, "internalModelName")
|
|
.ok_or_else(|| ControllerParseError::MissingField("internalModelName".into()))?;
|
|
let model_class = extract_static_string(php, "internalModelClass")
|
|
.ok_or_else(|| ControllerParseError::MissingField("internalModelClass".into()))?;
|
|
|
|
let module = derive_module(&namespace);
|
|
let controller = derive_controller_path(&class_name);
|
|
|
|
let actions = extract_actions(php);
|
|
let body_keys = extract_body_keys(php);
|
|
let (entities, standalone_actions) = group_actions(&actions, &body_keys, &model_name);
|
|
|
|
Ok(ControllerIR {
|
|
module,
|
|
controller,
|
|
model_name,
|
|
model_class,
|
|
parent_class,
|
|
entities,
|
|
standalone_actions,
|
|
})
|
|
}
|
|
|
|
fn extract_namespace(php: &str) -> Option<String> {
|
|
let re = Regex::new(r"namespace\s+([\w\\]+);").unwrap();
|
|
re.captures(php).map(|c| c[1].to_string())
|
|
}
|
|
|
|
fn extract_class(php: &str) -> Option<(String, String)> {
|
|
let re = Regex::new(r"class\s+(\w+)\s+extends\s+(\w+)").unwrap();
|
|
re.captures(php)
|
|
.map(|c| (c[1].to_string(), c[2].to_string()))
|
|
}
|
|
|
|
fn extract_static_string(php: &str, var_name: &str) -> Option<String> {
|
|
let pattern = format!(
|
|
r#"protected\s+static\s+\${}(?:\s*:\s*\??\s*string)?\s*=\s*['"](.*?)['"]"#,
|
|
regex::escape(var_name)
|
|
);
|
|
let re = Regex::new(&pattern).unwrap();
|
|
re.captures(php).map(|c| c[1].to_string())
|
|
}
|
|
|
|
fn extract_actions(php: &str) -> Vec<String> {
|
|
let re = Regex::new(r"public\s+function\s+(\w+Action)").unwrap();
|
|
re.captures_iter(php).map(|c| c[1].to_string()).collect()
|
|
}
|
|
|
|
/// Derive the API module path from the PHP namespace.
|
|
/// `OPNsense\Firewall\Api` → `firewall`
|
|
/// `OPNsense\Interfaces\Api` → `interfaces`
|
|
/// `OPNsense\Dnsmasq\Api` → `dnsmasq`
|
|
fn derive_module(namespace: &str) -> String {
|
|
let parts: Vec<&str> = namespace.split('\\').collect();
|
|
// The module is the part between OPNsense and Api
|
|
// e.g., OPNsense\Firewall\Api → Firewall → firewall
|
|
if parts.len() >= 3 {
|
|
parts[1].to_snake_case()
|
|
} else {
|
|
namespace.to_snake_case()
|
|
}
|
|
}
|
|
|
|
/// Derive the API controller path from the PHP class name.
|
|
/// `DNatController` → `d_nat`
|
|
/// `VlanSettingsController` → `vlan_settings`
|
|
/// `FilterController` → `filter`
|
|
fn derive_controller_path(class_name: &str) -> String {
|
|
let name = class_name.strip_suffix("Controller").unwrap_or(class_name);
|
|
// OPNsense uses a specific conversion: insert underscores before uppercase
|
|
// letters that follow lowercase letters, then lowercase everything.
|
|
// DNat → d_nat, VlanSettings → vlan_settings
|
|
let mut result = String::new();
|
|
for (i, ch) in name.chars().enumerate() {
|
|
if ch.is_uppercase() && i > 0 {
|
|
let prev = name.chars().nth(i - 1).unwrap();
|
|
if prev.is_lowercase() || prev.is_ascii_digit() {
|
|
result.push('_');
|
|
} else if i + 1 < name.len() {
|
|
// Handle sequences like "DNat" → "d_nat": insert _ before the
|
|
// last uppercase in a consecutive run if followed by lowercase
|
|
let next = name.chars().nth(i + 1).unwrap();
|
|
if next.is_lowercase() {
|
|
result.push('_');
|
|
}
|
|
}
|
|
}
|
|
result.push(ch.to_ascii_lowercase());
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Extract body keys from PHP source by looking at addBase/setBase calls.
|
|
/// Returns a map from entity name (PascalCase) to body key string.
|
|
fn extract_body_keys(php: &str) -> std::collections::HashMap<String, String> {
|
|
let re = Regex::new(r#"(?:addBase|setBase)\s*\(\s*['"]([\w]+)['"]"#).unwrap();
|
|
let action_re = Regex::new(
|
|
r#"function\s+(add|set)(\w+)Action[^{]*\{[^}]*(?:addBase|setBase)\s*\(\s*['"]([\w]+)['"]"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let mut keys = std::collections::HashMap::new();
|
|
|
|
// Try matching function name + base call together
|
|
for caps in action_re.captures_iter(php) {
|
|
let entity = caps[2].to_string();
|
|
let body_key = caps[3].to_string();
|
|
keys.insert(entity, body_key);
|
|
}
|
|
|
|
// Fallback: if no matches, try just the first addBase call
|
|
if keys.is_empty()
|
|
&& let Some(caps) = re.captures(php)
|
|
{
|
|
keys.insert("default".to_string(), caps[1].to_string());
|
|
}
|
|
|
|
keys
|
|
}
|
|
|
|
/// Group raw action names into entities and standalone actions.
|
|
fn group_actions(
|
|
actions: &[String],
|
|
body_keys: &std::collections::HashMap<String, String>,
|
|
model_name: &str,
|
|
) -> (Vec<EntityIR>, Vec<String>) {
|
|
let crud_re = Regex::new(r"^(search|get|add|set|del|toggle)(\w+)Action$").unwrap();
|
|
|
|
let mut entity_map: std::collections::BTreeMap<String, Vec<ActionKind>> =
|
|
std::collections::BTreeMap::new();
|
|
let mut standalone = Vec::new();
|
|
|
|
for action in actions {
|
|
if let Some(caps) = crud_re.captures(action) {
|
|
let verb = &caps[1];
|
|
let entity = caps[2].to_string();
|
|
let kind = match verb {
|
|
"search" => ActionKind::Search,
|
|
"get" => ActionKind::Get,
|
|
"add" => ActionKind::Add,
|
|
"set" => ActionKind::Set,
|
|
"del" => ActionKind::Del,
|
|
"toggle" => ActionKind::Toggle,
|
|
_ => continue,
|
|
};
|
|
entity_map.entry(entity).or_default().push(kind);
|
|
} else {
|
|
let name = action.strip_suffix("Action").unwrap_or(action);
|
|
standalone.push(name.to_string());
|
|
}
|
|
}
|
|
|
|
let entities = entity_map
|
|
.into_iter()
|
|
.map(|(name, actions)| {
|
|
// Determine body key: from PHP extraction, or convention
|
|
let body_key = body_keys
|
|
.get(&name)
|
|
.or_else(|| body_keys.get("default"))
|
|
.cloned()
|
|
.unwrap_or_else(|| {
|
|
if name == "Item" {
|
|
model_name.to_lowercase()
|
|
} else {
|
|
name.to_snake_case()
|
|
}
|
|
});
|
|
EntityIR {
|
|
name,
|
|
body_key,
|
|
actions,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
(entities, standalone)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_derive_controller_path() {
|
|
assert_eq!(derive_controller_path("DNatController"), "d_nat");
|
|
assert_eq!(
|
|
derive_controller_path("VlanSettingsController"),
|
|
"vlan_settings"
|
|
);
|
|
assert_eq!(derive_controller_path("FilterController"), "filter");
|
|
assert_eq!(
|
|
derive_controller_path("LaggSettingsController"),
|
|
"lagg_settings"
|
|
);
|
|
assert_eq!(
|
|
derive_controller_path("VipSettingsController"),
|
|
"vip_settings"
|
|
);
|
|
assert_eq!(derive_controller_path("SettingsController"), "settings");
|
|
}
|
|
|
|
#[test]
|
|
fn test_derive_module() {
|
|
assert_eq!(derive_module("OPNsense\\Firewall\\Api"), "firewall");
|
|
assert_eq!(derive_module("OPNsense\\Interfaces\\Api"), "interfaces");
|
|
assert_eq!(derive_module("OPNsense\\Dnsmasq\\Api"), "dnsmasq");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_dnat_controller() {
|
|
let php = r#"
|
|
namespace OPNsense\Firewall\Api;
|
|
class DNatController extends FilterBaseController
|
|
{
|
|
protected static $internalModelName = 'DNat';
|
|
protected static $internalModelClass = 'OPNsense\\Firewall\\DNat';
|
|
|
|
public function searchRuleAction() {}
|
|
public function setRuleAction($uuid) {}
|
|
public function addRuleAction() {}
|
|
public function getRuleAction($uuid = null) {}
|
|
public function delRuleAction($uuid) {}
|
|
public function toggleRuleAction($uuid, $disabled = null) {}
|
|
public function applyAction($rollback_revision = null) {}
|
|
}
|
|
"#;
|
|
|
|
let ir = parse_controller_str(php).unwrap();
|
|
assert_eq!(ir.module, "firewall");
|
|
assert_eq!(ir.controller, "d_nat");
|
|
assert_eq!(ir.model_name, "DNat");
|
|
assert_eq!(ir.parent_class, "FilterBaseController");
|
|
assert_eq!(ir.entities.len(), 1);
|
|
assert_eq!(ir.entities[0].name, "Rule");
|
|
assert_eq!(ir.entities[0].actions.len(), 6); // search, get, add, set, del, toggle
|
|
assert!(ir.standalone_actions.contains(&"apply".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_vlan_controller() {
|
|
let php = r#"
|
|
namespace OPNsense\Interfaces\Api;
|
|
class VlanSettingsController extends ApiMutableModelControllerBase
|
|
{
|
|
protected static $internalModelName = 'vlan';
|
|
protected static $internalModelClass = 'OPNsense\Interfaces\Vlan';
|
|
|
|
public function searchItemAction() {}
|
|
public function setItemAction($uuid) {}
|
|
public function addItemAction() {}
|
|
public function getItemAction($uuid = null) {}
|
|
public function delItemAction($uuid) {}
|
|
public function reconfigureAction() {}
|
|
}
|
|
"#;
|
|
let ir = parse_controller_str(php).unwrap();
|
|
assert_eq!(ir.module, "interfaces");
|
|
assert_eq!(ir.controller, "vlan_settings");
|
|
assert_eq!(ir.model_name, "vlan");
|
|
assert_eq!(ir.entities.len(), 1);
|
|
assert_eq!(ir.entities[0].name, "Item");
|
|
assert!(ir.standalone_actions.contains(&"reconfigure".to_string()));
|
|
}
|
|
}
|