use std::collections::HashMap;
pub(crate) fn resolve_field(
field: &str,
credential: &str,
companions: &HashMap<String, String>,
) -> String {
match field {
"match" => credential.to_string(),
s if s.starts_with("companion.") => {
let name = &s["companion.".len()..];
companions.get(name).cloned().unwrap_or_default() }
"" => String::new(),
other => other.to_string(),
}
}
fn url_encode(s: &str) -> String {
percent_encoding::percent_encode(s.as_bytes(), percent_encoding::NON_ALPHANUMERIC).to_string()
}
pub(crate) fn sanitize_oob_value(s: &str) -> String {
s.chars()
.filter_map(|c| {
let lc = c.to_ascii_lowercase();
if lc.is_ascii_lowercase() || c.is_ascii_digit() || lc == '.' || lc == '-' {
Some(lc)
} else {
None
}
})
.collect()
}
pub(crate) fn sanitize_raw_value(s: &str) -> String {
s.chars()
.filter(|c| {
let cp = *c as u32;
!(cp < 0x20 && cp != 0x09) && cp != 0x7F && !(0x80..=0x9F).contains(&cp)
})
.collect()
}
pub(crate) fn resolve_and_sanitize_field(
field: &str,
credential: &str,
companions: &HashMap<String, String>,
) -> String {
sanitize_raw_value(&resolve_field(field, credential, companions))
}
pub const MAX_TEMPLATE_TOKENS: usize = 1024;
pub(crate) fn interpolate(
template: &str,
credential: &str,
companions: &HashMap<String, String>,
) -> String {
interpolate_url(template, credential, companions)
}
pub(crate) fn interpolate_url(
template: &str,
credential: &str,
companions: &HashMap<String, String>,
) -> String {
interpolate_with_context(template, credential, companions, InterpolationContext::Url)
}
pub(crate) fn interpolate_http_value(
template: &str,
credential: &str,
companions: &HashMap<String, String>,
) -> String {
interpolate_with_context(
template,
credential,
companions,
InterpolationContext::HttpValue,
)
}
pub(crate) fn missing_companion_field(
field: &str,
companions: &HashMap<String, String>,
) -> Option<String> {
field
.strip_prefix("companion.")
.filter(|name| !companions.contains_key(*name))
.map(str::to_string)
}
pub fn missing_companion_refs(template: &str, companions: &HashMap<String, String>) -> Vec<String> {
let mut missing = Vec::new();
let mut search_from = 0usize;
let mut scanned = 0usize;
while scanned < MAX_TEMPLATE_TOKENS {
let Some(offset) = template[search_from..].find("{{companion.") else {
break;
};
let start = search_from + offset;
let Some(end_offset) = template[start..].find("}}") else {
break;
};
let name_start = start + "{{companion.".len();
let name_end = start + end_offset;
let name = &template[name_start..name_end];
if !companions.contains_key(name) && !missing.iter().any(|m| m == name) {
missing.push(name.to_string());
}
search_from = start + end_offset + 2;
scanned += 1;
}
missing
}
#[derive(Copy, Clone)]
enum InterpolationContext {
Url,
HttpValue,
}
fn interpolate_placeholder_value(value: &str, context: InterpolationContext) -> String {
match context {
InterpolationContext::Url => url_encode(value),
InterpolationContext::HttpValue => sanitize_raw_value(value),
}
}
fn resolve_oob_url(companions: &HashMap<String, String>) -> String {
let raw = companions
.get(OOB_COMPANION_URL)
.map(String::as_str)
.unwrap_or(""); match raw.split_once("://") {
Some((scheme, host)) if scheme.chars().all(|c| c.is_ascii_alphabetic()) => {
format!("{scheme}://{}", sanitize_oob_value(host))
}
_ => sanitize_oob_value(raw),
}
}
fn resolve_placeholder(
inner: &str,
credential: &str,
companions: &HashMap<String, String>,
context: InterpolationContext,
) -> Option<String> {
let oob = |key| {
sanitize_oob_value(companions.get(key).map(String::as_str).unwrap_or(""))
};
match inner {
"match" => Some(interpolate_placeholder_value(credential, context)),
"interactsh.url" => Some(resolve_oob_url(companions)),
"interactsh.host" | "interactsh" => Some(oob(OOB_COMPANION_HOST)),
"interactsh.id" => Some(oob(OOB_COMPANION_ID)),
_ => inner.strip_prefix("companion.").map(|name| {
let raw = companions.get(name).map(String::as_str).unwrap_or(""); interpolate_placeholder_value(raw, context)
}),
}
}
fn interpolate_with_context(
template: &str,
credential: &str,
companions: &HashMap<String, String>,
context: InterpolationContext,
) -> String {
if template == "{{match}}" {
return sanitize_raw_value(credential);
}
if template.starts_with("{{companion.")
&& template.ends_with("}}")
&& template.matches("{{").count() == 1
{
let name = &template["{{companion.".len()..template.len() - 2];
let raw = match companions.get(name) {
Some(value) => value.as_str(),
None => "",
};
return sanitize_raw_value(raw);
}
let mut out = String::with_capacity(template.len());
let mut rest = template;
let mut replacements = 0usize;
while replacements < MAX_TEMPLATE_TOKENS {
let Some(open) = rest.find("{{") else { break };
let after_open = &rest[open + 2..];
let Some(close_rel) = after_open.find("}}") else {
break;
};
let inner = &after_open[..close_rel];
out.push_str(&rest[..open]);
match resolve_placeholder(inner, credential, companions, context) {
Some(value) => out.push_str(&value),
None => {
out.push_str("{{");
out.push_str(inner);
out.push_str("}}");
}
}
rest = &after_open[close_rel + 2..];
replacements += 1;
}
out.push_str(rest);
out
}
pub(crate) const OOB_COMPANION_URL: &str = "__keyhog_oob_url";
pub(crate) const OOB_COMPANION_HOST: &str = "__keyhog_oob_host";
pub(crate) const OOB_COMPANION_ID: &str = "__keyhog_oob_id";
pub(crate) fn companions_with_oob(
base: &HashMap<String, String>,
minted_host: &str,
minted_url: &str,
minted_id: &str,
) -> HashMap<String, String> {
let mut out = base.clone();
out.insert(OOB_COMPANION_HOST.to_string(), minted_host.to_string());
out.insert(OOB_COMPANION_URL.to_string(), minted_url.to_string());
out.insert(OOB_COMPANION_ID.to_string(), minted_id.to_string());
out
}