use super::is_credential_env_key;
const BINARY_PLIST_MAGIC: &[u8] = b"bplist00";
#[derive(Clone, PartialEq, Eq)]
pub struct PlistSecret(String);
impl PlistSecret {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl std::fmt::Debug for PlistSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PlistSecret(<redacted>)")
}
}
impl std::fmt::Display for PlistSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("<redacted>")
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlistCredentialEntry {
pub key: String,
pub value: PlistSecret,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlistScrubError {
pub reason: String,
}
impl std::fmt::Display for PlistScrubError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "could not parse the plist: {}", self.reason)
}
}
impl std::error::Error for PlistScrubError {}
impl PlistScrubError {
pub(super) fn new(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScrubbedPlist {
pub keys: Vec<String>,
pub xml: String,
}
#[must_use]
pub fn is_binary_plist(bytes: &[u8]) -> bool {
bytes.starts_with(BINARY_PLIST_MAGIC)
}
pub fn credential_entries(xml: &str) -> Result<Vec<PlistCredentialEntry>, PlistScrubError> {
Ok(walk(xml)?
.into_iter()
.filter(|found| is_credential_env_key(&found.key))
.map(|found| PlistCredentialEntry {
key: found.key,
value: PlistSecret::new(found.value),
})
.collect())
}
pub fn scrub_plist_credential_env(xml: &str) -> Result<ScrubbedPlist, PlistScrubError> {
let wanted: Vec<String> = walk(xml)?
.into_iter()
.filter(|found| is_credential_env_key(&found.key))
.map(|found| found.key)
.collect();
scrub_plist_keys(xml, &wanted)
}
pub fn scrub_plist_keys(xml: &str, keys: &[String]) -> Result<ScrubbedPlist, PlistScrubError> {
let found = walk(xml)?;
let mut removed = Vec::new();
let mut drops: Vec<(usize, usize)> = Vec::new();
for entry in found {
if keys.iter().any(|k| k == &entry.key) {
removed.push(entry.key);
drops.push((entry.start, entry.end));
}
}
let mut out = xml.to_string();
for (from, to) in drops.iter().rev() {
out.replace_range(*from..*to, "");
}
Ok(ScrubbedPlist {
keys: removed,
xml: out,
})
}
struct Found {
key: String,
value: String,
start: usize,
end: usize,
}
fn walk(xml: &str) -> Result<Vec<Found>, PlistScrubError> {
let Some((start, end)) = env_dict_span(xml)? else {
return Ok(Vec::new());
};
let mut out = Vec::new();
let mut cursor = start;
while let Some(key_open) = find_from(xml, "<key>", cursor, end) {
let key_body = key_open + "<key>".len();
let key_close = find_from(xml, "</key>", key_body, end)
.ok_or_else(|| PlistScrubError::new("an EnvironmentVariables <key> is unterminated"))?;
let key = xml[key_body..key_close].trim().to_string();
let value_end = value_element_end(xml, key_close + "</key>".len(), end)?;
out.push(Found {
key,
value: value_text(xml, key_close + "</key>".len(), value_end),
start: key_open,
end: swallow_trailing_newline(xml, value_end),
});
cursor = value_end;
}
Ok(out)
}
fn value_text(xml: &str, from: usize, end: usize) -> String {
let Some(open) = xml[from..end].find('>').map(|o| from + o + 1) else {
return String::new();
};
let Some(close) = xml[open..end].rfind("</").map(|o| open + o) else {
return String::new();
};
if close < open {
return String::new();
}
xml[open..close].trim().to_string()
}
fn env_dict_span(xml: &str) -> Result<Option<(usize, usize)>, PlistScrubError> {
let Some(key_at) = xml.find("<key>EnvironmentVariables</key>") else {
return Ok(None);
};
let open = xml[key_at..]
.find("<dict>")
.map(|o| key_at + o + "<dict>".len())
.ok_or_else(|| PlistScrubError::new("EnvironmentVariables is not followed by a <dict>"))?;
let mut depth = 1usize;
let mut cursor = open;
while depth > 0 {
let next_open = xml[cursor..].find("<dict>").map(|o| cursor + o);
let next_close = xml[cursor..].find("</dict>").map(|o| cursor + o);
match (next_open, next_close) {
(Some(o), Some(c)) if o < c => {
depth += 1;
cursor = o + "<dict>".len();
}
(_, Some(c)) => {
depth -= 1;
if depth == 0 {
return Ok(Some((open, c)));
}
cursor = c + "</dict>".len();
}
_ => break,
}
}
Err(PlistScrubError::new(
"the EnvironmentVariables <dict> is unterminated",
))
}
fn value_element_end(xml: &str, from: usize, end: usize) -> Result<usize, PlistScrubError> {
let missing = || PlistScrubError::new("an EnvironmentVariables <key> has no value element");
let open = find_from(xml, "<", from, end).ok_or_else(missing)?;
let name_end = xml[open + 1..end]
.find(['>', ' ', '/'])
.map(|o| open + 1 + o)
.ok_or_else(missing)?;
let name = &xml[open + 1..name_end];
let tag_close = find_from(xml, ">", name_end, end).ok_or_else(missing)?;
if xml[open..=tag_close].ends_with("/>") {
return Ok(tag_close + 1);
}
let closing = format!("</{name}>");
find_from(xml, &closing, tag_close, end)
.map(|o| o + closing.len())
.ok_or_else(missing)
}
fn find_from(xml: &str, needle: &str, from: usize, end: usize) -> Option<usize> {
if from >= end {
return None;
}
xml[from..end].find(needle).map(|o| from + o)
}
fn swallow_trailing_newline(xml: &str, from: usize) -> usize {
let mut at = from;
for (offset, ch) in xml[from..].char_indices() {
match ch {
' ' | '\t' | '\r' => at = from + offset + ch.len_utf8(),
'\n' => return from + offset + 1,
_ => break,
}
}
at
}