use std::{borrow::Cow, collections::HashMap, str::FromStr};
use jiff::civil::Date;
use roxmltree::Node;
use crate::{StringStorageExt, error::FormatError};
#[derive(Debug, PartialEq, Eq, Clone, Copy, PartialOrd, Ord)]
pub enum Protocol {
Tcp,
Udp,
Icmp,
}
impl Protocol {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Tcp => "tcp",
Self::Udp => "udp",
Self::Icmp => "icmp",
}
}
}
impl FromStr for Protocol {
type Err = FormatError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"tcp" => Ok(Self::Tcp),
"udp" => Ok(Self::Udp),
"icmp" => Ok(Self::Icmp),
other => Err(FormatError::UnexpectedProtocol(other.into())),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum PluginType {
Summary,
Remote,
Combined,
Local,
}
impl FromStr for PluginType {
type Err = FormatError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"summary" => Ok(Self::Summary),
"remote" => Ok(Self::Remote),
"combined" => Ok(Self::Combined),
"local" => Ok(Self::Local),
other => Err(FormatError::UnexpectedPluginType(other.into())),
}
}
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
pub enum Level {
None = 0,
Low = 1,
Medium = 2,
High = 3,
Critical = 4,
}
impl Level {
fn from_int(int: &str) -> Result<Self, FormatError> {
match int {
"0" => Ok(Self::None),
"1" => Ok(Self::Low),
"2" => Ok(Self::Medium),
"3" => Ok(Self::High),
"4" => Ok(Self::Critical),
other => Err(FormatError::UnexpectedLevel(other.into())),
}
}
fn from_text(s: &str) -> Result<Self, FormatError> {
match s {
"None" => Ok(Self::None),
"Low" => Ok(Self::Low),
"Medium" => Ok(Self::Medium),
"High" => Ok(Self::High),
"Critical" => Ok(Self::Critical),
other => Err(FormatError::UnexpectedLevel(other.into())),
}
}
}
#[derive(Debug)]
pub struct Item<'input> {
pub plugin_id: u32,
pub plugin_name: Cow<'input, str>,
pub port: u16,
pub protocol: Protocol,
pub svc_name: &'input str,
pub severity: Level,
pub plugin_family: &'input str,
pub plugin_output: Option<Cow<'input, str>>,
pub solution: Cow<'input, str>,
pub script_version: &'input str,
pub risk_factor: Level,
pub plugin_type: PluginType,
pub plugin_publication_date: jiff::civil::Date,
pub plugin_modification_date: jiff::civil::Date,
pub fname: &'input str,
pub description: Cow<'input, str>,
pub exploit_available: bool,
pub exploited_by_nessus: bool,
pub exploitability_ease: Option<&'input str>,
pub agent: Option<&'input str>,
pub cvss_vector: Option<&'input str>,
pub cvss_temporal_vector: Option<&'input str>,
pub cvss3_vector: Option<&'input str>,
pub cvss3_temporal_vector: Option<&'input str>,
pub cvss4_vector: Option<&'input str>,
pub cvss4_threat_vector: Option<&'input str>,
pub others: HashMap<&'input str, Vec<Cow<'input, str>>>,
}
impl<'input> Item<'input> {
#[expect(clippy::too_many_lines, clippy::similar_names)]
pub(crate) fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
let mut plugin_id = None;
let mut plugin_name = None;
let mut port = None;
let mut protocol = None;
let mut svc_name = None;
let mut severity = None;
let mut plugin_family = None;
for attribute in node.attributes() {
match attribute.name() {
"pluginID" => {
if plugin_id.is_some() {
return Err(FormatError::RepeatedTag("pluginID"));
}
plugin_id = Some(attribute.value_storage().parse()?);
}
"pluginName" => {
if plugin_name.is_some() {
return Err(FormatError::RepeatedTag("pluginName"));
}
plugin_name = Some(attribute.value_storage().to_cow());
}
"port" => {
if port.is_some() {
return Err(FormatError::RepeatedTag("port"));
}
port = Some(attribute.value().parse()?);
}
"protocol" => {
if protocol.is_some() {
return Err(FormatError::RepeatedTag("protocol"));
}
protocol = Some(attribute.value_storage().parse()?);
}
"svc_name" => {
if svc_name.is_some() {
return Err(FormatError::RepeatedTag("svc_name"));
}
svc_name = Some(attribute.value_storage().to_str()?);
}
"severity" => {
if severity.is_some() {
return Err(FormatError::RepeatedTag("severity"));
}
severity = Some(Level::from_int(attribute.value())?);
}
"pluginFamily" => {
if plugin_family.is_some() {
return Err(FormatError::RepeatedTag("pluginFamily"));
}
plugin_family = Some(attribute.value_storage().to_str()?);
}
other => return Err(FormatError::UnexpectedXmlAttribute(other.into())),
}
}
let mut plugin_output = None;
let mut solution = None;
let mut script_version = None;
let mut risk_factor = None;
let mut plugin_type = None;
let mut plugin_publication_date = None;
let mut plugin_modification_date = None;
let mut fname = None;
let mut description = None;
let mut agent = None;
let mut cvss_vector = None;
let mut cvss3_vector = None;
let mut cvss_temporal_vector = None;
let mut cvss3_temporal_vector = None;
let mut cvss4_vector = None;
let mut cvss4_threat_vector = None;
let mut exploitability_ease = None;
let mut exploit_available = None;
let mut exploited_by_nessus = None;
let mut others: HashMap<_, Vec<_>> = HashMap::new();
for child in node.children() {
if child.is_text() {
if let Some(text) = child.text()
&& !text.trim().is_empty()
{
return Err(FormatError::UnexpectedText(text.into()));
}
continue;
}
let name = child.tag_name().name();
if let Some(value) = child.text_storage() {
match name {
"plugin_output" => {
if plugin_output.is_some() {
return Err(FormatError::RepeatedTag("plugin_output"));
}
plugin_output = Some(value.to_cow());
}
"solution" => {
if solution.is_some() {
return Err(FormatError::RepeatedTag("solution"));
}
solution = Some(value.to_cow());
}
"description" => {
if description.is_some() {
return Err(FormatError::RepeatedTag("description"));
}
description = Some(value.to_cow());
}
"script_version" => {
if script_version.is_some() {
return Err(FormatError::RepeatedTag("script_version"));
}
script_version = Some(value.to_str()?);
}
"risk_factor" => {
if risk_factor.is_some() {
return Err(FormatError::RepeatedTag("risk_factor"));
}
risk_factor = Some(Level::from_text(value.as_str())?);
}
"plugin_type" => {
if plugin_type.is_some() {
return Err(FormatError::RepeatedTag("plugin_type"));
}
plugin_type = Some(value.parse()?);
}
"plugin_publication_date" => {
if plugin_publication_date.is_some() {
return Err(FormatError::RepeatedTag("plugin_publication_date"));
}
plugin_publication_date = Some(Date::strptime("%Y/%m/%d", value.as_str())?);
}
"plugin_modification_date" => {
if plugin_modification_date.is_some() {
return Err(FormatError::RepeatedTag("plugin_modification_date"));
}
plugin_modification_date =
Some(Date::strptime("%Y/%m/%d", value.as_str())?);
}
"fname" => {
if fname.is_some() {
return Err(FormatError::RepeatedTag("fname"));
}
fname = Some(value.to_str()?);
}
"agent" => {
if agent.is_some() {
return Err(FormatError::RepeatedTag("agent"));
}
agent = Some(value.to_str()?);
}
"cvss_vector" => {
if cvss_vector.is_some() {
return Err(FormatError::RepeatedTag("cvss_vector"));
}
cvss_vector = Some(value.to_str()?);
}
"cvss3_vector" => {
if cvss3_vector.is_some() {
return Err(FormatError::RepeatedTag("cvss3_vector"));
}
cvss3_vector = Some(value.to_str()?);
}
"cvss_temporal_vector" => {
if cvss_temporal_vector.is_some() {
return Err(FormatError::RepeatedTag("cvss_temporal_vector"));
}
cvss_temporal_vector = Some(value.to_str()?);
}
"cvss3_temporal_vector" => {
if cvss3_temporal_vector.is_some() {
return Err(FormatError::RepeatedTag("cvss3_temporal_vector"));
}
cvss3_temporal_vector = Some(value.to_str()?);
}
"cvss4_vector" => {
if cvss4_vector.is_some() {
return Err(FormatError::RepeatedTag("cvss4_vector"));
}
cvss4_vector = Some(value.to_str()?);
}
"cvss4_threat_vector" => {
if cvss4_threat_vector.is_some() {
return Err(FormatError::RepeatedTag("cvss4_threat_vector"));
}
cvss4_threat_vector = Some(value.to_str()?);
}
"exploitability_ease" => {
if exploitability_ease.is_some() {
return Err(FormatError::RepeatedTag("exploitability_ease"));
}
exploitability_ease = Some(value.to_str()?);
}
"exploit_available" => {
if exploit_available.is_some() {
return Err(FormatError::RepeatedTag("exploit_available"));
}
exploit_available = Some(value.as_str() == "true");
}
"exploited_by_nessus" => {
if exploited_by_nessus.is_some() {
return Err(FormatError::RepeatedTag("exploited_by_nessus"));
}
exploited_by_nessus = Some(value.as_str() == "true");
}
_ => others.entry(name).or_default().push(value.to_cow()),
}
} else {
return Err(FormatError::UnexpectedNode(name.into()));
}
}
Ok(Self {
plugin_id: plugin_id.ok_or(FormatError::MissingAttribute("pluginID"))?,
plugin_name: plugin_name.ok_or(FormatError::MissingAttribute("pluginName"))?,
port: port.ok_or(FormatError::MissingAttribute("port"))?,
protocol: protocol.ok_or(FormatError::MissingAttribute("protocol"))?,
svc_name: svc_name.ok_or(FormatError::MissingAttribute("svc_name"))?,
severity: severity.ok_or(FormatError::MissingAttribute("severity"))?,
plugin_family: plugin_family.ok_or(FormatError::MissingAttribute("pluginFamily"))?,
solution: solution.ok_or(FormatError::MissingTag("solution"))?,
script_version: script_version.ok_or(FormatError::MissingTag("script_version"))?,
risk_factor: risk_factor.ok_or(FormatError::MissingTag("risk_factor"))?,
plugin_type: plugin_type.ok_or(FormatError::MissingTag("plugin_type"))?,
plugin_publication_date: plugin_publication_date
.ok_or(FormatError::MissingTag("plugin_publication_date"))?,
plugin_modification_date: plugin_modification_date
.ok_or(FormatError::MissingTag("plugin_modification_date"))?,
fname: fname.ok_or(FormatError::MissingTag("fname"))?,
description: description.ok_or(FormatError::MissingTag("description"))?,
plugin_output,
agent,
cvss_vector,
cvss3_vector,
cvss_temporal_vector,
cvss3_temporal_vector,
cvss4_vector,
cvss4_threat_vector,
exploitability_ease,
exploit_available: exploit_available == Some(true),
exploited_by_nessus: exploited_by_nessus == Some(true),
others,
})
}
}
#[cfg(test)]
mod tests {
use roxmltree::Document;
use crate::error::FormatError;
use super::{Item, Level, PluginType, Protocol};
fn parse_item(xml: &str) -> Result<Item<'_>, FormatError> {
let doc = Document::parse(xml).expect("test XML should parse");
let node = doc.root_element();
Item::from_xml_node(node)
}
fn minimal_item_xml(extra_attributes: &str, extra_children: &str) -> String {
format!(
r#"<ReportItem pluginID="1" pluginName="x" port="80" protocol="tcp" svc_name="www" severity="2" pluginFamily="General" {extra_attributes}>
<solution>fix</solution>
<script_version>1.0</script_version>
<risk_factor>Medium</risk_factor>
<plugin_type>remote</plugin_type>
<plugin_publication_date>2024/01/01</plugin_publication_date>
<plugin_modification_date>2024/01/02</plugin_modification_date>
<fname>x.nasl</fname>
<description>desc</description>
{extra_children}
</ReportItem>"#
)
}
#[test]
fn protocol_and_plugin_type_parsing_cover_invalid_values() {
assert!(matches!("tcp".parse(), Ok(Protocol::Tcp)));
assert!(matches!(
"not-proto".parse::<Protocol>(),
Err(FormatError::UnexpectedProtocol(_))
));
assert!(matches!("local".parse(), Ok(PluginType::Local)));
assert!(matches!(
"bad".parse::<PluginType>(),
Err(FormatError::UnexpectedPluginType(_))
));
}
#[test]
fn item_rejects_unknown_attribute() {
let xml = minimal_item_xml(r#"bad="x""#, "");
let err = parse_item(&xml).expect_err("must fail");
assert!(matches!(err, FormatError::UnexpectedXmlAttribute(_)));
}
#[test]
fn item_rejects_non_empty_text_node() {
let xml = minimal_item_xml("", "hello");
let err = parse_item(&xml).expect_err("must fail");
assert!(matches!(err, FormatError::UnexpectedText(_)));
}
#[test]
fn item_rejects_missing_required_fields() {
let xml = r#"<ReportItem pluginID="1" pluginName="x" port="80" protocol="tcp" svc_name="www" severity="2" pluginFamily="General"></ReportItem>"#;
let err = parse_item(xml).expect_err("must fail");
assert!(matches!(err, FormatError::MissingTag("solution")));
}
#[test]
fn item_rejects_repeated_child_tag() {
let xml = minimal_item_xml("", "<solution>again</solution>");
let err = parse_item(&xml).expect_err("must fail");
assert!(matches!(err, FormatError::RepeatedTag("solution")));
}
#[test]
fn item_coerces_exploit_booleans() {
let xml = minimal_item_xml(
"",
r"
<exploit_available>true</exploit_available>
<exploited_by_nessus>not-true</exploited_by_nessus>
",
);
let item = parse_item(&xml).expect("must parse");
assert!(item.exploit_available);
assert!(!item.exploited_by_nessus);
}
#[test]
fn level_invalid_values_fail() {
let xml = minimal_item_xml("", "").replace(
"<risk_factor>Medium</risk_factor>",
"<risk_factor>NotALevel</risk_factor>",
);
let err = parse_item(&xml).expect_err("must fail");
assert!(matches!(err, FormatError::UnexpectedLevel(_)));
assert!(matches!(
Level::from_int("9"),
Err(FormatError::UnexpectedLevel(_))
));
assert!(matches!(
Level::from_text("NotALevel"),
Err(FormatError::UnexpectedLevel(_))
));
}
}