use crate::expand;
use crate::path_entry::PathEntry;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
Process,
User,
Machine,
}
#[derive(Debug)]
pub struct PathRead {
pub entries: Vec<PathEntry>,
pub warning: Option<String>,
}
pub fn read_path(target: Target) -> PathRead {
match target {
Target::Process => read_process(),
Target::User => read_registry(target),
Target::Machine => read_registry(target),
}
}
#[cfg(not(windows))]
fn read_process() -> PathRead {
PathRead {
entries: split_into_entries(&std::env::var("PATH").unwrap_or_default()),
warning: None,
}
}
#[cfg(windows)]
fn read_process() -> PathRead {
let process = split_into_entries(&std::env::var("PATH").unwrap_or_default());
let user_reg = read_registry(Target::User);
let machine_reg = read_registry(Target::Machine);
let entries =
reconcile_process_with_registry(&process, &user_reg.entries, &machine_reg.entries);
let mut warnings: Vec<String> = Vec::new();
if let Some(w) = user_reg.warning {
warnings.push(format!("user-registry overlay: {w}"));
}
if let Some(w) = machine_reg.warning {
warnings.push(format!("machine-registry overlay: {w}"));
}
let warning = if warnings.is_empty() {
None
} else {
Some(warnings.join("; "))
};
PathRead { entries, warning }
}
#[allow(dead_code)]
pub(crate) fn reconcile_process_with_registry(
process: &[PathEntry],
user_reg: &[PathEntry],
machine_reg: &[PathEntry],
) -> Vec<PathEntry> {
process
.iter()
.map(|p| {
let candidate =
find_expanded_match(p, user_reg).or_else(|| find_expanded_match(p, machine_reg));
match candidate {
Some(reg_raw) if reg_raw != p.raw => p.clone().with_provenance(reg_raw),
_ => p.clone(),
}
})
.collect()
}
#[allow(dead_code)]
fn find_expanded_match(p: &PathEntry, reg: &[PathEntry]) -> Option<String> {
let key = expand::normalize(&p.expanded);
reg.iter()
.find(|r| expand::normalize(&r.expanded) == key)
.map(|r| r.raw.clone())
}
pub(crate) fn split_into_entries(s: &str) -> Vec<PathEntry> {
let sep = if cfg!(windows) { ';' } else { ':' };
s.split(sep)
.filter(|x| !x.is_empty())
.map(|raw| PathEntry::from_raw(raw, |v| std::env::var(v).ok()))
.collect()
}
#[cfg(windows)]
fn read_registry(target: Target) -> PathRead {
use winreg::RegKey;
use winreg::enums::*;
let (root, subkey) = match target {
Target::User => (RegKey::predef(HKEY_CURRENT_USER), "Environment"),
Target::Machine => (
RegKey::predef(HKEY_LOCAL_MACHINE),
r"System\CurrentControlSet\Control\Session Manager\Environment",
),
Target::Process => unreachable!(),
};
let key = match root.open_subkey(subkey) {
Ok(k) => k,
Err(e) => {
return PathRead {
entries: Vec::new(),
warning: Some(format!("could not open registry key: {e}")),
};
}
};
let raw_value = match key.get_raw_value("Path") {
Ok(v) => v,
Err(e) => {
return PathRead {
entries: Vec::new(),
warning: Some(format!("could not read Path value: {e}")),
};
}
};
match decode_reg_string(&raw_value) {
Ok(raw_string) => PathRead {
entries: split_into_entries(&raw_string),
warning: None,
},
Err(reason) => PathRead {
entries: Vec::new(),
warning: Some(format!("registry Path is not a valid string ({reason})")),
},
}
}
#[cfg(not(windows))]
fn read_registry(target: Target) -> PathRead {
let label = match target {
Target::User => "user",
Target::Machine => "machine",
Target::Process => unreachable!(),
};
PathRead {
entries: split_into_entries(&std::env::var("PATH").unwrap_or_default()),
warning: Some(format!(
"--target {label} is Windows-only; falling back to process PATH"
)),
}
}
#[cfg(windows)]
pub(crate) fn decode_reg_string(v: &winreg::RegValue) -> Result<String, &'static str> {
use winreg::enums::RegType;
match v.vtype {
RegType::REG_SZ | RegType::REG_EXPAND_SZ => {
if v.bytes.len() % 2 != 0 {
return Err("UTF-16 byte stream has odd length");
}
let units: Vec<u16> = v
.bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
let trimmed: &[u16] = match units.iter().position(|&u| u == 0) {
Some(idx) => &units[..idx],
None => &units[..],
};
Ok(String::from_utf16_lossy(trimmed))
}
_ => Err("unexpected registry value type"),
}
}
#[cfg(test)]
mod overlay_tests {
use super::*;
fn raw_entry(raw: &str, expanded: &str) -> PathEntry {
PathEntry {
raw: raw.into(),
expanded: expanded.into(),
provenance_raw: None,
}
}
#[test]
fn reconcile_overlays_user_provenance_when_expanded_matches() {
let process = vec![raw_entry(
r"C:\Users\me\AppData\Local\Microsoft\WindowsApps",
r"C:\Users\me\AppData\Local\Microsoft\WindowsApps",
)];
let user_reg = vec![raw_entry(
r"%LocalAppData%\Microsoft\WindowsApps",
r"C:\Users\me\AppData\Local\Microsoft\WindowsApps",
)];
let machine_reg: Vec<PathEntry> = Vec::new();
let out = reconcile_process_with_registry(&process, &user_reg, &machine_reg);
assert_eq!(out.len(), 1);
assert_eq!(
out[0].provenance_raw.as_deref(),
Some(r"%LocalAppData%\Microsoft\WindowsApps"),
);
}
#[test]
fn reconcile_prefers_user_over_machine_when_both_match() {
let process = vec![raw_entry(
r"C:\Users\me\AppData\Local\Microsoft\WindowsApps",
r"C:\Users\me\AppData\Local\Microsoft\WindowsApps",
)];
let user_reg = vec![raw_entry(
r"%LocalAppData%\Microsoft\WindowsApps",
r"C:\Users\me\AppData\Local\Microsoft\WindowsApps",
)];
let machine_reg = vec![raw_entry(
r"%MACHINE_VAR%\Microsoft\WindowsApps",
r"C:\Users\me\AppData\Local\Microsoft\WindowsApps",
)];
let out = reconcile_process_with_registry(&process, &user_reg, &machine_reg);
assert_eq!(
out[0].provenance_raw.as_deref(),
Some(r"%LocalAppData%\Microsoft\WindowsApps"),
"HKCU must win over HKLM when expanded matches both",
);
}
#[test]
fn reconcile_skips_when_process_expanded_has_no_registry_match() {
let process = vec![raw_entry(
r"C:\runtime\injected\bin",
r"C:\runtime\injected\bin",
)];
let user_reg = vec![raw_entry(
r"%LocalAppData%\Microsoft\WindowsApps",
r"C:\Users\me\AppData\Local\Microsoft\WindowsApps",
)];
let machine_reg: Vec<PathEntry> = Vec::new();
let out = reconcile_process_with_registry(&process, &user_reg, &machine_reg);
assert_eq!(out.len(), 1);
assert!(
out[0].provenance_raw.is_none(),
"no expanded match in either registry; provenance must stay None",
);
}
#[test]
fn reconcile_no_overlay_when_raw_already_matches_registry() {
let process = vec![raw_entry(
r"C:\Program Files\PowerShell\7",
r"C:\Program Files\PowerShell\7",
)];
let user_reg = vec![raw_entry(
r"C:\Program Files\PowerShell\7",
r"C:\Program Files\PowerShell\7",
)];
let machine_reg: Vec<PathEntry> = Vec::new();
let out = reconcile_process_with_registry(&process, &user_reg, &machine_reg);
assert!(
out[0].provenance_raw.is_none(),
"raw already matches; provenance overlay would be redundant",
);
}
#[test]
fn reconcile_first_occurrence_wins_within_user_registry() {
let process = vec![raw_entry(
r"C:\Users\me\AppData\Local\Microsoft\WindowsApps",
r"C:\Users\me\AppData\Local\Microsoft\WindowsApps",
)];
let user_reg = vec![
raw_entry(
r"%LocalAppData%\Microsoft\WindowsApps",
r"C:\Users\me\AppData\Local\Microsoft\WindowsApps",
),
raw_entry(
r"%USERPROFILE%\AppData\Local\Microsoft\WindowsApps",
r"C:\Users\me\AppData\Local\Microsoft\WindowsApps",
),
];
let machine_reg: Vec<PathEntry> = Vec::new();
let out = reconcile_process_with_registry(&process, &user_reg, &machine_reg);
assert_eq!(
out[0].provenance_raw.as_deref(),
Some(r"%LocalAppData%\Microsoft\WindowsApps"),
"first occurrence must win",
);
}
#[test]
fn reconcile_uses_normalized_expanded_for_match() {
let process = vec![raw_entry(
r"C:\Users\Me\AppData\Local\X",
r"C:\Users\Me\AppData\Local\X",
)];
let user_reg = vec![raw_entry(
r"%LocalAppData%\X",
"c:/users/me/appdata/local/x",
)];
let machine_reg: Vec<PathEntry> = Vec::new();
let out = reconcile_process_with_registry(&process, &user_reg, &machine_reg);
assert_eq!(
out[0].provenance_raw.as_deref(),
Some(r"%LocalAppData%\X"),
"match must use expand::normalize on both sides",
);
}
}
#[cfg(all(test, windows))]
mod tests {
use super::*;
use winreg::RegValue;
use winreg::enums::RegType;
fn reg_value(s: &str, vtype: RegType) -> RegValue {
let mut units: Vec<u16> = s.encode_utf16().collect();
units.push(0);
let bytes: Vec<u8> = units.iter().flat_map(|u| u.to_le_bytes()).collect();
RegValue { bytes, vtype }
}
#[test]
fn decode_reg_string_keeps_percent_var_for_reg_expand_sz() {
let v = reg_value(
r"%LocalAppData%\Microsoft\WindowsApps",
RegType::REG_EXPAND_SZ,
);
let decoded = decode_reg_string(&v).expect("REG_EXPAND_SZ decode");
assert_eq!(decoded, r"%LocalAppData%\Microsoft\WindowsApps");
}
#[test]
fn decode_reg_string_handles_reg_sz_literal() {
let v = reg_value(r"C:\Program Files\PowerShell\7", RegType::REG_SZ);
let decoded = decode_reg_string(&v).expect("REG_SZ decode");
assert_eq!(decoded, r"C:\Program Files\PowerShell\7");
}
#[test]
fn decode_reg_string_rejects_unsupported_reg_type() {
let v = RegValue {
bytes: vec![0, 0, 0, 0],
vtype: RegType::REG_DWORD,
};
let err = decode_reg_string(&v).unwrap_err();
assert!(err.contains("unexpected"), "err was: {err}");
}
#[test]
fn decode_reg_string_rejects_odd_byte_length() {
let v = RegValue {
bytes: vec![b'A', b'B', b'C'],
vtype: RegType::REG_SZ,
};
let err = decode_reg_string(&v).unwrap_err();
assert!(err.contains("odd length"), "err was: {err}");
}
}