Files
ghostty-rs/crates/ghostty-os/src/homedir.rs
T
ddidderr 395cff8981 fix: preserve non-UTF-8 bytes in OS, config and CLI layers
Zig treats paths, arguments and environment values as byte slices; the
port converted them lossily in many places. Now (final-Z1.md):
- argv, env (incl. present non-UTF-8 values), HOME, PATH lookup,
  passwd, resources dir, TMPDIR, cgroup, config path canonicalization,
  +validate-config, +list-themes and Zig-string escapes (\xFF) keep raw
  bytes. String-based config values carry bytes via ghostty_os::osbytes,
  a bijective escape (noncharacter U+FDD0 + byte) that leaves all valid
  UTF-8 - including Nerd Font private-use icons - unchanged.
- os/file rlimit fallbacks exist on all targets.
- i18n locale canonicalization is an exact port of libintl's tables
  (e.g. pa-Guru -> pa_IN).
- New ghostty-build-info crate is the single source of version, channel,
  commit and build mode for all consumers.

Test Plan:
- cargo test -p ghostty-os -p ghostty-config -p ghostty-cli
  (incl. randomized osbytes round-trip property tests)
2026-10-12 08:14:02 +02:00

265 lines
8.6 KiB
Rust

use std::ffi::{OsStr, OsString};
#[cfg(unix)]
use std::process::{Command, Stdio};
use crate::osbytes;
#[cfg(unix)]
use crate::passwd;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum Error {
/// The buffer used for output is not large enough to store the value.
#[error("buffer too small")]
BufferTooSmall,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ExpandError {
#[error("home detection failed")]
HomeDetectionFailed,
#[error("buffer too small")]
BufferTooSmall,
}
/// Equivalent of `std.fs.max_path_bytes` on Linux.
const MAX_PATH_BYTES: usize = 4096;
/// Determine the home directory for the currently executing user. This
/// is generally an expensive process so the value should be cached.
///
/// Non-UTF-8 bytes are preserved through [`crate::osbytes`]; use
/// [`home_os`] for the raw OS string.
pub fn home() -> Result<Option<String>, Error> {
home_with_capacity(MAX_PATH_BYTES)
}
/// Byte-preserving [`home`].
pub fn home_os() -> Result<Option<OsString>, Error> {
home_os_with_capacity(MAX_PATH_BYTES)
}
/// Like [`home`] but emulates the fixed output buffer of the Zig version:
/// errors with `BufferTooSmall` if the result is longer than `cap` bytes.
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
Ok(home_os_with_capacity(cap)?.map(|h| osbytes::os_to_config_string(&h)))
}
/// Byte-preserving [`home_with_capacity`].
#[cfg(unix)]
pub fn home_os_with_capacity(cap: usize) -> Result<Option<OsString>, Error> {
let check = |s: OsString| {
if cap < osbytes::os_bytes(&s).len() {
Err(Error::BufferTooSmall)
} else {
Ok(Some(s))
}
};
// First: if we have a HOME env var, then we use that.
if let Some(result) = std::env::var_os("HOME") {
return check(result);
}
// On macOS: [NSFileManager defaultManager].homeDirectoryForCurrentUser.path
#[cfg(target_os = "macos")]
{
use objc2_foundation::NSFileManager;
let manager = NSFileManager::defaultManager();
let home_url = manager.homeDirectoryForCurrentUser();
if let Some(path) = home_url.path() {
return check(OsString::from(path.to_string()));
}
}
// We try passwd. This doesn't work on multi-user mac but we try it anyways.
if let Ok(pw) = passwd::get_os()
&& let Some(result) = pw.home
{
return check(result);
}
// If all else fails, have the shell tell us...
let run = Command::new("/bin/sh")
.args(["-c", "cd && pwd"])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output();
if let Ok(run) = run
&& run.status.success()
{
return check(osbytes::os_from_bytes(trim_space(&run.stdout).to_vec()));
}
Ok(None)
}
/// Windows: `%HOMEDRIVE%%HOMEPATH%` (`homeWindows`). Returns `None` if
/// either variable is missing.
#[cfg(windows)]
pub fn home_os_with_capacity(cap: usize) -> Result<Option<OsString>, Error> {
let drive = std::env::var_os("HOMEDRIVE").map(|v| v.to_string_lossy().into_owned());
let path = std::env::var_os("HOMEPATH").map(|v| v.to_string_lossy().into_owned());
Ok(home_windows(drive.as_deref(), path.as_deref(), cap)?.map(OsString::from))
}
/// Pure part of [`home_with_capacity`] on Windows.
#[cfg(any(windows, test))]
fn home_windows(
drive: Option<&str>,
path: Option<&str>,
cap: usize,
) -> Result<Option<String>, Error> {
let (Some(drive), Some(path)) = (drive, path) else {
return Ok(None);
};
if cap < drive.len() + path.len() {
return Err(Error::BufferTooSmall);
}
Ok(Some(format!("{drive}{path}")))
}
#[cfg(unix)]
fn trim_space(input: &[u8]) -> &[u8] {
let is_ws = |b: &u8| matches!(b, b' ' | b'\n' | b'\t');
let start = input.iter().position(|b| !is_ws(b)).unwrap_or(input.len());
let end = input
.iter()
.rposition(|b| !is_ws(b))
.map_or(start, |i| i + 1);
&input[start..end]
}
/// Expands a path that starts with a tilde (~) to the home directory of
/// the current user.
///
/// Errors if `home` fails or if the size of the expanded path is larger
/// than the maximum path length.
pub fn expand_home(path: &str) -> Result<String, ExpandError> {
expand_home_with_capacity(path, MAX_PATH_BYTES)
}
/// Byte-preserving [`expand_home`].
pub fn expand_home_os(path: &OsStr) -> Result<OsString, ExpandError> {
expand_home_os_with_capacity(path, MAX_PATH_BYTES)
}
/// Like [`expand_home`] with an explicit output capacity (emulating the
/// caller-provided buffer of the Zig version).
pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, ExpandError> {
expand_home_os_with_capacity(&osbytes::config_str_to_os(path), cap)
.map(|p| osbytes::os_to_config_string(&p))
}
/// Windows: `~/` is not an idiom generally used on Windows.
#[cfg(windows)]
pub fn expand_home_os_with_capacity(path: &OsStr, _cap: usize) -> Result<OsString, ExpandError> {
Ok(path.to_owned())
}
/// See the Windows variant above.
#[cfg(unix)]
pub fn expand_home_os_with_capacity(path: &OsStr, cap: usize) -> Result<OsString, ExpandError> {
let bytes = osbytes::os_bytes(path);
if !bytes.starts_with(b"~/") {
return Ok(path.to_owned());
}
let Ok(Some(home_dir)) = home_os_with_capacity(cap) else {
return Err(ExpandError::HomeDetectionFailed);
};
let mut out = osbytes::os_bytes(&home_dir);
let rest = &bytes[1..]; // Skip the ~
if out.len() + rest.len() > cap {
return Err(ExpandError::BufferTooSmall);
}
out.extend_from_slice(rest);
Ok(osbytes::os_from_bytes(out))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn home_windows_joins_drive_and_path() {
assert_eq!(
home_windows(Some("C:"), Some("\\Users\\me"), 4096),
Ok(Some("C:\\Users\\me".to_owned()))
);
assert_eq!(home_windows(None, Some("\\x"), 4096), Ok(None));
assert_eq!(home_windows(Some("C:"), None, 4096), Ok(None));
assert_eq!(
home_windows(Some("C:"), Some("\\Users"), 3),
Err(Error::BufferTooSmall)
);
}
}
#[cfg(all(test, unix))]
mod tests_unix {
use super::*;
use crate::testutil::with_env_lock;
#[test]
fn expand_home_unix() {
with_env_lock(|| {
let cap = MAX_PATH_BYTES;
let home_dir = expand_home_with_capacity("~/", cap).unwrap();
// Joining the home directory `~` with the path `/`
// the result should end with a separator here. (e.g. `/home/user/`)
assert!(home_dir.ends_with('/'));
let downloads = expand_home_with_capacity("~/Downloads/shader.glsl", cap).unwrap();
let expected_downloads = format!("{home_dir}Downloads/shader.glsl");
assert_eq!(expected_downloads, downloads);
assert_eq!("~", expand_home_with_capacity("~", cap).unwrap());
assert_eq!("~abc/", expand_home_with_capacity("~abc/", cap).unwrap());
assert_eq!(
"/home/user",
expand_home_with_capacity("/home/user", cap).unwrap()
);
assert_eq!("", expand_home_with_capacity("", cap).unwrap());
// Expect an error if the buffer is large enough to hold the home directory,
// but not the expanded path
assert_eq!(
Err(ExpandError::BufferTooSmall),
expand_home_with_capacity("~/Downloads", home_dir.len())
);
});
}
#[test]
fn home_is_detected() {
with_env_lock(|| {
let result = home().unwrap();
assert!(result.is_some());
assert!(!result.unwrap().is_empty());
});
}
#[test]
fn home_preserves_non_utf8_env() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let h = OsString::from_vec(b"/home/\xffu".to_vec());
temp_env::with_var("HOME", Some(&h), || {
assert_eq!(home_os().unwrap().unwrap(), h);
let e = expand_home_os(OsStr::from_bytes(b"~/x\xfe")).unwrap();
assert_eq!(e.as_bytes(), b"/home/\xffu/x\xfe");
// The String API round-trips through the escape encoding.
let s = expand_home("~/y").unwrap();
assert_eq!(osbytes::config_str_to_bytes(&s), b"/home/\xffu/y");
});
}
#[test]
fn home_prefers_env() {
temp_env::with_var("HOME", Some("/some/home"), || {
assert_eq!(home().unwrap().as_deref(), Some("/some/home"));
assert_eq!(expand_home("~/x").unwrap(), "/some/home/x");
assert_eq!(home_with_capacity(3), Err(Error::BufferTooSmall));
});
}
}