Files
ghostty-rs/crates/ghostty-os/src/env.rs
T
ddidderr e6564cb838 fix: systematic byte fidelity for config strings and RV7 gaps
Resolves the 9 gap groups in docs/rewrite/notes/review-rv7-codex.md and
audits every string-typed config field against its consumers
(field -> consumers -> decode status table in final-Z9.md):
- Windows EnvMap keys use a port of Zig's nls.zig uppercase table
  (upcaseW); Windows locale init follows Zig's chain (setlocale +
  _putenv_s); malformed WTF-8 now fails process launch conversions like
  Zig instead of substituting U+FFFD; kitty file paths convert WTF-8.
- bash --rcfile/--init-file, configured TERM and inherited GTK LANG
  keep their original bytes.
- C API clipboard completion and ghostty_surface_binding_action take
  bytes; complete_clipboard_request is byte-typed through the core.
- text:/csi:/esc: bindings, enquiry-response, input = path:, embedded
  initial_input, search needles, titles, link regexes, clipboard
  codepoint map, command palette text and GTK font/namespace values
  decode the escape encoding before use.

Remaining by design: D-Bus a{ss} values and +new-window arguments are
UTF-8 by D-Bus type. C API key/preedit text follows separately.

Test Plan:
- cargo test --workspace
- just check-macos (both), just check-windows, just check-wasm
2026-10-12 10:50:20 +02:00

433 lines
13 KiB
Rust

use std::{
borrow::Cow,
collections::HashMap,
ffi::{OsStr, OsString},
};
use crate::flatpak::is_flatpak;
/// PATH-style delimiter (`std.fs.path.delimiter`).
#[cfg(windows)]
const DELIMITER: char = ';';
#[cfg(not(windows))]
const DELIMITER: char = ':';
/// The comparison form of an environment key on Windows: its WTF-16 code
/// units, uppercased per unit. Never lossy: distinct lone surrogates stay
/// distinct.
#[cfg_attr(not(windows), allow(dead_code))]
fn windows_key(key: &OsStr) -> Vec<u16> {
crate::osbytes::wtf8_to_wide(&crate::osbytes::os_bytes(key))
.into_iter()
.map(crate::windows_nls::upcase_w)
.collect()
}
/// A byte-preserving environment map (Zig `std.process.EnvMap`): keys and
/// values are arbitrary OS strings, never required to be UTF-8.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EnvMap(HashMap<OsString, OsString>);
impl EnvMap {
pub fn new() -> Self {
Self::default()
}
/// On Windows environment variable names are case-insensitive (Zig's
/// `EnvMap` uses a case-insensitive hash context there), so lookups find
/// the existing key regardless of ASCII case. Elsewhere this is the key
/// itself.
#[cfg(windows)]
fn canonical_key<'a>(&'a self, key: &'a OsStr) -> Cow<'a, OsStr> {
let want = windows_key(key);
self.0
.keys()
.find(|k| windows_key(k) == want)
.map_or(Cow::Borrowed(key), |k| Cow::Borrowed(k.as_os_str()))
}
#[cfg(not(windows))]
#[allow(clippy::unused_self)]
fn canonical_key<'a>(&'a self, key: &'a OsStr) -> Cow<'a, OsStr> {
Cow::Borrowed(key)
}
pub fn insert(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Option<OsString> {
let key = self.canonical_key(key.as_ref()).into_owned();
self.0.insert(key, value.as_ref().to_owned())
}
pub fn get(&self, key: impl AsRef<OsStr>) -> Option<&OsStr> {
self.0
.get(&*self.canonical_key(key.as_ref()))
.map(OsString::as_os_str)
}
pub fn remove(&mut self, key: impl AsRef<OsStr>) -> Option<OsString> {
let key = self.canonical_key(key.as_ref()).into_owned();
self.0.remove(&key)
}
pub fn contains_key(&self, key: impl AsRef<OsStr>) -> bool {
self.0.contains_key(&*self.canonical_key(key.as_ref()))
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&OsStr, &OsStr)> {
self.0.iter().map(|(k, v)| (k.as_os_str(), v.as_os_str()))
}
}
impl<K: AsRef<OsStr>> std::ops::Index<K> for EnvMap {
type Output = OsStr;
fn index(&self, key: K) -> &OsStr {
self.get(key).expect("no entry found for key")
}
}
impl<K: Into<OsString>, V: Into<OsString>> FromIterator<(K, V)> for EnvMap {
fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
Self(
iter.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
)
}
}
impl IntoIterator for EnvMap {
type Item = (OsString, OsString);
type IntoIter = std::collections::hash_map::IntoIter<OsString, OsString>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
/// Get the environment map. Inside Flatpak this is empty (the sandbox
/// environment is not meaningful for host commands). Bytes are preserved.
pub fn get_env_map() -> EnvMap {
if is_flatpak() {
return EnvMap::new();
}
std::env::vars_os().collect()
}
/// Filter out Snap-specific environment variables and path components
/// (port of `Surface.filterSnapPaths`): remove any `SNAP_*` variable and
/// drop `:`-separated components that start with one of the snap
/// directories. Variables we set ourselves (`TERMINFO`, `GHOSTTY*`) are
/// left alone.
#[cfg(windows)]
pub fn filter_snap_paths(_env: &mut EnvMap) {
// Snap only exists on Linux.
}
/// See the Windows no-op above.
#[cfg(unix)]
pub fn filter_snap_paths(env: &mut EnvMap) {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
const SNAP_VARS: [&str; 5] = [
"SNAP",
"SNAP_USER_COMMON",
"SNAP_USER_DATA",
"SNAP_DATA",
"SNAP_COMMON",
];
let mut to_remove: Vec<OsString> = Vec::new();
let mut to_update: Vec<(OsString, OsString)> = Vec::new();
for (key, value) in env.iter() {
let kb = key.as_bytes();
if kb == b"TERMINFO" || kb.starts_with(b"GHOSTTY") {
continue;
}
if kb.starts_with(b"SNAP_") {
to_remove.push(key.to_owned());
continue;
}
let mut filtered: Vec<&[u8]> = Vec::new();
let mut modified = false;
for path in value.as_bytes().split(|&b| b == b':') {
let mut include = true;
for k in SNAP_VARS {
if let Some(snap_path) = env.get(k) {
let snap_path = snap_path.as_bytes();
if snap_path.is_empty() {
continue;
}
if path.starts_with(snap_path) {
include = false;
modified = true;
break;
}
}
}
if include {
filtered.push(path);
}
}
if modified {
if filtered.is_empty() {
to_remove.push(key.to_owned());
} else {
to_update.push((key.to_owned(), OsString::from_vec(filtered.join(&b':'))));
}
}
}
for (k, v) in to_update {
env.insert(k, v);
}
for k in to_remove {
env.remove(k);
}
}
/// Append a value to an environment variable such as PATH.
pub fn append_env(current: &str, value: &str) -> String {
// If there is no prior value, we return it as-is
if current.is_empty() {
return value.to_owned();
}
// Otherwise we must prefix.
append_env_always(current, value)
}
/// Always append value to environment, even when it is empty.
/// This is useful because some env vars (like MANPATH) want there
/// to be an empty prefix to preserve existing values.
pub fn append_env_always(current: &str, value: &str) -> String {
format!("{current}{DELIMITER}{value}")
}
/// Byte-preserving [`append_env_always`].
pub fn append_env_always_os(current: &OsStr, value: &OsStr) -> OsString {
let mut out = current.to_owned();
out.push(DELIMITER.to_string());
out.push(value);
out
}
/// Byte-preserving [`prepend_env`].
pub fn prepend_env_os(current: &OsStr, value: &OsStr) -> OsString {
if current.is_empty() {
return value.to_owned();
}
let mut out = value.to_owned();
out.push(DELIMITER.to_string());
out.push(current);
out
}
/// Byte-preserving [`append_env`].
pub fn append_env_os(current: &OsStr, value: &OsStr) -> OsString {
if current.is_empty() {
return value.to_owned();
}
let mut out = current.to_owned();
out.push(DELIMITER.to_string());
out.push(value);
out
}
/// Prepend a value to an environment variable such as PATH.
pub fn prepend_env(current: &str, value: &str) -> String {
// If there is no prior value, we return it as-is
if current.is_empty() {
return value.to_owned();
}
format!("{value}{DELIMITER}{current}")
}
/// Gets the raw value of an environment variable, or None if not found.
/// A present value that is not valid UTF-8 is returned as-is (never treated
/// as absent).
pub fn getenv_os(key: &str) -> Option<OsString> {
std::env::var_os(key)
}
/// Gets the raw value of an environment variable. Returns None if not found
/// or the value is empty.
pub fn getenv_not_empty_os(key: &str) -> Option<OsString> {
getenv_os(key).filter(|v| !v.is_empty())
}
/// Gets the value of an environment variable, or None if not found. A
/// non-UTF-8 value is still present: its invalid bytes are carried through
/// the reversible [`crate::osbytes`] encoding.
pub fn getenv(key: &str) -> Option<crate::osbytes::EncodedOs> {
getenv_os(key).map(|v| crate::osbytes::EncodedOs::from_os(&v))
}
/// Gets the value of an environment variable. Returns None if not found or
/// the value is empty.
pub fn getenv_not_empty(key: &str) -> Option<crate::osbytes::EncodedOs> {
getenv(key).filter(|v| !v.is_empty())
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
use super::*;
#[cfg(unix)]
#[test]
fn env_map_preserves_non_utf8() {
let mut env = EnvMap::new();
let v = OsString::from_vec(vec![b'a', 0xff, b'b']);
env.insert("K", &v);
assert_eq!(env.get("K"), Some(v.as_os_str()));
}
#[cfg(unix)]
#[test]
fn filter_snap_paths_filters() {
let mut env = EnvMap::new();
env.insert("SNAP", "/snap/ghostty/1");
env.insert("SNAP_NAME", "ghostty");
env.insert("GHOSTTY_X", "/snap/ghostty/1/a");
env.insert("TERMINFO", "/snap/ghostty/1/ti");
env.insert("PATH", "/snap/ghostty/1/bin:/usr/bin:/snap/ghostty/1/x");
env.insert("ONLY", "/snap/ghostty/1/lib");
env.insert("KEEP", "/usr/lib");
filter_snap_paths(&mut env);
assert!(!env.contains_key("SNAP"));
assert!(!env.contains_key("SNAP_NAME"));
assert_eq!(env["GHOSTTY_X"], *"/snap/ghostty/1/a");
assert_eq!(env["TERMINFO"], *"/snap/ghostty/1/ti");
assert_eq!(env["PATH"], *"/usr/bin");
assert!(!env.contains_key("ONLY"));
assert_eq!(env["KEEP"], *"/usr/lib");
}
#[test]
fn append_env_empty() {
assert_eq!(append_env("", "foo"), "foo");
}
#[test]
fn append_env_existing() {
assert_eq!(append_env("a:b", "foo"), format!("a:b{DELIMITER}foo"));
}
#[cfg(unix)]
#[test]
fn getenv_non_utf8_is_present() {
let v = OsString::from_vec(vec![b'a', 0xff]);
temp_env::with_var("GHOSTTY_RS_T3", Some(&v), || {
assert_eq!(getenv_os("GHOSTTY_RS_T3").unwrap(), v);
assert_eq!(getenv_not_empty_os("GHOSTTY_RS_T3").unwrap(), v);
let s = getenv("GHOSTTY_RS_T3").unwrap();
assert_eq!(
crate::osbytes::config_str_to_bytes(s.as_encoded()),
vec![b'a', 0xff]
);
assert!(getenv_not_empty("GHOSTTY_RS_T3").is_some());
});
}
#[cfg(unix)]
#[test]
fn os_variants_preserve_bytes() {
let cur = OsString::from_vec(vec![0xff, b'a']);
let out = prepend_env_os(&cur, OsStr::new("x"));
assert_eq!(out.into_vec(), vec![b'x', b':', 0xff, b'a']);
let out = append_env_os(&cur, OsStr::new("x"));
assert_eq!(out.into_vec(), vec![0xff, b'a', b':', b'x']);
assert_eq!(append_env_os(OsStr::new(""), OsStr::new("x")), "x");
}
#[test]
fn prepend_env_empty() {
assert_eq!(prepend_env("", "foo"), "foo");
}
#[test]
fn prepend_env_existing() {
assert_eq!(prepend_env("a:b", "foo"), format!("foo{DELIMITER}a:b"));
}
#[test]
fn append_env_always_empty_value() {
assert_eq!(append_env_always("a", ""), format!("a{DELIMITER}"));
assert_eq!(append_env_always("", "x"), format!("{DELIMITER}x"));
}
#[test]
fn getenv_not_empty_filters() {
temp_env::with_vars(
[("GHOSTTY_RS_T1", Some("")), ("GHOSTTY_RS_T2", Some("v"))],
|| {
assert_eq!(getenv("GHOSTTY_RS_T1").unwrap(), "");
assert_eq!(getenv_not_empty("GHOSTTY_RS_T1"), None);
assert_eq!(getenv_not_empty("GHOSTTY_RS_T2").unwrap(), "v");
assert_eq!(getenv_not_empty("GHOSTTY_RS_NOPE"), None);
},
);
}
}
#[cfg(test)]
mod windows_key_tests {
#[cfg(unix)]
use super::*;
fn key(units: &[u16]) -> Vec<u16> {
units
.iter()
.copied()
.map(crate::windows_nls::upcase_w)
.collect()
}
#[test]
fn keys_use_the_windows_table_not_unicode_mappings() {
// U+1F80 and U+1F88 are the same key under Windows/Zig's table even
// though Unicode's uppercase of U+1F80 is a two-unit sequence.
assert_eq!(key(&[0x1F80]), key(&[0x1F88]));
// The Georgian letters gained uppercase after the table froze.
assert_ne!(key(&[0x10D0]), key(&[0x1C90]));
// U+00DF stays distinct from "SS" (and from U+1E9E).
assert_ne!(key(&[0xDF]), key(&[0x53, 0x53]));
}
#[test]
fn non_ascii_case_keys_are_equal() {
assert_eq!(key(&[0xE9, 0x61]), key(&[0xC9, 0x41]));
}
#[test]
fn distinct_lone_surrogate_keys_stay_distinct() {
assert_ne!(key(&[0x41, 0xD800]), key(&[0x41, 0xD801]));
assert_ne!(key(&[0xD800]), key(&[0xFFFD]));
}
#[cfg(unix)]
#[test]
fn windows_key_of_os_str_is_wtf16() {
use std::os::unix::ffi::OsStrExt;
// Unix OsStr "Aé" in UTF-8.
assert_eq!(
windows_key(OsStr::from_bytes("a\u{e9}".as_bytes())),
vec![0x41, 0xC9]
);
}
}