feat(pipeline): PLC/SPS control-logic security scanner (IEC 61131-3) (#162)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m48s
CI / Deploy Dashboard (push) Successful in 2m51s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m2s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m48s
CI / Deploy Dashboard (push) Successful in 2m51s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m2s
This commit was merged in pull request #162.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
//! PLCopen XML → Structured Text POUs.
|
||||
//!
|
||||
//! A PLCopen project stores each POU as `<pou name=".." pouType="..">` with an
|
||||
//! `<interface>` (typed variable sections) and a `<body>`. We handle the
|
||||
//! Structured-Text body form (`<ST>…</ST>`); FBD/LD/SFC bodies are skipped.
|
||||
//!
|
||||
//! For each ST POU we reconstruct an equivalent ST source (a `VAR` block built
|
||||
//! from the interface + the ST body) and run it through the ST parser, so both
|
||||
//! raw `.st` files and PLCopen projects flow through one analysis path.
|
||||
|
||||
use super::ast::Pou;
|
||||
use super::parser;
|
||||
|
||||
/// Parse every Structured-Text POU out of a PLCopen XML document.
|
||||
pub fn parse_plcopen(xml: &str) -> Vec<Pou> {
|
||||
let doc = match roxmltree::Document::parse(xml) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let mut pous = Vec::new();
|
||||
for pou in doc.descendants().filter(|n| n.has_tag_name("pou")) {
|
||||
let name = pou.attribute("name").unwrap_or("pou").to_string();
|
||||
let pou_type = pou.attribute("pouType").unwrap_or("program");
|
||||
|
||||
// ST body text (skip non-ST bodies).
|
||||
let Some(st_node) = pou
|
||||
.descendants()
|
||||
.find(|n| n.has_tag_name("ST") && n.ancestors().any(|a| a.has_tag_name("body")))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let body = collect_text(st_node);
|
||||
if body.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let var_block = build_var_block(pou);
|
||||
let kw = match pou_type.to_ascii_lowercase().as_str() {
|
||||
"function" => "FUNCTION",
|
||||
"functionblock" | "functionblocktype" => "FUNCTION_BLOCK",
|
||||
_ => "PROGRAM",
|
||||
};
|
||||
let synthetic = format!("{kw} {name}\n{var_block}{body}\nEND_{kw}\n");
|
||||
pous.extend(parser::parse(&synthetic));
|
||||
}
|
||||
pous
|
||||
}
|
||||
|
||||
/// Concatenate all descendant text of a node (ST bodies are often wrapped in
|
||||
/// `<xhtml>` and may contain multiple text runs).
|
||||
fn collect_text(node: roxmltree::Node) -> String {
|
||||
node.descendants()
|
||||
.filter_map(|n| n.text())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
/// Build an ST `VAR … END_VAR` block from a POU's `<interface>` variable
|
||||
/// sections, so declarations (types, initial values) reach the rules.
|
||||
fn build_var_block(pou: roxmltree::Node) -> String {
|
||||
let Some(interface) = pou.children().find(|n| n.has_tag_name("interface")) else {
|
||||
return String::new();
|
||||
};
|
||||
let mut out = String::from("VAR\n");
|
||||
let mut any = false;
|
||||
for container in interface.children().filter(|n| n.is_element()) {
|
||||
// localVars / inputVars / outputVars / inOutVars / tempVars / globalVars / externalVars
|
||||
if !container.tag_name().name().ends_with("Vars") {
|
||||
continue;
|
||||
}
|
||||
for var in container.children().filter(|n| n.has_tag_name("variable")) {
|
||||
let Some(vname) = var.attribute("name") else {
|
||||
continue;
|
||||
};
|
||||
let ty = var
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("type"))
|
||||
.map(type_name)
|
||||
.unwrap_or_else(|| "BOOL".to_string());
|
||||
let init = var
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("initialValue"))
|
||||
.and_then(initial_value);
|
||||
match init {
|
||||
Some(v) => out.push_str(&format!(" {vname} : {ty} := {v};\n")),
|
||||
None => out.push_str(&format!(" {vname} : {ty};\n")),
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
out.push_str("END_VAR\n");
|
||||
if any {
|
||||
out
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a PLCopen `<type>` element as an ST type string.
|
||||
fn type_name(type_node: roxmltree::Node) -> String {
|
||||
let Some(inner) = type_node.children().find(|n| n.is_element()) else {
|
||||
return "BOOL".to_string();
|
||||
};
|
||||
let tag = inner.tag_name().name();
|
||||
match tag {
|
||||
"derived" => inner.attribute("name").unwrap_or("DERIVED").to_string(),
|
||||
"array" => {
|
||||
let dim = inner.children().find(|n| n.has_tag_name("dimension"));
|
||||
let (lo, hi) = dim
|
||||
.map(|d| {
|
||||
(
|
||||
d.attribute("lower").unwrap_or("0").to_string(),
|
||||
d.attribute("upper").unwrap_or("0").to_string(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| ("0".to_string(), "0".to_string()));
|
||||
let base = inner
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("baseType"))
|
||||
.map(type_name)
|
||||
.unwrap_or_else(|| "INT".to_string());
|
||||
format!("ARRAY[{lo}..{hi}] OF {base}")
|
||||
}
|
||||
"string" | "wstring" => "STRING".to_string(),
|
||||
// BOOL, INT, DINT, REAL, TIME, ... — the tag name is the ST type.
|
||||
other => other.to_ascii_uppercase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract an initial value as an ST literal (quoting strings).
|
||||
fn initial_value(iv: roxmltree::Node) -> Option<String> {
|
||||
let simple = iv.descendants().find(|n| n.has_tag_name("simpleValue"))?;
|
||||
let raw = simple.attribute("value")?.trim().to_string();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Numbers / booleans / time literals pass through; everything else is a
|
||||
// string literal.
|
||||
let is_scalar = raw.eq_ignore_ascii_case("true")
|
||||
|| raw.eq_ignore_ascii_case("false")
|
||||
|| raw.starts_with(['T', 't', 'D', 'd']) && raw.contains('#')
|
||||
|| raw
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+');
|
||||
if is_scalar || raw.starts_with('\'') || raw.starts_with('"') {
|
||||
Some(raw)
|
||||
} else {
|
||||
Some(format!("'{}'", raw.replace('\'', "''")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user