use crate::modules::gpo::types::{
GpoError, GptTmplPolicy, PrivilegeAssignment, RestrictedGroupDirective,
RestrictedGroupOperation,
};
pub fn decode_gpttmpl_bytes(raw: &[u8]) -> Result<String, GpoError> {
if raw.is_empty() {
return Ok(String::new());
}
if raw.starts_with(&[0x00, 0x00, 0xFE, 0xFF]) {
return Err(GpoError::InvalidEncoding(
"Unsupported UTF-32BE encoding".to_string(),
));
}
if raw.starts_with(&[0xFF, 0xFE, 0x00, 0x00]) {
return Err(GpoError::InvalidEncoding(
"Unsupported UTF-32LE encoding".to_string(),
));
}
if raw.starts_with(&[0xEF, 0xBB, 0xBF]) {
return std::str::from_utf8(&raw[3..])
.map(|s| s.to_string())
.map_err(|e| GpoError::InvalidEncoding(format!("Invalid UTF-8 after BOM: {e}")));
}
if raw.starts_with(&[0xFF, 0xFE]) {
return decode_utf16le(&raw[2..]);
}
if raw.starts_with(&[0xFE, 0xFF]) {
return decode_utf16be(&raw[2..]);
}
if let Ok(s) = std::str::from_utf8(raw) {
return Ok(s.to_string());
}
if raw.len() >= 2 && raw.len() % 2 == 0 && raw[1] == 0 {
if let Ok(s) = decode_utf16le(raw) {
return Ok(s);
}
}
Err(GpoError::InvalidEncoding(
"Unable to decode policy bytes: unrecognized or corrupted character encoding".to_string(),
))
}
fn decode_utf16le(bytes: &[u8]) -> Result<String, GpoError> {
if bytes.len() % 2 != 0 {
return Err(GpoError::InvalidEncoding(
"Truncated UTF-16LE sequence (odd byte count)".to_string(),
));
}
let u16_units: Vec<u16> = bytes
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
char::decode_utf16(u16_units)
.collect::<Result<String, _>>()
.map_err(|e| GpoError::InvalidEncoding(format!("Invalid UTF-16LE code point: {e}")))
}
fn decode_utf16be(bytes: &[u8]) -> Result<String, GpoError> {
if bytes.len() % 2 != 0 {
return Err(GpoError::InvalidEncoding(
"Truncated UTF-16BE sequence (odd byte count)".to_string(),
));
}
let u16_units: Vec<u16> = bytes
.chunks_exact(2)
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
.collect();
char::decode_utf16(u16_units)
.collect::<Result<String, _>>()
.map_err(|e| GpoError::InvalidEncoding(format!("Invalid UTF-16BE code point: {e}")))
}
pub fn parse_gpttmpl(content: &str) -> Result<GptTmplPolicy, GpoError> {
let mut current_section: Option<String> = None;
let mut privilege_rights: Vec<PrivilegeAssignment> = Vec::new();
let mut restricted_groups: Vec<RestrictedGroupDirective> = Vec::new();
for (line_idx, line) in content.lines().enumerate() {
let line_no = line_idx + 1;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with(';') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let section_name = trimmed[1..trimmed.len() - 1].trim();
current_section = Some(section_name.to_ascii_lowercase());
continue;
}
if let Some(ref section) = current_section {
if section == "privilege rights" {
let (raw_key, raw_val) = trimmed.split_once('=').ok_or_else(|| {
GpoError::MalformedContent(format!(
"invalid privilege assignment at line {line_no}: missing '='"
))
})?;
let privilege = raw_key.trim();
if privilege.is_empty() {
return Err(GpoError::MalformedContent(format!(
"invalid privilege assignment at line {line_no}: empty privilege name"
)));
}
let val_clean = match raw_val.find(';') {
Some(idx) => &raw_val[..idx],
None => raw_val,
};
let mut principals = Vec::new();
for part in val_clean.split(',') {
let principal = part.trim();
if !principal.is_empty() && !principals.contains(&principal.to_string()) {
principals.push(principal.to_string());
}
}
if let Some(existing) = privilege_rights
.iter_mut()
.find(|p| p.privilege().eq_ignore_ascii_case(privilege))
{
let mut merged = existing.principals().to_vec();
for p in principals {
if !merged.contains(&p) {
merged.push(p);
}
}
*existing = PrivilegeAssignment::new(existing.privilege().to_string(), merged);
} else {
privilege_rights
.push(PrivilegeAssignment::new(privilege.to_string(), principals));
}
} else if section == "group membership" {
let (raw_key, raw_val) = trimmed.split_once('=').ok_or_else(|| {
GpoError::MalformedContent(format!(
"invalid group membership entry at line {line_no}: missing '='"
))
})?;
let key = raw_key.trim();
let key_lower = key.to_ascii_lowercase();
let (target, operation) = if key_lower.ends_with("__members") {
(
&key[..key.len() - "__Members".len()],
RestrictedGroupOperation::ReplaceMembers,
)
} else if key_lower.ends_with("__memberof") {
(
&key[..key.len() - "__Memberof".len()],
RestrictedGroupOperation::AddToParentGroups,
)
} else {
return Err(GpoError::MalformedContent(format!(
"invalid group membership entry at line {line_no}: expected __Members or __Memberof suffix"
)));
};
let target = target.trim();
if target.is_empty() {
return Err(GpoError::MalformedContent(format!(
"invalid group membership entry at line {line_no}: empty target group"
)));
}
let val_clean = raw_val.split(';').next().unwrap_or_default();
let principals = val_clean
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect();
restricted_groups
.push(RestrictedGroupDirective::new(target, operation, principals));
}
}
}
Ok(GptTmplPolicy::with_entries(
privilege_rights,
restricted_groups,
))
}
pub fn parse_gpttmpl_bytes(raw: &[u8]) -> Result<GptTmplPolicy, GpoError> {
let decoded = decode_gpttmpl_bytes(raw)?;
parse_gpttmpl(&decoded)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_standard_privilege_rights_synthetic() {
let content = r#"
[Unicode]
Unicode=yes
[Version]
signature="$CHICAGO$"
revision=1
[Privilege Rights]
SeDebugPrivilege = *S-1-5-32-544
SeBackupPrivilege = *S-1-5-21-111-222-333-1001,*S-1-5-32-544
SeRemoteInteractiveLogonRight = *S-1-5-32-555
"#;
let policy = parse_gpttmpl(content).expect("parsing should succeed");
assert_eq!(policy.privilege_rights().len(), 3);
let debug_priv = policy.get_privilege("SeDebugPrivilege").unwrap();
assert_eq!(debug_priv.principals(), &["*S-1-5-32-544"]);
assert_eq!(
debug_priv.normalized_principals().collect::<Vec<_>>(),
vec!["S-1-5-32-544"]
);
let backup_priv = policy.get_privilege("SeBackupPrivilege").unwrap();
assert_eq!(
backup_priv.principals(),
&["*S-1-5-21-111-222-333-1001", "*S-1-5-32-544"]
);
assert_eq!(
backup_priv.sid_candidates().collect::<Vec<_>>(),
vec!["S-1-5-21-111-222-333-1001", "S-1-5-32-544"]
);
let rdp_priv = policy
.get_privilege("SeRemoteInteractiveLogonRight")
.unwrap();
assert_eq!(rdp_priv.principals(), &["*S-1-5-32-555"]);
}
#[test]
fn parse_handles_crlf_and_whitespace_and_comments() {
let content = "; Header comment\r\n\r\n[Privilege Rights]\r\n SeImpersonatePrivilege = *S-1-5-32-544 , *S-1-5-19 ; inline comment\r\nSeTcbPrivilege = \r\n";
let policy = parse_gpttmpl(content).expect("parsing should succeed");
let imp = policy.get_privilege("SeImpersonatePrivilege").unwrap();
assert_eq!(imp.principals(), &["*S-1-5-32-544", "*S-1-5-19"]);
assert_eq!(
imp.sid_candidates().collect::<Vec<_>>(),
vec!["S-1-5-32-544", "S-1-5-19"]
);
let tcb = policy.get_privilege("SeTcbPrivilege").unwrap();
assert!(tcb.principals().is_empty());
}
#[test]
fn parse_preserves_hash_character_in_principals_per_ms_gpsb() {
let content1 = "[Privilege Rights]\nSeServiceLogonRight = svc#backup\n";
let policy1 = parse_gpttmpl(content1).unwrap();
let p1 = policy1.get_privilege("SeServiceLogonRight").unwrap();
assert_eq!(p1.principals(), &["svc#backup"]);
let content2 = "[Privilege Rights]\nSeServiceLogonRight = svc#backup ; valid comment\n";
let policy2 = parse_gpttmpl(content2).unwrap();
let p2 = policy2.get_privilege("SeServiceLogonRight").unwrap();
assert_eq!(p2.principals(), &["svc#backup"]);
}
#[test]
fn parse_rejects_malformed_lines_in_privilege_rights() {
let bad_content1 = "[Privilege Rights]\nSeDebugPrivilege : *S-1-5-32-544\n";
let err1 = parse_gpttmpl(bad_content1).unwrap_err();
match err1 {
GpoError::MalformedContent(msg) => {
assert!(msg.contains("line 2"));
assert!(msg.contains("missing '='"));
}
other => panic!("Unexpected error type: {other:?}"),
}
let bad_content2 = "[Privilege Rights]\n = *S-1-5-32-544\n";
let err2 = parse_gpttmpl(bad_content2).unwrap_err();
match err2 {
GpoError::MalformedContent(msg) => {
assert!(msg.contains("line 2"));
assert!(msg.contains("empty privilege name"));
}
other => panic!("Unexpected error type: {other:?}"),
}
}
#[test]
fn parses_restricted_groups_without_applying_graph_semantics() {
let content = r#"
[Group Membership]
*S-1-5-32-544__Members = *S-1-5-21-1-2-3-1001,DOMAIN\Helpdesk
DOMAIN\Helpdesk__Memberof = *S-1-5-32-555
*S-1-5-32-546__Members =
"#;
let policy = parse_gpttmpl(content).unwrap();
let directives = policy.restricted_groups();
assert_eq!(directives.len(), 3);
assert_eq!(directives[0].target(), "*S-1-5-32-544");
assert_eq!(
directives[0].operation(),
RestrictedGroupOperation::ReplaceMembers
);
assert_eq!(
directives[0].principals(),
&["*S-1-5-21-1-2-3-1001", "DOMAIN\\Helpdesk"]
);
assert_eq!(
directives[1].operation(),
RestrictedGroupOperation::AddToParentGroups
);
assert!(directives[2].principals().is_empty());
}
#[test]
fn rejects_unknown_restricted_group_suffixes() {
let content = "[Group Membership]\nAdministrators__Unknown = DOMAIN\\alice\n";
assert!(matches!(
parse_gpttmpl(content),
Err(GpoError::MalformedContent(_))
));
}
#[test]
fn restricted_groups_preserve_comments_crlf_names_and_empty_values() {
let content = "; synthetic fixture\r\n[Group Membership]\r\n Local Operators__Members = DOMAIN\\alice, svc#backup ; comment\r\nLocal Operators__Memberof = *S-1-5-32-544, *S-1-5-32-555\r\nEmpty Group__Memberof = ; empty membership\r\n";
let policy = parse_gpttmpl(content).unwrap();
let groups = policy.restricted_groups();
assert_eq!(groups.len(), 3);
assert_eq!(groups[0].target(), "Local Operators");
assert_eq!(
groups[0].operation(),
RestrictedGroupOperation::ReplaceMembers
);
assert_eq!(groups[0].principals(), &["DOMAIN\\alice", "svc#backup"]);
assert_eq!(groups[1].target(), "Local Operators");
assert_eq!(
groups[1].operation(),
RestrictedGroupOperation::AddToParentGroups
);
assert_eq!(groups[1].principals(), &["*S-1-5-32-544", "*S-1-5-32-555"]);
assert!(groups[2].principals().is_empty());
}
#[test]
fn restricted_groups_reject_missing_equals_and_empty_targets() {
for entry in [
"Administrators__Members : DOMAIN\\alice",
"__Members = DOMAIN\\alice",
"__Memberof = *S-1-5-32-544",
] {
assert!(matches!(
parse_gpttmpl(&format!("[Group Membership]\n{entry}\n")),
Err(GpoError::MalformedContent(_))
));
}
}
#[test]
fn restricted_groups_decode_canonical_utf16le() {
let text = "[Group Membership]\r\n*S-1-5-32-544__Members = DOMAIN\\alice, *S-1-5-21-1-2-3-1001\r\nLocal Operators__Memberof = *S-1-5-32-555\r\n";
let mut bytes = vec![0xFF, 0xFE];
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
let policy = parse_gpttmpl_bytes(&bytes).unwrap();
let groups = policy.restricted_groups();
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].target(), "*S-1-5-32-544");
assert_eq!(
groups[0].operation(),
RestrictedGroupOperation::ReplaceMembers
);
assert_eq!(
groups[0].principals(),
&["DOMAIN\\alice", "*S-1-5-21-1-2-3-1001"]
);
assert_eq!(
groups[1].operation(),
RestrictedGroupOperation::AddToParentGroups
);
assert_eq!(groups[1].principals(), &["*S-1-5-32-555"]);
}
#[test]
fn parse_handles_utf16le_with_bom() {
let text = "[Privilege Rights]\r\nSeDebugPrivilege = *S-1-5-32-544\r\n";
let mut bytes = vec![0xFF, 0xFE]; for u in text.encode_utf16() {
bytes.extend_from_slice(&u.to_le_bytes());
}
let policy =
parse_gpttmpl_bytes(&bytes).expect("UTF-16LE with BOM should decode and parse");
let debug_priv = policy.get_privilege("SeDebugPrivilege").unwrap();
assert_eq!(debug_priv.principals(), &["*S-1-5-32-544"]);
}
#[test]
fn parse_handles_utf16be_with_bom() {
let text = "[Privilege Rights]\r\nSeSecurityPrivilege = *S-1-5-32-544\r\n";
let mut bytes = vec![0xFE, 0xFF]; for u in text.encode_utf16() {
bytes.extend_from_slice(&u.to_be_bytes());
}
let policy =
parse_gpttmpl_bytes(&bytes).expect("UTF-16BE with BOM should decode and parse");
let sec_priv = policy.get_privilege("SeSecurityPrivilege").unwrap();
assert_eq!(sec_priv.principals(), &["*S-1-5-32-544"]);
}
#[test]
fn parse_handles_utf8_with_bom() {
let text = "[Privilege Rights]\r\nSeTakeOwnershipPrivilege = *S-1-5-32-544\r\n";
let mut bytes = vec![0xEF, 0xBB, 0xBF]; bytes.extend_from_slice(text.as_bytes());
let policy = parse_gpttmpl_bytes(&bytes).expect("UTF-8 with BOM should decode and parse");
let own_priv = policy.get_privilege("SeTakeOwnershipPrivilege").unwrap();
assert_eq!(own_priv.principals(), &["*S-1-5-32-544"]);
}
#[test]
fn decode_rejects_utf32_boms() {
let utf32le = vec![0xFF, 0xFE, 0x00, 0x00, 0x5B, 0x00, 0x00, 0x00];
let err_le = decode_gpttmpl_bytes(&utf32le).unwrap_err();
match err_le {
GpoError::InvalidEncoding(msg) => assert!(msg.contains("UTF-32LE")),
other => panic!("Unexpected error type: {other:?}"),
}
let utf32be = vec![0x00, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x00, 0x5B];
let err_be = decode_gpttmpl_bytes(&utf32be).unwrap_err();
match err_be {
GpoError::InvalidEncoding(msg) => assert!(msg.contains("UTF-32BE")),
other => panic!("Unexpected error type: {other:?}"),
}
}
#[test]
fn decode_rejects_invalid_utf16_surrogates() {
let mut bytes = vec![0xFF, 0xFE];
bytes.extend_from_slice(&0xD800u16.to_le_bytes());
bytes.extend_from_slice(&0x0020u16.to_le_bytes());
let err = decode_gpttmpl_bytes(&bytes).unwrap_err();
match err {
GpoError::InvalidEncoding(msg) => assert!(msg.contains("Invalid UTF-16LE")),
other => panic!("Unexpected error type: {other:?}"),
}
}
#[test]
fn parse_empty_and_missing_sections() {
let empty_policy = parse_gpttmpl_bytes(b"").unwrap();
assert!(empty_policy.privilege_rights().is_empty());
let other_section = "[Version]\r\nsignature=\"$CHICAGO$\"\r\n";
let policy_other = parse_gpttmpl(other_section).unwrap();
assert!(policy_other.privilege_rights().is_empty());
}
#[test]
fn parse_handles_duplicates_and_merges() {
let content = r#"
[Privilege Rights]
SeDebugPrivilege = *S-1-5-32-544, *S-1-5-32-544
SeDebugPrivilege = *S-1-5-32-545
"#;
let policy = parse_gpttmpl(content).unwrap();
let debug_priv = policy.get_privilege("SeDebugPrivilege").unwrap();
assert_eq!(debug_priv.principals(), &["*S-1-5-32-544", "*S-1-5-32-545"]);
}
#[test]
fn parse_arbitrary_unknown_privilege_names_and_non_sid_principals() {
let content = "[Privilege Rights]\nSeCustomPrivilege = *S-1-5-21-999-888-777-1000, DOMAIN\\CustomGroup\n";
let policy = parse_gpttmpl(content).unwrap();
let custom = policy.get_privilege("SeCustomPrivilege").unwrap();
assert_eq!(
custom.principals(),
&["*S-1-5-21-999-888-777-1000", "DOMAIN\\CustomGroup"]
);
assert_eq!(
custom.normalized_principals().collect::<Vec<_>>(),
vec!["S-1-5-21-999-888-777-1000", "DOMAIN\\CustomGroup"]
);
assert_eq!(
custom.sid_candidates().collect::<Vec<_>>(),
vec!["S-1-5-21-999-888-777-1000"]
);
}
#[test]
fn decode_truncated_utf16_returns_error_without_panic() {
let odd_bytes = vec![0xFF, 0xFE, 0x41]; let result = decode_gpttmpl_bytes(&odd_bytes);
assert!(result.is_err());
match result.unwrap_err() {
GpoError::InvalidEncoding(msg) => assert!(msg.contains("Truncated")),
other => panic!("Unexpected error type: {other:?}"),
}
}
#[test]
fn parse_handles_mixed_sections_and_case_insensitivity() {
let content = r#"
[Version]
signature="$CHICAGO$"
revision=1
[PRIVILEGE RIGHTS]
SeAssignPrimaryTokenPrivilege = *S-1-5-19, *S-1-5-20
[Group Membership]
*S-1-5-32-544__Members = *S-1-5-21-1-2-3-500
[System Access]
MinimumPasswordAge = 1
"#;
let policy = parse_gpttmpl(content).unwrap();
assert_eq!(policy.privilege_rights().len(), 1);
let priv_assign = policy
.get_privilege("SeAssignPrimaryTokenPrivilege")
.unwrap();
assert_eq!(priv_assign.principals(), &["*S-1-5-19", "*S-1-5-20"]);
assert_eq!(
priv_assign.sid_candidates().collect::<Vec<_>>(),
vec!["S-1-5-19", "S-1-5-20"]
);
}
#[test]
fn parse_handles_trailing_comma_and_non_asterisk_sids() {
let content = r#"
[Privilege Rights]
SeLoadDriverPrivilege = S-1-5-32-544, *S-1-5-32-545,
"#;
let policy = parse_gpttmpl(content).unwrap();
let load_driver = policy.get_privilege("SeLoadDriverPrivilege").unwrap();
assert_eq!(load_driver.principals(), &["S-1-5-32-544", "*S-1-5-32-545"]);
assert_eq!(
load_driver.sid_candidates().collect::<Vec<_>>(),
vec!["S-1-5-32-544", "S-1-5-32-545"]
);
}
}