1use quick_xml::events::{BytesStart, Event};
2use quick_xml::Reader;
3
4use crate::modules::gpo::types::{
5 GpoError, GppGroupAction, GppGroupMember, GppLocalGroup, GppMemberAction,
6};
7
8#[derive(Default)]
9struct PendingGroup {
10 sid: Option<String>,
11 name: Option<String>,
12 action: Option<GppGroupAction>,
13 disabled: bool,
14 delete_all_users: bool,
15 delete_all_groups: bool,
16 has_item_level_targeting: bool,
17 members: Vec<GppGroupMember>,
18}
19
20#[derive(Clone, Copy, PartialEq, Eq)]
21enum Element {
22 Groups,
23 Group,
24 Properties,
25 Members,
26 Member,
27 Filters,
28 Other,
29}
30
31impl Element {
32 fn from_name(name: &[u8]) -> Self {
33 match name {
34 b"Groups" => Self::Groups,
35 b"Group" => Self::Group,
36 b"Properties" => Self::Properties,
37 b"Members" => Self::Members,
38 b"Member" => Self::Member,
39 b"Filters" => Self::Filters,
40 _ => Self::Other,
41 }
42 }
43}
44
45pub fn parse_groups_xml(content: &[u8]) -> Result<Vec<GppLocalGroup>, GpoError> {
57 use Element::*;
58
59 let mut reader = Reader::from_reader(content);
60 reader.config_mut().trim_text(true);
61 reader.config_mut().expand_empty_elements = true;
63 let mut path = Vec::new();
64 let mut root_seen = false;
65 let mut root_disabled = false;
66 let mut groups = Vec::new();
67 let mut pending: Option<PendingGroup> = None;
68
69 loop {
70 match reader.read_event() {
71 Ok(Event::Start(event)) => {
72 let element = Element::from_name(event.local_name().as_ref());
73 match (path.as_slice(), element) {
74 ([], Groups) if !root_seen => {
75 root_seen = true;
76 root_disabled = bool_attribute(&event, b"disabled")?;
77 }
78 ([], _) => {
79 return Err(malformed("expected a single Groups root element"));
80 }
81 ([Groups], Group) => {
82 pending = Some(PendingGroup {
83 disabled: root_disabled || bool_attribute(&event, b"disabled")?,
84 ..PendingGroup::default()
85 });
86 }
87 ([Groups, Group], Properties) => {
88 if let Some(group) = pending.as_mut() {
89 group.disabled |= bool_attribute(&event, b"disabled")?;
90 if !group.disabled {
91 if group.action.is_some() {
92 return Err(malformed("duplicate Group Properties element"));
93 }
94 group.sid = attribute(&event, b"groupSid")?;
95 group.name = attribute(&event, b"groupName")?;
96 group.action = Some(parse_group_action(
97 attribute(&event, b"action")?.as_deref().unwrap_or("U"),
98 )?);
99 group.delete_all_users = bool_attribute(&event, b"deleteAllUsers")?;
100 group.delete_all_groups =
101 bool_attribute(&event, b"deleteAllGroups")?;
102 }
103 }
104 }
105 ([Groups, Group, Properties, Members], Member) => {
106 if let Some(group) = pending.as_mut().filter(|group| !group.disabled) {
107 group.members.push(parse_member(&event)?);
108 }
109 }
110 ([Groups, Group], Filters) => {
111 if let Some(group) = pending.as_mut() {
112 group.has_item_level_targeting = true;
113 }
114 }
115 _ => {}
116 }
117 path.push(element);
118 }
119 Ok(Event::End(_)) => {
120 let element = path.pop();
121 if element == Some(Group) && path == [Groups] {
122 let group = pending
123 .take()
124 .ok_or_else(|| malformed("unexpected Group end"))?;
125 if group.disabled {
126 continue;
127 }
128 let action = group
129 .action
130 .ok_or_else(|| malformed("GPP group is missing Properties"))?;
131 let group = GppLocalGroup::new(
132 group.sid,
133 group.name,
134 action,
135 group.delete_all_users,
136 group.delete_all_groups,
137 group.has_item_level_targeting,
138 group.members,
139 );
140 if group.target().is_none() {
141 return Err(malformed("GPP group is missing groupSid and groupName"));
142 }
143 groups.push(group);
144 }
145 }
146 Ok(Event::Text(text)) if !text.is_empty() => {
147 return Err(malformed("unexpected text in Groups.xml"));
148 }
149 Ok(Event::DocType(_) | Event::CData(_)) => {
150 return Err(malformed("unsupported DTD or CDATA in Groups.xml"));
151 }
152 Ok(Event::Eof) => break,
153 Ok(_) => {}
154 Err(error) => {
155 return Err(malformed(format!(
156 "invalid Groups.xml near byte {}: {error}",
157 reader.error_position()
158 )));
159 }
160 }
161 }
162
163 if !path.is_empty() {
164 return Err(malformed("unterminated Groups.xml element"));
165 }
166 Ok(groups)
167}
168
169fn malformed(message: impl Into<String>) -> GpoError {
170 GpoError::MalformedContent(message.into())
171}
172
173fn attribute(event: &BytesStart<'_>, name: &[u8]) -> Result<Option<String>, GpoError> {
174 let mut value = None;
175 for attribute in event.attributes() {
176 let attribute =
177 attribute.map_err(|error| malformed(format!("invalid XML attribute: {error}")))?;
178 if attribute.key.local_name().as_ref() == name {
179 if value.is_some() {
180 return Err(malformed("duplicate XML attribute"));
181 }
182 value = Some(
183 attribute
184 .unescape_value()
185 .map_err(|error| malformed(format!("invalid XML attribute value: {error}")))?
186 .into_owned(),
187 );
188 }
189 }
190 Ok(value)
191}
192
193fn bool_attribute(event: &BytesStart<'_>, name: &[u8]) -> Result<bool, GpoError> {
194 match attribute(event, name)?.as_deref().map(str::trim) {
195 None | Some("0") => Ok(false),
196 Some("1") => Ok(true),
197 Some(value) if value.eq_ignore_ascii_case("true") => Ok(true),
198 Some(value) if value.eq_ignore_ascii_case("false") => Ok(false),
199 _ => Err(malformed("invalid boolean XML attribute")),
200 }
201}
202
203fn parse_group_action(value: &str) -> Result<GppGroupAction, GpoError> {
204 match value {
205 value if value.eq_ignore_ascii_case("C") => Ok(GppGroupAction::Create),
206 value if value.eq_ignore_ascii_case("D") => Ok(GppGroupAction::Delete),
207 value if value.eq_ignore_ascii_case("R") => Ok(GppGroupAction::Replace),
208 value if value.eq_ignore_ascii_case("U") => Ok(GppGroupAction::Update),
209 _ => Err(malformed(format!("unsupported GPP group action '{value}'"))),
210 }
211}
212
213fn parse_member(event: &BytesStart<'_>) -> Result<GppGroupMember, GpoError> {
214 let action =
215 attribute(event, b"action")?.ok_or_else(|| malformed("GPP member is missing action"))?;
216 let action = match action.as_str() {
217 value if value.eq_ignore_ascii_case("ADD") => GppMemberAction::Add,
218 value if value.eq_ignore_ascii_case("REMOVE") => GppMemberAction::Remove,
219 value => {
220 return Err(malformed(format!(
221 "unsupported GPP member action '{value}'"
222 )))
223 }
224 };
225 let member = GppGroupMember::new(
226 attribute(event, b"sid")?,
227 attribute(event, b"name")?,
228 action,
229 );
230 if member.principal().is_none() {
231 return Err(malformed("GPP member is missing sid and name"));
232 }
233 Ok(member)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 fn parse_item(properties: &str, members: &str) -> Result<Vec<GppLocalGroup>, GpoError> {
241 parse_groups_xml(format!(
242 "<Groups><Group><Properties {properties}><Members>{members}</Members></Properties></Group></Groups>"
243 ).as_bytes())
244 }
245
246 #[test]
247 fn preserves_sid_precedence_actions_and_targeting() {
248 let xml = br#"<?xml version="1.0" encoding="utf-8"?>
249<Groups><Group><Properties action="R" groupName="Administrators" groupSid="S-1-5-32-544" deleteAllUsers="1" deleteAllGroups="0"><Members><Member name="DOMAIN\alice" sid="S-1-5-21-1-2-3-1001" action="ADD"/><Member name="DOMAIN\old" action="REMOVE"/></Members></Properties><Filters><FilterOs bool="AND"/></Filters></Group></Groups>"#;
250 let groups = parse_groups_xml(xml).unwrap();
251 assert_eq!(groups.len(), 1);
252 let group = &groups[0];
253 assert_eq!(group.target(), Some("S-1-5-32-544"));
254 assert_eq!(group.name(), Some("Administrators"));
255 assert_eq!(group.action(), GppGroupAction::Replace);
256 assert!(group.delete_all_users());
257 assert!(!group.delete_all_groups());
258 assert!(group.has_item_level_targeting());
259 assert_eq!(group.members().len(), 2);
260 assert_eq!(group.members()[0].principal(), Some("S-1-5-21-1-2-3-1001"));
261 assert_eq!(group.members()[0].action(), GppMemberAction::Add);
262 assert_eq!(group.members()[1].action(), GppMemberAction::Remove);
263 }
264
265 #[test]
266 fn empty_member_sid_falls_back_to_name_without_trimming() {
267 for sid in ["", " ", "	

"] {
268 for name in [r"DOMAIN\alice", r" DOMAIN\Help Desk "] {
269 let groups = parse_item(
270 r#"groupName="Administrators""#,
271 &format!(r#"<Member sid="{sid}" name="{name}" action="ADD"/>"#),
272 )
273 .unwrap();
274 let member = &groups[0].members()[0];
275 assert_eq!(member.sid(), None);
276 assert_eq!(member.principal(), Some(name));
277 }
278 }
279 }
280
281 #[test]
282 fn empty_group_sid_falls_back_to_name_without_trimming() {
283 for sid in ["", " ", "	

"] {
284 for name in ["Administrators", " Local Operators "] {
285 let groups =
286 parse_item(&format!(r#"groupSid="{sid}" groupName="{name}""#), "").unwrap();
287 assert_eq!(groups[0].sid(), None);
288 assert_eq!(groups[0].target(), Some(name));
289 }
290 }
291 }
292
293 #[test]
294 fn empty_names_are_absent_when_sids_are_present() {
295 let groups = parse_item(
296 r#"groupSid="S-1-5-32-544" groupName=" ""#,
297 r#"<Member sid="S-1-5-21-1-2-3-1001" name="" action="ADD"/>"#,
298 )
299 .unwrap();
300 assert_eq!(groups[0].name(), None);
301 assert_eq!(groups[0].members()[0].name(), None);
302 assert_eq!(
303 groups[0].members()[0].principal(),
304 Some("S-1-5-21-1-2-3-1001")
305 );
306 }
307
308 #[test]
309 fn rejects_missing_or_blank_identities() {
310 for attributes in ["", r#"sid="" name=" ""#, r#"name="	""#] {
311 assert!(matches!(
312 parse_item(
313 r#"groupName="Administrators""#,
314 &format!(r#"<Member {attributes} action="ADD"/>"#)
315 ),
316 Err(GpoError::MalformedContent(_))
317 ));
318 }
319 for attributes in ["", r#"groupSid="" groupName=" ""#, r#"groupName="	""#] {
320 assert!(matches!(
321 parse_item(attributes, ""),
322 Err(GpoError::MalformedContent(_))
323 ));
324 }
325 }
326
327 #[test]
328 fn root_disabled_suppresses_every_item() {
329 for disabled in ["1", "true", "TRUE"] {
330 let xml = format!(
331 r#"<Groups disabled="{disabled}"><Group><Properties groupName="Administrators"/></Group><Group><Properties groupName="Remote Desktop Users"/></Group></Groups>"#
332 );
333 assert!(parse_groups_xml(xml.as_bytes()).unwrap().is_empty());
334 }
335 }
336
337 #[test]
338 fn root_enabled_preserves_items() {
339 for disabled in ["0", "false", "FALSE"] {
340 let xml = format!(
341 r#"<Groups disabled="{disabled}"><Group><Properties groupName="Administrators"/></Group></Groups>"#
342 );
343 assert_eq!(parse_groups_xml(xml.as_bytes()).unwrap().len(), 1);
344 }
345 }
346
347 #[test]
348 fn properties_disabled_suppresses_only_that_item() {
349 let xml = br#"<Groups><Group><Properties disabled="1" groupName="Skip"/></Group><Group><Properties disabled="0" groupName="Keep"/></Group></Groups>"#;
350 let groups = parse_groups_xml(xml).unwrap();
351 assert_eq!(groups.len(), 1);
352 assert_eq!(groups[0].target(), Some("Keep"));
353 }
354
355 #[test]
356 fn tolerates_group_disabled_without_overriding_properties() {
357 let xml = br#"<Groups><Group disabled="1"><Properties disabled="0" groupName="Skip"/></Group><Group disabled="0"><Properties disabled="1" groupName="Skip too"/></Group><Group disabled="0"><Properties groupName="Keep"/></Group></Groups>"#;
358 let groups = parse_groups_xml(xml).unwrap();
359 assert_eq!(groups.len(), 1);
360 assert_eq!(groups[0].target(), Some("Keep"));
361 }
362
363 #[test]
364 fn disabled_items_do_not_require_unused_membership_attributes() {
365 for xml in [
366 r#"<Groups disabled="1"><Group/></Groups>"#,
367 r#"<Groups><Group disabled="1"/></Groups>"#,
368 r#"<Groups><Group><Properties disabled="1"><Members><Member/></Members></Properties></Group></Groups>"#,
369 ] {
370 assert!(parse_groups_xml(xml.as_bytes()).unwrap().is_empty());
371 }
372 }
373
374 #[test]
375 fn rejects_invalid_disabled_and_delete_flags() {
376 for xml in [
377 r#"<Groups disabled="unknown"/>"#,
378 r#"<Groups><Group disabled="unknown"/></Groups>"#,
379 r#"<Groups><Group><Properties disabled="unknown" groupName="Admins"/></Group></Groups>"#,
380 r#"<Groups><Group><Properties groupName="Admins" deleteAllUsers="unknown"/></Group></Groups>"#,
381 r#"<Groups><Group><Properties groupName="Admins" deleteAllGroups="unknown"/></Group></Groups>"#,
382 ] {
383 assert!(matches!(
384 parse_groups_xml(xml.as_bytes()),
385 Err(GpoError::MalformedContent(_))
386 ));
387 }
388 }
389
390 #[test]
391 fn missing_member_action_is_an_error() {
392 let result = parse_item(
393 r#"groupName="Administrators""#,
394 r#"<Member name="DOMAIN\alice"/>"#,
395 );
396 assert!(
397 matches!(result, Err(GpoError::MalformedContent(message)) if message.contains("missing action"))
398 );
399 }
400
401 #[test]
402 fn rejects_unsupported_member_actions() {
403 for action in ["", " ", "DELETE", "DEL", "UNKNOWN"] {
404 assert!(matches!(
405 parse_item(
406 r#"groupName="Administrators""#,
407 &format!(r#"<Member name="DOMAIN\alice" action="{action}"/>"#)
408 ),
409 Err(GpoError::MalformedContent(_))
410 ));
411 }
412 }
413
414 #[test]
415 fn preserves_group_actions_and_defaults_only_missing_action_to_update() {
416 for (action, expected) in [
417 ("C", GppGroupAction::Create),
418 ("D", GppGroupAction::Delete),
419 ("R", GppGroupAction::Replace),
420 ("U", GppGroupAction::Update),
421 ] {
422 let groups =
423 parse_item(&format!(r#"groupName="Admins" action="{action}""#), "").unwrap();
424 assert_eq!(groups[0].action(), expected);
425 }
426 let groups = parse_item(r#"groupName="Admins""#, "").unwrap();
427 assert_eq!(groups[0].action(), GppGroupAction::Update);
428 for action in ["", "X"] {
429 assert!(matches!(
430 parse_item(&format!(r#"groupName="Admins" action="{action}""#), ""),
431 Err(GpoError::MalformedContent(_))
432 ));
433 }
434 }
435
436 #[test]
437 fn preserves_delete_flags_and_false_defaults() {
438 for (attributes, expected) in [
439 ("", (false, false)),
440 (r#"deleteAllUsers="1""#, (true, false)),
441 (r#"deleteAllGroups="1""#, (false, true)),
442 (r#"deleteAllUsers="1" deleteAllGroups="1""#, (true, true)),
443 (r#"deleteAllUsers="0" deleteAllGroups="0""#, (false, false)),
444 ] {
445 let groups = parse_item(&format!(r#"groupName="Admins" {attributes}"#), "").unwrap();
446 assert_eq!(
447 (groups[0].delete_all_users(), groups[0].delete_all_groups()),
448 expected
449 );
450 }
451 }
452
453 #[test]
454 fn accepts_empty_input_and_empty_groups() {
455 for xml in ["", " \r\n", "<Groups/>", "<Groups></Groups>"] {
456 assert!(parse_groups_xml(xml.as_bytes()).unwrap().is_empty());
457 }
458 }
459
460 #[test]
461 fn rejects_malformed_truncated_and_multiple_root_documents() {
462 for xml in [
463 "<",
464 "<Groups>",
465 "<Groups><Group>",
466 r#"<Groups><Group><Properties groupName="Admins"/>"#,
467 r#"<Groups><Group><Properties groupName="Admins"/></Group>"#,
468 "<Groups><Group></Groups>",
469 "<Groups/><Groups/>",
470 "<Other/>",
471 "not xml",
472 "<Groups/>trailing",
473 "<Groups><!",
474 "<Groups></Groups></Groups>",
475 ] {
476 assert!(
477 matches!(
478 parse_groups_xml(xml.as_bytes()),
479 Err(GpoError::MalformedContent(_))
480 ),
481 "{xml}"
482 );
483 }
484 }
485
486 #[test]
487 fn rejects_missing_or_duplicate_properties() {
488 for xml in [
489 "<Groups><Group/></Groups>",
490 "<Groups><Group></Group></Groups>",
491 r#"<Groups><Group><Properties groupName="First"/><Properties groupName="Second"/></Group></Groups>"#,
492 ] {
493 assert!(matches!(
494 parse_groups_xml(xml.as_bytes()),
495 Err(GpoError::MalformedContent(_))
496 ));
497 }
498 }
499
500 #[test]
501 fn scopes_members_and_targeting_to_the_correct_item() {
502 let xml = br#"<Groups><User><Properties userName="Ignored"/></User><Group><Properties groupName="First"><Members><Member name="DOMAIN\alice" action="ADD"></Member></Members></Properties><Filters><Group><Properties groupName="Not a directive"><Members><Member name="DOMAIN\other" action="ADD"/></Members></Properties></Group></Filters></Group><Group><Properties groupName="Second"/><Filters/></Group><Group><Properties groupName="Third"/></Group></Groups>"#;
503 let groups = parse_groups_xml(xml).unwrap();
504 assert_eq!(
505 groups.iter().map(GppLocalGroup::target).collect::<Vec<_>>(),
506 vec![Some("First"), Some("Second"), Some("Third")]
507 );
508 assert_eq!(groups[0].members().len(), 1);
509 assert_eq!(groups[0].members()[0].principal(), Some(r"DOMAIN\alice"));
510 assert!(groups[0].has_item_level_targeting());
511 assert!(groups[1].has_item_level_targeting());
512 assert!(!groups[2].has_item_level_targeting());
513 assert!(groups[2].members().is_empty());
514 }
515
516 #[test]
517 fn accepts_namespace_prefixes_and_unescapes_names() {
518 let xml = br#"<g:Groups xmlns:g="urn:synthetic:gpp"><g:Group><g:Properties groupName="Local & Operators"><g:Members><g:Member name="DOMAIN\a&b" action="ADD"/></g:Members></g:Properties></g:Group></g:Groups>"#;
519 let groups = parse_groups_xml(xml).unwrap();
520 assert_eq!(groups[0].target(), Some("Local & Operators"));
521 assert_eq!(groups[0].members()[0].principal(), Some(r"DOMAIN\a&b"));
522 }
523
524 #[test]
525 fn rejects_duplicate_attributes_and_unknown_entities() {
526 for properties in [
527 r#"groupName="Admins" groupName="Other""#,
528 r#"groupName="&unknown;""#,
529 r#"groupName="Admins" broken"#,
530 ] {
531 assert!(matches!(
532 parse_item(properties, ""),
533 Err(GpoError::MalformedContent(_))
534 ));
535 }
536 }
537}