Skip to main content

nessus_parser/
policy.rs

1//! Types for Nessus `<Policy>` configuration and preference parsing.
2
3use std::{borrow::Cow, collections::HashMap};
4
5use jiff::Timestamp;
6use roxmltree::{Node, StringStorage};
7
8use crate::{StringStorageExt, assert_empty_text, error::FormatError};
9
10/// Represents the `<Policy>` element within a Nessus report.
11///
12/// This struct holds the complete configuration of the scan policy,
13/// including its name, comments, and detailed preferences for server
14/// behavior and plugin selection.
15#[derive(Debug)]
16pub struct Policy<'input> {
17    /// The name of the policy (from the `<policyName>` tag).
18    pub policy_name: Cow<'input, str>,
19    /// Comments associated with the policy (from `<policyComments>`).
20    pub policy_comments: Option<Cow<'input, str>>,
21    /// Server and plugin preferences for the scan.
22    pub preferences: Preferences<'input>,
23    /// The selection of plugin families to be used in the scan.
24    pub family_selection: Vec<FamilyItem<'input>>,
25    /// The selection of individual plugins to be used in the scan.
26    pub individual_plugin_selection: Vec<PluginItem<'input>>,
27}
28
29impl<'input> Policy<'input> {
30    pub(crate) fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
31        let mut policy_name = None;
32        let mut policy_comments = None;
33        let mut preferences = None;
34        let mut family_selection = None;
35        let mut individual_plugin_selection = None;
36
37        for child in node.children() {
38            match child.tag_name().name() {
39                "policyName" => {
40                    if policy_name.is_some() {
41                        return Err(FormatError::RepeatedTag("policyName"));
42                    }
43                    policy_name = child.text_storage().map(StringStorageExt::to_cow);
44                }
45                "policyComments" => {
46                    if policy_comments.is_some() {
47                        return Err(FormatError::RepeatedTag("policyComments"));
48                    }
49                    policy_comments = child.text_storage().map(StringStorageExt::to_cow);
50                }
51                "Preferences" => {
52                    if preferences.is_some() {
53                        return Err(FormatError::RepeatedTag("Preferences"));
54                    }
55                    preferences = Some(Preferences::from_xml_node(child)?);
56                }
57                "FamilySelection" => {
58                    if family_selection.is_some() {
59                        return Err(FormatError::RepeatedTag("FamilySelection"));
60                    }
61
62                    let mut items = vec![];
63                    for child in child.children() {
64                        if child.tag_name().name() == "FamilyItem" {
65                            items.push(FamilyItem::from_xml_node(child)?);
66                        } else {
67                            assert_empty_text(child)?;
68                        }
69                    }
70                    family_selection = Some(items);
71                }
72                "IndividualPluginSelection" => {
73                    if individual_plugin_selection.is_some() {
74                        return Err(FormatError::RepeatedTag("IndividualPluginSelection"));
75                    }
76
77                    let mut items = vec![];
78                    for child in child.children() {
79                        if child.tag_name().name() == "PluginItem" {
80                            items.push(PluginItem::from_xml_node(child)?);
81                        } else {
82                            assert_empty_text(child)?;
83                        }
84                    }
85                    individual_plugin_selection = Some(items);
86                }
87                _ => assert_empty_text(child)?,
88            }
89        }
90
91        Ok(Self {
92            policy_name: policy_name.ok_or(FormatError::MissingTag("policyName"))?,
93            policy_comments,
94            preferences: preferences.ok_or(FormatError::MissingTag("Preferences"))?,
95            family_selection: family_selection.ok_or(FormatError::MissingTag("FamilySelection"))?,
96            individual_plugin_selection: individual_plugin_selection
97                .ok_or(FormatError::MissingTag("IndividualPluginSelection"))?,
98        })
99    }
100}
101
102/// Represents the `<Preferences>` element within a scan policy.
103///
104/// This acts as a container for both server-level and plugin-specific
105/// preferences that define the scan's behavior.
106#[derive(Debug)]
107pub struct Preferences<'a> {
108    /// A collection of server-wide settings for the scan.
109    pub server_preferences: ServerPreferences<'a>,
110    /// A collection of preferences specific to individual plugins.
111    pub plugins_preferences: Vec<PluginPreferenceItem<'a>>,
112}
113
114impl<'input> Preferences<'input> {
115    fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
116        let mut server_preferences = None;
117        let mut plugins_preferences = None;
118
119        for child in node.children() {
120            match child.tag_name().name() {
121                "ServerPreferences" => {
122                    if server_preferences.is_some() {
123                        return Err(FormatError::RepeatedTag("ServerPreferences"));
124                    }
125                    server_preferences = Some(ServerPreferences::from_xml_node(child)?);
126                }
127                "PluginsPreferences" => {
128                    if plugins_preferences.is_some() {
129                        return Err(FormatError::RepeatedTag("PluginsPreferences"));
130                    }
131                    let mut items = vec![];
132                    for item_node in child.children() {
133                        if item_node.tag_name().name() == "item" {
134                            items.push(PluginPreferenceItem::from_xml_node(item_node)?);
135                        } else {
136                            assert_empty_text(item_node)?;
137                        }
138                    }
139                    plugins_preferences = Some(items);
140                }
141                _ => assert_empty_text(child)?,
142            }
143        }
144
145        Ok(Self {
146            server_preferences: server_preferences
147                .ok_or(FormatError::MissingTag("ServerPreferences"))?,
148            plugins_preferences: plugins_preferences
149                .ok_or(FormatError::MissingTag("PluginsPreferences"))?,
150        })
151    }
152}
153
154/// Represents the `<ServerPreferences>` element, containing detailed
155/// settings for the Nessus scanner's behavior during the scan.
156#[derive(Debug)]
157pub struct ServerPreferences<'input> {
158    /// The user who launched the scan
159    pub whoami: Cow<'input, str>,
160    /// The user-defined name for the scan
161    pub scan_name: Option<Cow<'input, str>>,
162    /// The user-defined description for the scan
163    pub scan_description: Cow<'input, str>,
164    /// An alternative description field for the scan
165    pub description: Option<Cow<'input, str>>,
166    /// A list of targets for the scan (e.g., IP addresses, CIDR ranges).
167    // 1.2.3.4,192.168.0.0/24, ...
168    pub target: Vec<&'input str>,
169    /// The port range to be scanned (e.g., "1-65535", "default", "all").
170    pub port_range: &'input str,
171    /// The timestamp when the scan was initiated.
172    pub scan_start_timestamp_seconds: jiff::Timestamp,
173    /// The timestamp when the scan was completed.
174    pub scan_end_timestamp_seconds: Option<jiff::Timestamp>,
175    /// The set of all plugin IDs that were active for the scan.
176    // "...;28505;28497;28507;28502;28508;..." (gigantic list)
177    pub plugin_set: &'input str,
178    /// The name of the scan policy (e.g., "Advanced Scan").
179    pub name: Cow<'input, str>,
180    /// The discovery mode used for the scan (e.g., `"portscan_common"`, `"custom"`).
181    // None | Some("portscan_all") | Some("host_enumeration")
182    // | Some("custom") | Some("portscan_common") | Some("log4shell_thorough")
183    // | Some("identity_quick") | Some("log4shell_dc_normal")
184    pub discovery_mode: Option<&'input str>,
185    /// A map to hold any other server preferences not explicitly parsed into
186    /// other fields. The key is the preference name.
187    pub others: HashMap<&'input str, Vec<Cow<'input, str>>>,
188}
189
190impl<'input> ServerPreferences<'input> {
191    #[allow(clippy::too_many_lines)]
192    fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
193        let mut whoami = None;
194        let mut scan_name = None;
195        let mut scan_description = None;
196        let mut description = None;
197        let mut target = None;
198        let mut port_range = None;
199        let mut scan_start_timestamp_seconds = None;
200        let mut scan_end_timestamp_seconds = None;
201        let mut plugin_set = None;
202        let mut name_name = None;
203        let mut discovery_mode = None;
204
205        let mut others: HashMap<&'input str, Vec<Cow<'input, str>>> = HashMap::new();
206
207        for child in node.children() {
208            if child.tag_name().name() != "preference" {
209                assert_empty_text(child)?;
210                continue;
211            }
212
213            let (name, value) = get_preference_name_value(child)?;
214
215            match name {
216                "whoami" => {
217                    if whoami.is_some() {
218                        return Err(FormatError::RepeatedTag("whoami"));
219                    }
220                    whoami = Some(value.to_cow());
221                }
222                "scan_name" => {
223                    if scan_name.is_some() {
224                        return Err(FormatError::RepeatedTag("scan_name"));
225                    }
226                    scan_name = Some(value.to_cow());
227                }
228                "scan_description" => {
229                    if scan_description.is_some() {
230                        return Err(FormatError::RepeatedTag("scan_description"));
231                    }
232                    scan_description = Some(value.to_cow());
233                }
234                "description" => {
235                    if description.is_some() {
236                        return Err(FormatError::RepeatedTag("description"));
237                    }
238                    description = Some(value.to_cow());
239                }
240                "TARGET" => {
241                    if target.is_some() {
242                        return Err(FormatError::RepeatedTag("TARGET"));
243                    }
244                    target = Some(value.to_str()?.split(',').collect());
245                }
246                "port_range" => {
247                    if port_range.is_some() {
248                        return Err(FormatError::RepeatedTag("port_range"));
249                    }
250                    port_range = Some(value.to_str()?);
251                }
252                "scan_start_timestamp" => {
253                    if scan_start_timestamp_seconds.is_some() {
254                        return Err(FormatError::RepeatedTag("scan_start_timestamp"));
255                    }
256                    scan_start_timestamp_seconds =
257                        Some(Timestamp::from_second(value.parse::<i64>()?)?);
258                }
259                "scan_end_timestamp" => {
260                    if scan_end_timestamp_seconds.is_some() {
261                        return Err(FormatError::RepeatedTag("scan_end_timestamp"));
262                    }
263                    scan_end_timestamp_seconds =
264                        Some(Timestamp::from_second(value.parse::<i64>()?)?);
265                }
266                "plugin_set" => {
267                    if plugin_set.is_some() {
268                        return Err(FormatError::RepeatedTag("plugin_set"));
269                    }
270
271                    plugin_set = Some(value.to_str()?);
272                }
273                "name" => {
274                    if name_name.is_some() {
275                        return Err(FormatError::RepeatedTag("name"));
276                    }
277                    name_name = Some(value.to_cow());
278                }
279                "discovery_mode" => {
280                    if discovery_mode.is_some() {
281                        return Err(FormatError::RepeatedTag("discovery_mode"));
282                    }
283                    discovery_mode = Some(value.to_str()?);
284                }
285                other_name => {
286                    others.entry(other_name).or_default().push(value.to_cow());
287                }
288            }
289        }
290
291        Ok(Self {
292            whoami: whoami.ok_or(FormatError::MissingTag("whoami"))?,
293            scan_name,
294            scan_description: scan_description
295                .ok_or(FormatError::MissingTag("scan_description"))?,
296            description,
297            target: target.ok_or(FormatError::MissingTag("TARGET"))?,
298            port_range: port_range.ok_or(FormatError::MissingTag("port_range"))?,
299            scan_start_timestamp_seconds: scan_start_timestamp_seconds
300                .ok_or(FormatError::MissingTag("scan_start_timestamp"))?,
301            scan_end_timestamp_seconds,
302            plugin_set: plugin_set.ok_or(FormatError::MissingTag("plugin_set"))?,
303            name: name_name.ok_or(FormatError::MissingTag("name"))?,
304            discovery_mode,
305            others,
306        })
307    }
308}
309
310fn get_preference_name_value<'input, 'a>(
311    child: Node<'a, 'input>,
312) -> Result<(&'input str, &'a StringStorage<'input>), FormatError> {
313    let mut name = None;
314    let mut value = None;
315
316    for sub_node in child.children() {
317        match sub_node.tag_name().name() {
318            "name" => {
319                if name.is_some() {
320                    return Err(FormatError::RepeatedTag("name"));
321                }
322                name = sub_node
323                    .text_storage()
324                    .map(StringStorageExt::to_str)
325                    .transpose()?;
326            }
327            "value" => {
328                if value.is_some() {
329                    return Err(FormatError::RepeatedTag("value"));
330                }
331                value = Some(
332                    sub_node
333                        .text_storage()
334                        .unwrap_or(&StringStorage::Borrowed("")),
335                );
336            }
337            _ => assert_empty_text(sub_node)?,
338        }
339    }
340
341    let name = name.ok_or(FormatError::MissingTag("name"))?;
342    let value = value.ok_or(FormatError::MissingTag("value"))?;
343
344    Ok((name, value))
345}
346
347/// Represents an individual `<item>` within `<PluginsPreferences>`.
348#[derive(Debug)]
349pub struct PluginPreferenceItem<'input> {
350    pub plugin_name: Cow<'input, str>,
351    pub plugin_id: u32,
352    pub full_name: Cow<'input, str>,
353    pub preference_name: Cow<'input, str>,
354    pub preference_type: Cow<'input, str>,
355    pub preference_values: Option<Cow<'input, str>>,
356    pub selected_value: Option<Cow<'input, str>>,
357}
358
359impl<'input> PluginPreferenceItem<'input> {
360    fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
361        let mut plugin_name = None;
362        let mut plugin_id = None;
363        let mut full_name = None;
364        let mut preference_name = None;
365        let mut preference_type = None;
366        let mut preference_values = None;
367        let mut selected_value = None;
368
369        for child in node.children() {
370            match child.tag_name().name() {
371                "pluginName" => {
372                    if plugin_name.is_some() {
373                        return Err(FormatError::RepeatedTag("pluginName"));
374                    }
375                    plugin_name = child.text_storage().map(StringStorageExt::to_cow);
376                }
377                "pluginId" => {
378                    if plugin_id.is_some() {
379                        return Err(FormatError::RepeatedTag("pluginId"));
380                    }
381                    let val = child.text().ok_or(FormatError::MissingTag("pluginId"))?;
382                    plugin_id = Some(val.parse()?);
383                }
384                "fullName" => {
385                    if full_name.is_some() {
386                        return Err(FormatError::RepeatedTag("fullName"));
387                    }
388                    full_name = child.text_storage().map(StringStorageExt::to_cow);
389                }
390                "preferenceName" => {
391                    if preference_name.is_some() {
392                        return Err(FormatError::RepeatedTag("preferenceName"));
393                    }
394                    preference_name = child.text_storage().map(StringStorageExt::to_cow);
395                }
396                "preferenceType" => {
397                    if preference_type.is_some() {
398                        return Err(FormatError::RepeatedTag("preferenceType"));
399                    }
400                    preference_type = child.text_storage().map(StringStorageExt::to_cow);
401                }
402                "preferenceValues" => {
403                    if preference_values.is_some() {
404                        return Err(FormatError::RepeatedTag("preferenceValues"));
405                    }
406                    preference_values = child.text_storage().map(StringStorageExt::to_cow);
407                }
408                "selectedValue" => {
409                    if selected_value.is_some() {
410                        return Err(FormatError::RepeatedTag("selectedValue"));
411                    }
412                    selected_value = child.text_storage().map(StringStorageExt::to_cow);
413                }
414                _ => assert_empty_text(child)?,
415            }
416        }
417
418        Ok(Self {
419            plugin_name: plugin_name.ok_or(FormatError::MissingTag("pluginName"))?,
420            plugin_id: plugin_id.ok_or(FormatError::MissingTag("pluginId"))?,
421            full_name: full_name.ok_or(FormatError::MissingTag("fullName"))?,
422            preference_name: preference_name.ok_or(FormatError::MissingTag("preferenceName"))?,
423            preference_type: preference_type.ok_or(FormatError::MissingTag("preferenceType"))?,
424            preference_values,
425            selected_value,
426        })
427    }
428}
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
431pub enum FamilyStatus {
432    Enabled,
433    Disabled,
434    Mixed,
435}
436
437impl std::str::FromStr for FamilyStatus {
438    type Err = FormatError;
439
440    fn from_str(s: &str) -> Result<Self, Self::Err> {
441        match s {
442            "enabled" => Ok(Self::Enabled),
443            "disabled" => Ok(Self::Disabled),
444            "mixed" => Ok(Self::Mixed),
445            _ => Err(FormatError::UnexpectedFormat("FamilyStatus")),
446        }
447    }
448}
449
450/// Represents an individual plugin family selection entry.
451#[derive(Debug)]
452pub struct FamilyItem<'input> {
453    pub family_name: Cow<'input, str>,
454    pub status: FamilyStatus,
455}
456
457impl<'input> FamilyItem<'input> {
458    fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
459        let mut family_name = None;
460        let mut status = None;
461
462        for child in node.children() {
463            match child.tag_name().name() {
464                "FamilyName" => {
465                    if family_name.is_some() {
466                        return Err(FormatError::RepeatedTag("FamilyName"));
467                    }
468                    family_name = child.text_storage().map(StringStorageExt::to_cow);
469                }
470                "Status" => {
471                    if status.is_some() {
472                        return Err(FormatError::RepeatedTag("Status"));
473                    }
474                    let val = child.text().ok_or(FormatError::MissingTag("Status"))?;
475                    status = Some(val.parse()?);
476                }
477                _ => assert_empty_text(child)?,
478            }
479        }
480
481        Ok(Self {
482            family_name: family_name.ok_or(FormatError::MissingTag("FamilyName"))?,
483            status: status.ok_or(FormatError::MissingTag("Status"))?,
484        })
485    }
486}
487
488/// Represents an individual plugin entry within `<IndividualPluginSelection>`.
489#[derive(Debug)]
490pub struct PluginItem<'input> {
491    pub plugin_id: u32,
492    pub plugin_name: Cow<'input, str>,
493    pub family: Cow<'input, str>,
494    pub status: FamilyStatus,
495}
496
497impl<'input> PluginItem<'input> {
498    fn from_xml_node(node: Node<'_, 'input>) -> Result<Self, FormatError> {
499        let mut plugin_id = None;
500        let mut plugin_name = None;
501        let mut family = None;
502        let mut status = None;
503
504        for child in node.children() {
505            match child.tag_name().name() {
506                "PluginId" => {
507                    if plugin_id.is_some() {
508                        return Err(FormatError::RepeatedTag("PluginId"));
509                    }
510                    let val = child.text().ok_or(FormatError::MissingTag("PluginId"))?;
511                    plugin_id = Some(val.parse()?);
512                }
513                "PluginName" => {
514                    if plugin_name.is_some() {
515                        return Err(FormatError::RepeatedTag("PluginName"));
516                    }
517                    plugin_name = child.text_storage().map(StringStorageExt::to_cow);
518                }
519                "Family" => {
520                    if family.is_some() {
521                        return Err(FormatError::RepeatedTag("Family"));
522                    }
523                    family = child.text_storage().map(StringStorageExt::to_cow);
524                }
525                "Status" => {
526                    if status.is_some() {
527                        return Err(FormatError::RepeatedTag("Status"));
528                    }
529                    let val = child.text().ok_or(FormatError::MissingTag("Status"))?;
530                    status = Some(val.parse()?);
531                }
532                _ => assert_empty_text(child)?,
533            }
534        }
535
536        Ok(Self {
537            plugin_id: plugin_id.ok_or(FormatError::MissingTag("PluginId"))?,
538            plugin_name: plugin_name.ok_or(FormatError::MissingTag("PluginName"))?,
539            family: family.ok_or(FormatError::MissingTag("Family"))?,
540            status: status.ok_or(FormatError::MissingTag("Status"))?,
541        })
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use roxmltree::Document;
548
549    use crate::error::FormatError;
550
551    use super::{FamilyStatus, Policy};
552
553    fn parse_policy(xml: &str) -> Result<Policy<'_>, FormatError> {
554        let doc = Document::parse(xml).expect("test XML should parse");
555        let node = doc.root_element();
556        Policy::from_xml_node(node)
557    }
558
559    #[test]
560    fn rejects_missing_required_policy_sections() {
561        let xml = r"<Policy><policyName>p</policyName></Policy>";
562        let err = parse_policy(xml).expect_err("must fail");
563        assert!(matches!(err, FormatError::MissingTag("Preferences")));
564    }
565
566    #[test]
567    fn rejects_repeated_whoami_preference() {
568        let xml = r"
569<Policy>
570  <policyName>p</policyName>
571  <Preferences>
572    <ServerPreferences>
573      <preference><name>whoami</name><value>u1</value></preference>
574      <preference><name>whoami</name><value>u2</value></preference>
575      <preference><name>scan_description</name><value>d</value></preference>
576      <preference><name>TARGET</name><value>127.0.0.1</value></preference>
577      <preference><name>port_range</name><value>default</value></preference>
578      <preference><name>scan_start_timestamp</name><value>1</value></preference>
579      <preference><name>plugin_set</name><value>;1;</value></preference>
580      <preference><name>name</name><value>n</value></preference>
581    </ServerPreferences>
582    <PluginsPreferences/>
583  </Preferences>
584  <FamilySelection/>
585  <IndividualPluginSelection/>
586</Policy>
587";
588        let err = parse_policy(xml).expect_err("must fail");
589        assert!(matches!(err, FormatError::RepeatedTag("whoami")));
590    }
591
592    #[test]
593    fn rejects_preference_item_missing_name_or_value() {
594        let missing_name = r"
595<Policy>
596  <policyName>p</policyName>
597  <Preferences>
598    <ServerPreferences>
599      <preference><value>u</value></preference>
600      <preference><name>scan_description</name><value>d</value></preference>
601      <preference><name>TARGET</name><value>127.0.0.1</value></preference>
602      <preference><name>port_range</name><value>default</value></preference>
603      <preference><name>scan_start_timestamp</name><value>1</value></preference>
604      <preference><name>plugin_set</name><value>;1;</value></preference>
605      <preference><name>name</name><value>n</value></preference>
606    </ServerPreferences>
607    <PluginsPreferences/>
608  </Preferences>
609  <FamilySelection/>
610  <IndividualPluginSelection/>
611</Policy>
612";
613        let err = parse_policy(missing_name).expect_err("must fail");
614        assert!(matches!(err, FormatError::MissingTag("name")));
615
616        let missing_value = r"
617<Policy>
618  <policyName>p</policyName>
619  <Preferences>
620    <ServerPreferences>
621      <preference><name>whoami</name></preference>
622      <preference><name>scan_description</name><value>d</value></preference>
623      <preference><name>TARGET</name><value>127.0.0.1</value></preference>
624      <preference><name>port_range</name><value>default</value></preference>
625      <preference><name>scan_start_timestamp</name><value>1</value></preference>
626      <preference><name>plugin_set</name><value>;1;</value></preference>
627      <preference><name>name</name><value>n</value></preference>
628    </ServerPreferences>
629    <PluginsPreferences/>
630  </Preferences>
631  <FamilySelection/>
632  <IndividualPluginSelection/>
633</Policy>
634";
635        let err = parse_policy(missing_value).expect_err("must fail");
636        assert!(matches!(err, FormatError::MissingTag("value")));
637    }
638
639    #[test]
640    fn family_status_parsing_works() {
641        assert!(matches!("enabled".parse(), Ok(FamilyStatus::Enabled)));
642        assert!(matches!("disabled".parse(), Ok(FamilyStatus::Disabled)));
643        assert!(matches!("mixed".parse(), Ok(FamilyStatus::Mixed)));
644        assert!(matches!(
645            "bad".parse::<FamilyStatus>(),
646            Err(FormatError::UnexpectedFormat("FamilyStatus"))
647        ));
648    }
649
650    #[test]
651    fn minimal_policy_parses() {
652        let minimal_policy_xml = r"
653<Policy>
654  <policyName>p</policyName>
655  <Preferences>
656    <ServerPreferences>
657      <preference><name>whoami</name><value>u</value></preference>
658      <preference><name>scan_description</name><value>d</value></preference>
659      <preference><name>TARGET</name><value>127.0.0.1</value></preference>
660      <preference><name>port_range</name><value>default</value></preference>
661      <preference><name>scan_start_timestamp</name><value>1</value></preference>
662      <preference><name>plugin_set</name><value>;1;</value></preference>
663      <preference><name>name</name><value>n</value></preference>
664    </ServerPreferences>
665    <PluginsPreferences/>
666  </Preferences>
667  <FamilySelection>
668    <FamilyItem>
669      <FamilyName>General</FamilyName>
670      <Status>enabled</Status>
671    </FamilyItem>
672  </FamilySelection>
673  <IndividualPluginSelection>
674    <PluginItem>
675      <PluginId>1</PluginId>
676      <PluginName>x</PluginName>
677      <Family>General</Family>
678      <Status>enabled</Status>
679    </PluginItem>
680  </IndividualPluginSelection>
681</Policy>
682";
683        let parsed = parse_policy(minimal_policy_xml).expect("must parse");
684        assert_eq!(parsed.policy_name, "p");
685    }
686}