419 lines
16 KiB
Rust
419 lines
16 KiB
Rust
//! PLCopen XML → Structured Text POUs.
|
|
//!
|
|
//! A PLCopen project stores each POU as `<pou name=".." pouType="..">` with an
|
|
//! `<interface>` (typed variable sections) and a `<body>` in one of the IEC
|
|
//! 61131-3 languages. We reconstruct an equivalent Structured-Text source for
|
|
//! each POU (a `VAR` block from the interface + statements from the body) and run
|
|
//! it through the ST parser, so raw `.st` files and PLCopen projects — textual or
|
|
//! graphical — flow through one analysis path.
|
|
//!
|
|
//! Body languages:
|
|
//! - **ST** — taken verbatim.
|
|
//! - **FBD / LD** — the graphical network is translated to synthetic ST: blocks
|
|
//! become calls (`TypeName(pin := arg, …)`), out-variables / coils become
|
|
//! assignments, with input pins resolved by tracing connections. This lets the
|
|
//! semantic rules see comm calls, hardcoded arguments and safety writes that
|
|
//! live in graphical logic, not just in text.
|
|
//! - **SFC** — the step/transition graph itself is skipped; the ST/FBD/LD bodies
|
|
//! embedded in its actions and transitions are still translated.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use roxmltree::Node;
|
|
|
|
use super::ast::Pou;
|
|
use super::parser;
|
|
|
|
/// Parse every POU out of a PLCopen XML document (ST, FBD or LD bodies).
|
|
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");
|
|
|
|
let Some(body) = reconstruct_body(pou) else {
|
|
continue;
|
|
};
|
|
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
|
|
}
|
|
|
|
/// Case-insensitive tag match (PLCopen uses `FBD`/`LD`/`ST`, CODESYS may vary).
|
|
fn tag_is(n: &Node, name: &str) -> bool {
|
|
n.tag_name().name().eq_ignore_ascii_case(name)
|
|
}
|
|
|
|
/// Reconstruct a POU's body as Structured Text, whatever language it is written
|
|
/// in. Concatenates every language body found under `<body>` (SFC actions and
|
|
/// transitions carry their own ST/FBD/LD sub-bodies).
|
|
fn reconstruct_body(pou: Node) -> Option<String> {
|
|
let mut out = String::new();
|
|
for body in pou.descendants().filter(|n| tag_is(n, "body")) {
|
|
for lang in body.children().filter(|n| n.is_element()) {
|
|
let piece = match lang.tag_name().name().to_ascii_uppercase().as_str() {
|
|
"ST" | "IL" => collect_text(lang),
|
|
"FBD" | "LD" => translate_network(lang),
|
|
_ => continue,
|
|
};
|
|
if !piece.trim().is_empty() {
|
|
out.push_str(&piece);
|
|
if !piece.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if out.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(out)
|
|
}
|
|
}
|
|
|
|
// ── graphical (FBD / LD) → synthetic ST ────────────────────────────────
|
|
|
|
/// Translate one FBD/LD network into ST statements: blocks → calls,
|
|
/// out-variables and coils → assignments.
|
|
fn translate_network(net: Node) -> String {
|
|
let by_id = index_local_ids(net);
|
|
let mut out = String::new();
|
|
for el in net.children().filter(|n| n.is_element()) {
|
|
let stmt = match el.tag_name().name().to_ascii_lowercase().as_str() {
|
|
"block" => block_call(el, &by_id).map(|c| format!("{c};")),
|
|
"outvariable" => out_assignment(el, &by_id),
|
|
"coil" => coil_assignment(el, &by_id),
|
|
_ => None,
|
|
};
|
|
if let Some(s) = stmt {
|
|
out.push_str(&s);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Index every element in a network by its `localId` so connections resolve.
|
|
fn index_local_ids<'a, 'input>(net: Node<'a, 'input>) -> HashMap<String, Node<'a, 'input>> {
|
|
net.descendants()
|
|
.filter(|n| n.is_element())
|
|
.filter_map(|n| n.attribute("localId").map(|id| (id.to_string(), n)))
|
|
.collect()
|
|
}
|
|
|
|
/// Build a call expression for a block: `TypeName(pin := arg, …)`.
|
|
fn block_call(block: Node, by_id: &HashMap<String, Node>) -> Option<String> {
|
|
let ty = block.attribute("typeName")?;
|
|
let mut args = Vec::new();
|
|
if let Some(inputs) = block.children().find(|n| tag_is(n, "inputVariables")) {
|
|
for v in inputs.children().filter(|n| tag_is(n, "variable")) {
|
|
let Some(expr) = input_expr(v, by_id, 0) else {
|
|
continue;
|
|
};
|
|
match v.attribute("formalParameter") {
|
|
Some(pin) if !pin.is_empty() => args.push(format!("{pin} := {expr}")),
|
|
_ => args.push(expr),
|
|
}
|
|
}
|
|
}
|
|
Some(format!("{ty}({})", args.join(", ")))
|
|
}
|
|
|
|
/// `target := <traced expression>;` for an FBD out-variable.
|
|
fn out_assignment(outvar: Node, by_id: &HashMap<String, Node>) -> Option<String> {
|
|
let target = expression_text(outvar)?;
|
|
let value = input_expr(outvar, by_id, 0).unwrap_or_else(|| "0".to_string());
|
|
Some(format!("{target} := {value};"))
|
|
}
|
|
|
|
/// `coil := <traced rung expression>;` for an LD coil (negated → `NOT (…)`).
|
|
fn coil_assignment(coil: Node, by_id: &HashMap<String, Node>) -> Option<String> {
|
|
let target = child_text(coil, "variable")?;
|
|
let rung = input_expr(coil, by_id, 0).unwrap_or_else(|| "TRUE".to_string());
|
|
let negated = matches!(coil.attribute("negated"), Some(v) if v.eq_ignore_ascii_case("true"));
|
|
let rhs = if negated {
|
|
format!("NOT ({rung})")
|
|
} else {
|
|
rung
|
|
};
|
|
Some(format!("{target} := {rhs};"))
|
|
}
|
|
|
|
/// Resolve the expression feeding `node`'s single input connection.
|
|
fn input_expr(node: Node, by_id: &HashMap<String, Node>, depth: u8) -> Option<String> {
|
|
let refid = ref_local_id(node)?;
|
|
Some(expr_for(&refid, by_id, depth))
|
|
}
|
|
|
|
/// Build the ST expression produced by the element with this `localId`.
|
|
fn expr_for(local_id: &str, by_id: &HashMap<String, Node>, depth: u8) -> String {
|
|
if depth > 24 {
|
|
return "0".to_string();
|
|
}
|
|
let Some(node) = by_id.get(local_id) else {
|
|
return format!("__net{local_id}");
|
|
};
|
|
match node.tag_name().name().to_ascii_lowercase().as_str() {
|
|
"invariable" | "inoutvariable" => {
|
|
expression_text(*node).unwrap_or_else(|| format!("__net{local_id}"))
|
|
}
|
|
// A block feeding another element: reference it by a synthetic result
|
|
// name; the block is emitted as its own call statement, so we neither
|
|
// duplicate the call nor lose it.
|
|
"block" => format!("__blk{local_id}"),
|
|
"contact" => {
|
|
let var = child_text(*node, "variable").unwrap_or_else(|| "TRUE".to_string());
|
|
let negated =
|
|
matches!(node.attribute("negated"), Some(v) if v.eq_ignore_ascii_case("true"));
|
|
let term = if negated { format!("NOT {var}") } else { var };
|
|
match ref_local_id(*node) {
|
|
Some(up) => {
|
|
let upstream = expr_for(&up, by_id, depth + 1);
|
|
if upstream == "TRUE" {
|
|
term
|
|
} else {
|
|
format!("({upstream} AND {term})")
|
|
}
|
|
}
|
|
None => term,
|
|
}
|
|
}
|
|
"leftpowerrail" => "TRUE".to_string(),
|
|
_ => format!("__net{local_id}"),
|
|
}
|
|
}
|
|
|
|
/// The `refLocalId` of `node`'s first input connection, if any.
|
|
fn ref_local_id(node: Node) -> Option<String> {
|
|
node.descendants()
|
|
.find(|n| tag_is(n, "connectionPointIn"))
|
|
.and_then(|cpi| cpi.descendants().find(|n| tag_is(n, "connection")))
|
|
.and_then(|c| c.attribute("refLocalId"))
|
|
.map(|s| s.to_string())
|
|
}
|
|
|
|
/// Text of a node's `<expression>` child (variable name or literal).
|
|
fn expression_text(node: Node) -> Option<String> {
|
|
let e = node.children().find(|n| tag_is(n, "expression"))?;
|
|
let t = collect_text(e).trim().to_string();
|
|
if t.is_empty() {
|
|
None
|
|
} else {
|
|
Some(t)
|
|
}
|
|
}
|
|
|
|
/// Text of a named child element (e.g. `<variable>` of a contact/coil).
|
|
fn child_text(node: Node, name: &str) -> Option<String> {
|
|
let c = node.children().find(|n| tag_is(n, name))?;
|
|
let t = collect_text(c).trim().to_string();
|
|
if t.is_empty() {
|
|
None
|
|
} else {
|
|
Some(t)
|
|
}
|
|
}
|
|
|
|
/// Concatenate the text of a node's descendant text nodes (bodies are often
|
|
/// wrapped in `<xhtml>` and may contain multiple text runs). Only text nodes are
|
|
/// gathered: an element's `.text()` would re-yield its first child's text, which
|
|
/// (with the text node itself) would duplicate every value.
|
|
fn collect_text(node: Node) -> String {
|
|
node.descendants()
|
|
.filter(|n| n.is_text())
|
|
.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: 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: 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: 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('\'', "''")))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::parse_plcopen;
|
|
use crate::pipeline::plc::rules;
|
|
use std::collections::HashSet;
|
|
|
|
fn rule_ids(xml: &str) -> HashSet<&'static str> {
|
|
parse_plcopen(xml)
|
|
.iter()
|
|
.flat_map(rules::analyze)
|
|
.map(|h| h.rule_id)
|
|
.collect()
|
|
}
|
|
|
|
/// A Ladder Diagram network: a rung (power rail → contact → coil) plus an
|
|
/// insecure comm block. Coils/contacts translate to assignments; the block
|
|
/// translates to a call so the port rule fires.
|
|
#[test]
|
|
fn ld_coil_and_block_translate_and_are_analysed() {
|
|
let xml = r#"<?xml version="1.0"?>
|
|
<project xmlns="http://www.plcopen.org/xml/tc6_0201">
|
|
<types><pous>
|
|
<pou name="Rung" pouType="program">
|
|
<interface><localVars>
|
|
<variable name="Motor"><type><BOOL/></type></variable>
|
|
</localVars></interface>
|
|
<body><LD>
|
|
<leftPowerRail localId="0"/>
|
|
<contact localId="1"><variable>Start</variable>
|
|
<connectionPointIn><connection refLocalId="0"/></connectionPointIn></contact>
|
|
<coil localId="2"><variable>Motor</variable>
|
|
<connectionPointIn><connection refLocalId="1"/></connectionPointIn></coil>
|
|
<inVariable localId="3"><expression>21</expression></inVariable>
|
|
<inVariable localId="4"><expression>FALSE</expression></inVariable>
|
|
<block localId="10" typeName="Ftp_Send">
|
|
<inputVariables>
|
|
<variable formalParameter="PORT">
|
|
<connectionPointIn><connection refLocalId="3"/></connectionPointIn></variable>
|
|
<variable formalParameter="ENCRYPT">
|
|
<connectionPointIn><connection refLocalId="4"/></connectionPointIn></variable>
|
|
</inputVariables>
|
|
</block>
|
|
</LD></body>
|
|
</pou>
|
|
</pous></types>
|
|
</project>"#;
|
|
let ids = rule_ids(xml);
|
|
// Ftp_Send(PORT := 21, ENCRYPT := FALSE) — port 21 is an insecure protocol.
|
|
assert!(
|
|
ids.contains("plc-insecure-protocol-port"),
|
|
"LD block should flag port 21; got {ids:?}"
|
|
);
|
|
}
|
|
|
|
/// Doubled-text regression: a graphical expression must be extracted once,
|
|
/// so literals like `502` and `FALSE` stay intact (not `502502`/`FALSEFALSE`).
|
|
#[test]
|
|
fn graphical_expression_text_is_not_duplicated() {
|
|
let xml = r#"<?xml version="1.0"?>
|
|
<project xmlns="http://www.plcopen.org/xml/tc6_0201">
|
|
<types><pous>
|
|
<pou name="Comm" pouType="program">
|
|
<body><FBD>
|
|
<inVariable localId="1"><expression>502</expression></inVariable>
|
|
<inVariable localId="2"><expression>FALSE</expression></inVariable>
|
|
<block localId="10" typeName="Modbus_TCP_Master">
|
|
<inputVariables>
|
|
<variable formalParameter="PORT">
|
|
<connectionPointIn><connection refLocalId="1"/></connectionPointIn></variable>
|
|
<variable formalParameter="AUTH">
|
|
<connectionPointIn><connection refLocalId="2"/></connectionPointIn></variable>
|
|
</inputVariables>
|
|
</block>
|
|
</FBD></body>
|
|
</pou>
|
|
</pous></types>
|
|
</project>"#;
|
|
let ids = rule_ids(xml);
|
|
assert!(ids.contains("plc-insecure-protocol-port")); // PORT := 502 (not 502502)
|
|
assert!(ids.contains("plc-insecure-comm")); // AUTH := FALSE (not FALSEFALSE)
|
|
}
|
|
}
|