pub mod error;
pub mod ping;
pub mod policy;
pub mod report;
use std::{borrow::Cow, str::FromStr};
use roxmltree::{Document, StringStorage};
use crate::{error::FormatError, policy::Policy, report::Report};
#[derive(Debug)]
pub struct NessusClientDataV2<'input> {
pub policy: Policy<'input>,
pub report: Option<Report<'input>>,
}
impl<'input> NessusClientDataV2<'input> {
pub fn parse(xml: &'input str) -> Result<Self, FormatError> {
let doc = Document::parse(xml)?;
let root = doc.root_element();
if root.tag_name().name() != "NessusClientData_v2" {
return Err(FormatError::UnsupportedVersion);
}
let mut policy = None;
let mut report = None;
for child in root.children() {
match child.tag_name().name() {
"Policy" => {
if policy.is_some() {
return Err(FormatError::RepeatedTag("Policy"));
}
policy = Some(Policy::from_xml_node(child)?);
}
"Report" => {
if report.is_some() {
return Err(FormatError::RepeatedTag("Report"));
}
report = Some(Report::from_xml_node(child)?);
}
_ => assert_empty_text(child)?,
}
}
let policy = policy.ok_or(FormatError::MissingTag("Policy"))?;
Ok(Self { policy, report })
}
}
fn assert_empty_text(node: roxmltree::Node<'_, '_>) -> Result<(), FormatError> {
let Some(text) = node.text() else {
return Err(FormatError::UnexpectedNodeKind);
};
if !text.trim().is_empty() {
return Err(FormatError::UnexpectedNode(
format!("{}: {text}", node.tag_name().name()).into_boxed_str(),
));
}
Ok(())
}
trait StringStorageExt<'input> {
fn to_str(&self) -> Result<&'input str, FormatError>;
fn to_cow(&self) -> Cow<'input, str>;
}
impl<'input> StringStorageExt<'input> for StringStorage<'input> {
fn to_str(&self) -> Result<&'input str, FormatError> {
match self {
StringStorage::Borrowed(s) => Ok(s),
StringStorage::Owned(s) => Err(FormatError::UnexpectedXmlAttribute(s.as_ref().into())),
}
}
fn to_cow(&self) -> Cow<'input, str> {
match self {
StringStorage::Borrowed(s) => Cow::Borrowed(s),
StringStorage::Owned(s) => Cow::Owned(String::from(s.as_ref())),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct MacAddress {
bytes: [u8; 6],
}
impl MacAddress {
#[must_use]
pub const fn bytes(self) -> [u8; 6] {
self.bytes
}
}
impl FromStr for MacAddress {
type Err = FormatError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut octets = s.split(':');
let mac_address = Self {
bytes: [
parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
parse_octet(octets.next().ok_or(FormatError::MacAddressParse)?)?,
],
};
if octets.next().is_some() {
Err(FormatError::MacAddressParse)
} else {
Ok(mac_address)
}
}
}
fn parse_octet(input: &str) -> Result<u8, FormatError> {
let &[a, b] = input.as_bytes() else {
return Err(FormatError::MacAddressParse);
};
Ok((parse_hex_digit(a)? << 4) | parse_hex_digit(b)?)
}
const fn parse_hex_digit(ch: u8) -> Result<u8, FormatError> {
match ch {
b'0'..=b'9' => Ok(ch - b'0'),
b'A'..=b'F' => Ok((ch - b'A') + 10),
b'a'..=b'f' => Ok((ch - b'a') + 10),
_ => Err(FormatError::MacAddressParse),
}
}
impl std::fmt::Display for MacAddress {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
self.bytes[0],
self.bytes[1],
self.bytes[2],
self.bytes[3],
self.bytes[4],
self.bytes[5]
)
}
}
impl std::fmt::Debug for MacAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "\"{self}\"")
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::{FormatError, MacAddress, NessusClientDataV2};
const MINIMAL_POLICY: &str = r"
<Policy>
<policyName>p</policyName>
<Preferences>
<ServerPreferences>
<preference><name>whoami</name><value>u</value></preference>
<preference><name>scan_description</name><value>d</value></preference>
<preference><name>TARGET</name><value>127.0.0.1</value></preference>
<preference><name>port_range</name><value>default</value></preference>
<preference><name>scan_start_timestamp</name><value>1</value></preference>
<preference><name>plugin_set</name><value>;1;</value></preference>
<preference><name>name</name><value>n</value></preference>
</ServerPreferences>
<PluginsPreferences/>
</Preferences>
<FamilySelection/>
<IndividualPluginSelection/>
</Policy>
";
#[test]
fn parse_allows_missing_report_tag() {
let xml = format!("<NessusClientData_v2>{MINIMAL_POLICY}</NessusClientData_v2>");
let parsed = NessusClientDataV2::parse(&xml).expect("XML should parse");
assert!(parsed.report.is_none());
}
#[test]
fn parse_rejects_repeated_policy_tag() {
let xml =
format!("<NessusClientData_v2>{MINIMAL_POLICY}{MINIMAL_POLICY}</NessusClientData_v2>");
let err = NessusClientDataV2::parse(&xml).expect_err("duplicate Policy must fail");
assert!(matches!(err, FormatError::RepeatedTag("Policy")));
}
#[test]
fn parse_rejects_repeated_report_tag() {
let xml = format!(
"<NessusClientData_v2>{MINIMAL_POLICY}<Report name=\"r\"/><Report name=\"r2\"/></NessusClientData_v2>"
);
let err = NessusClientDataV2::parse(&xml).expect_err("duplicate Report must fail");
assert!(matches!(err, FormatError::RepeatedTag("Report")));
}
#[test]
fn parse_rejects_non_empty_unknown_top_level_text() {
let xml = format!(
"<NessusClientData_v2>{MINIMAL_POLICY}<extra>boom</extra></NessusClientData_v2>"
);
let err =
NessusClientDataV2::parse(&xml).expect_err("non-empty unknown node text must fail");
assert!(matches!(err, FormatError::UnexpectedNode(_)));
}
#[test]
fn mac_address_rejects_invalid_inputs() {
for input in [
"0a:1b:2c:3d:4e",
"0a:1b:2c:3d:4e:5f:6a",
"0a:1b:2c:3d:4e:zz",
"0:1b:2c:3d:4e:5f",
] {
let err = MacAddress::from_str(input).expect_err("invalid MAC must fail");
assert!(matches!(err, FormatError::MacAddressParse));
}
}
}