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)
This commit is contained in:
1 parent
e4cdd8e007
commit
395cff8981
32 files changed
+1464
-333
No files matched your search
@@ -5,9 +5,28 @@
|
||||
//! don't panic).
|
||||
|
||||
/// Returns an iterator over the command line arguments.
|
||||
///
|
||||
/// If [`set_argv`] was called (the embedded `ghostty_init`, Zig:
|
||||
/// `std.os.argv = argv[0..argc]`) those arguments are returned instead of
|
||||
/// the process arguments.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn iterator() -> std::env::ArgsOs {
|
||||
std::env::args_os()
|
||||
pub fn iterator() -> std::vec::IntoIter<std::ffi::OsString> {
|
||||
match ARGV_OVERRIDE.lock().ok().and_then(|g| g.clone()) {
|
||||
Some(v) => v.into_iter(),
|
||||
None => std::env::args_os().collect::<Vec<_>>().into_iter(),
|
||||
}
|
||||
}
|
||||
|
||||
static ARGV_OVERRIDE: std::sync::Mutex<Option<Vec<std::ffi::OsString>>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
/// Replace the process arguments seen by [`iterator`] (Zig's assignment to
|
||||
/// `std.os.argv` in `ghostty_init`). Has no effect on macOS, where the
|
||||
/// arguments always come from `NSProcessInfo` (as in Zig).
|
||||
pub fn set_argv(args: Vec<std::ffi::OsString>) {
|
||||
if let Ok(mut g) = ARGV_OVERRIDE.lock() {
|
||||
*g = Some(args);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator over the command line arguments.
|
||||
@@ -27,15 +46,25 @@ pub fn iterator() -> std::vec::IntoIter<std::ffi::OsString> {
|
||||
.into_iter()
|
||||
}
|
||||
|
||||
/// Collect the process arguments as `String`s without ever panicking on
|
||||
/// non-UTF-8 bytes (invalid sequences become U+FFFD). The CLI/config layers
|
||||
/// are string based; `+action` dispatch only depends on the ASCII prefix.
|
||||
pub fn collect_lossy() -> Vec<String> {
|
||||
lossy(iterator())
|
||||
/// Collect the process arguments as raw OS strings (no conversion, never
|
||||
/// panics on non-UTF-8 bytes).
|
||||
pub fn collect_os() -> Vec<std::ffi::OsString> {
|
||||
iterator().collect()
|
||||
}
|
||||
|
||||
fn lossy(it: impl Iterator<Item = std::ffi::OsString>) -> Vec<String> {
|
||||
it.map(|a| a.to_string_lossy().into_owned()).collect()
|
||||
/// Collect the process arguments as raw byte vectors (Zig `[]const u8`).
|
||||
pub fn collect_bytes() -> Vec<Vec<u8>> {
|
||||
iterator().map(|a| crate::osbytes::os_bytes(&a)).collect()
|
||||
}
|
||||
|
||||
/// Collect the process arguments as `String`s using the reversible
|
||||
/// byte-escape encoding of [`crate::osbytes`]: no bytes are lost, invalid
|
||||
/// UTF-8 bytes are carried as private-use escapes that the config layer
|
||||
/// decodes back to the original bytes.
|
||||
pub fn collect_config_strings() -> Vec<String> {
|
||||
iterator()
|
||||
.map(|a| crate::osbytes::os_to_config_string(&a))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -50,13 +79,13 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lossy_does_not_panic_on_non_utf8() {
|
||||
fn config_strings_keep_non_utf8_bytes() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let args = vec![
|
||||
std::ffi::OsString::from("ghostty"),
|
||||
std::ffi::OsString::from_vec(vec![b'+', b'x', 0xff]),
|
||||
];
|
||||
let out = lossy(args.into_iter());
|
||||
assert_eq!(out, vec!["ghostty".to_owned(), "+x\u{fffd}".to_owned()]);
|
||||
let a = std::ffi::OsString::from_vec(vec![b'+', b'x', 0xff]);
|
||||
let s = crate::osbytes::os_to_config_string(&a);
|
||||
assert_eq!(
|
||||
crate::osbytes::config_str_to_bytes(&s),
|
||||
vec![b'+', b'x', 0xff]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,14 @@ use std::io::Read;
|
||||
/// into a caller-provided buffer; callers there use path-sized buffers).
|
||||
const READ_LIMIT: u64 = 4096;
|
||||
|
||||
/// Returns the path to the cgroup for the given pid.
|
||||
/// Returns the path to the cgroup for the given pid. Non-UTF-8 bytes are
|
||||
/// carried via [`crate::osbytes`]; see [`current_os`] for the raw bytes.
|
||||
pub fn current(pid: u32) -> Option<String> {
|
||||
current_os(pid).map(|p| crate::osbytes::os_to_config_string(&p))
|
||||
}
|
||||
|
||||
/// Byte-preserving [`current`].
|
||||
pub fn current_os(pid: u32) -> Option<std::ffi::OsString> {
|
||||
// Read our cgroup by opening /proc/<pid>/cgroup and reading the first
|
||||
// line. The first line will look something like this:
|
||||
// 0::/user.slice/user-1000.slice/session-1.scope
|
||||
@@ -21,15 +27,15 @@ pub fn current(pid: u32) -> Option<String> {
|
||||
.iter()
|
||||
.rposition(|b| !matches!(b, b' ' | b'\r' | b'\n'))
|
||||
.map_or(0, |i| i + 1);
|
||||
Some(String::from_utf8_lossy(&rest[..end]).into_owned())
|
||||
Some(crate::osbytes::os_from_bytes(rest[..end].to_vec()))
|
||||
}
|
||||
|
||||
/// True if the final path component of `pid`'s cgroup equals `expected`.
|
||||
pub fn scope_matches(pid: u32, expected: &str) -> bool {
|
||||
current(pid)
|
||||
.as_deref()
|
||||
.and_then(|raw| raw.rsplit_once('/'))
|
||||
.is_some_and(|(_, last)| last == expected)
|
||||
current_os(pid).is_some_and(|raw| {
|
||||
let raw = crate::osbytes::os_bytes(&raw);
|
||||
raw.rsplit(|&b| b == b'/').next() == Some(expected.as_bytes()) && raw.contains(&b'/')
|
||||
})
|
||||
}
|
||||
|
||||
/// Poll (every 25ms, at most 250ms) until `pid`'s cgroup ends in `expected`.
|
||||
@@ -61,4 +67,15 @@ mod tests {
|
||||
}
|
||||
assert!(current(u32::MAX).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_matches_requires_slash_and_last_component() {
|
||||
// Our own cgroup path always matches its own last component.
|
||||
if let Some(p) = current(std::process::id())
|
||||
&& let Some((_, last)) = p.rsplit_once('/')
|
||||
{
|
||||
assert!(scope_matches(std::process::id(), last));
|
||||
}
|
||||
assert!(!scope_matches(u32::MAX, "x"));
|
||||
}
|
||||
}
|
||||
@@ -235,10 +235,24 @@ pub fn prepend_env(current: &str, value: &str) -> String {
|
||||
format!("{value}{DELIMITER}{current}")
|
||||
}
|
||||
|
||||
/// Gets the value of an environment variable, or None if not found
|
||||
/// (or not valid unicode).
|
||||
/// 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<String> {
|
||||
std::env::var(key).ok()
|
||||
getenv_os(key).map(|v| crate::osbytes::os_to_config_string(&v))
|
||||
}
|
||||
|
||||
/// Gets the value of an environment variable. Returns None if not found or
|
||||
@@ -294,6 +308,19 @@ mod tests {
|
||||
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), vec![b'a', 0xff]);
|
||||
assert!(getenv_not_empty("GHOSTTY_RS_T3").is_some());
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn os_variants_preserve_bytes() {
|
||||
|
||||
@@ -70,19 +70,41 @@ pub fn restore_max_files(lim: Rlimit) {
|
||||
let _ = setrlimit(Resource::Nofile, lim);
|
||||
}
|
||||
|
||||
/// Return the recommended path for temporary files.
|
||||
pub fn alloc_tmp_dir() -> Option<String> {
|
||||
/// Resource limit placeholder on platforms without `rlimit` (Zig:
|
||||
/// `rlimit = if (@hasDecl(posix.system, "rlimit")) posix.rlimit else struct {}`).
|
||||
#[cfg(not(unix))]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Rlimit;
|
||||
|
||||
/// Unsupported-platform fallback: returns `None` like Zig.
|
||||
#[cfg(not(unix))]
|
||||
pub fn fix_max_files() -> Option<Rlimit> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Unsupported-platform fallback: no-op like Zig.
|
||||
#[cfg(not(unix))]
|
||||
pub fn restore_max_files(_lim: Rlimit) {}
|
||||
|
||||
/// Return the recommended path for temporary files (byte-preserving).
|
||||
pub fn alloc_tmp_dir_os() -> Option<std::path::PathBuf> {
|
||||
if cfg!(windows) {
|
||||
// TODO: what is a good fallback path on windows? (same as Zig)
|
||||
return std::env::var_os("TMP").map(|v| v.to_string_lossy().into_owned());
|
||||
return std::env::var_os("TMP").map(Into::into);
|
||||
}
|
||||
if let Some(v) = std::env::var_os("TMPDIR") {
|
||||
return Some(v.to_string_lossy().into_owned());
|
||||
return Some(v.into());
|
||||
}
|
||||
if let Some(v) = std::env::var_os("TMP") {
|
||||
return Some(v.to_string_lossy().into_owned());
|
||||
return Some(v.into());
|
||||
}
|
||||
Some("/tmp".to_owned())
|
||||
Some("/tmp".into())
|
||||
}
|
||||
|
||||
/// Return the recommended path for temporary files. Non-UTF-8 bytes are
|
||||
/// carried via [`crate::osbytes`]; see [`alloc_tmp_dir_os`].
|
||||
pub fn alloc_tmp_dir() -> Option<String> {
|
||||
alloc_tmp_dir_os().map(|p| crate::osbytes::os_to_config_string(p.as_os_str()))
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
@@ -103,6 +125,18 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmp_dir_non_utf8() {
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
let v = std::ffi::OsString::from_vec(b"/tmp/\xff".to_vec());
|
||||
temp_env::with_vars([("TMPDIR", Some(v.clone())), ("TMP", None)], || {
|
||||
assert_eq!(
|
||||
alloc_tmp_dir_os().unwrap().as_os_str().as_bytes(),
|
||||
b"/tmp/\xff"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn fix_and_restore_max_files() {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::ffi::{OsStr, OsString};
|
||||
#[cfg(unix)]
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::osbytes;
|
||||
#[cfg(unix)]
|
||||
use crate::passwd;
|
||||
|
||||
@@ -24,16 +26,29 @@ 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.
|
||||
#[cfg(unix)]
|
||||
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
||||
let check = |s: String| {
|
||||
if cap < s.len() {
|
||||
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))
|
||||
@@ -42,7 +57,7 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
||||
|
||||
// First: if we have a HOME env var, then we use that.
|
||||
if let Some(result) = std::env::var_os("HOME") {
|
||||
return check(result.to_string_lossy().into_owned());
|
||||
return check(result);
|
||||
}
|
||||
|
||||
// On macOS: [NSFileManager defaultManager].homeDirectoryForCurrentUser.path
|
||||
@@ -53,12 +68,12 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
||||
let manager = NSFileManager::defaultManager();
|
||||
let home_url = manager.homeDirectoryForCurrentUser();
|
||||
if let Some(path) = home_url.path() {
|
||||
return check(path.to_string());
|
||||
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()
|
||||
if let Ok(pw) = passwd::get_os()
|
||||
&& let Some(result) = pw.home
|
||||
{
|
||||
return check(result);
|
||||
@@ -73,8 +88,7 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
||||
if let Ok(run) = run
|
||||
&& run.status.success()
|
||||
{
|
||||
let out = String::from_utf8_lossy(&run.stdout);
|
||||
return check(trim_space(&out).to_owned());
|
||||
return check(osbytes::os_from_bytes(trim_space(&run.stdout).to_vec()));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
@@ -83,12 +97,10 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
||||
/// Windows: `%HOMEDRIVE%%HOMEPATH%` (`homeWindows`). Returns `None` if
|
||||
/// either variable is missing.
|
||||
#[cfg(windows)]
|
||||
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
||||
home_windows(
|
||||
std::env::var("HOMEDRIVE").ok().as_deref(),
|
||||
std::env::var("HOMEPATH").ok().as_deref(),
|
||||
cap,
|
||||
)
|
||||
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.
|
||||
@@ -108,8 +120,14 @@ fn home_windows(
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn trim_space(input: &str) -> &str {
|
||||
input.trim_matches([' ', '\n', '\t'])
|
||||
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
|
||||
@@ -121,31 +139,41 @@ 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_with_capacity(path: &str, _cap: usize) -> Result<String, ExpandError> {
|
||||
// `~/` is not an idiom generally used on 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_with_capacity(path: &str, cap: usize) -> Result<String, ExpandError> {
|
||||
if !path.starts_with("~/") {
|
||||
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_with_capacity(cap) else {
|
||||
let Ok(Some(home_dir)) = home_os_with_capacity(cap) else {
|
||||
return Err(ExpandError::HomeDetectionFailed);
|
||||
};
|
||||
let rest = &path[1..]; // Skip the ~
|
||||
let expanded_len = home_dir.len() + rest.len();
|
||||
|
||||
if expanded_len > cap {
|
||||
let mut out = osbytes::os_bytes(&home_dir);
|
||||
let rest = &bytes[1..]; // Skip the ~
|
||||
if out.len() + rest.len() > cap {
|
||||
return Err(ExpandError::BufferTooSmall);
|
||||
}
|
||||
|
||||
Ok(format!("{home_dir}{rest}"))
|
||||
out.extend_from_slice(rest);
|
||||
Ok(osbytes::os_from_bytes(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -211,6 +239,20 @@ mod tests_unix {
|
||||
});
|
||||
}
|
||||
|
||||
#[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"), || {
|
||||
|
||||
@@ -79,26 +79,49 @@ pub fn tr(msgid: &str) -> String {
|
||||
/// a POSIX-compliant value.
|
||||
///
|
||||
/// The Zig version is a thin layer over gnulib's
|
||||
/// `_libintl_locale_name_canonicalize` (only linked when building libintl
|
||||
/// from source, i.e. macOS). That function isn't available here, so this is
|
||||
/// a pure-Rust approximation of the cases that matter: BCP-47 style tags
|
||||
/// have `-` replaced with `_` and the Chinese script tags are mapped to
|
||||
/// their POSIX region equivalents. Encodings are NOT parsed out (like the
|
||||
/// original, `en_US.UTF-8` becomes `en_US.UTF_8`).
|
||||
/// `_libintl_locale_name_canonicalize` (the Mac OS X flavour of
|
||||
/// `gl_locale_name_canonicalize`), after fixing up some `zh` locales. This
|
||||
/// ports that function exactly, including its legacy-name, language-tag and
|
||||
/// script tables ([`crate::i18n_canon_table`]). Encodings are NOT parsed out
|
||||
/// (like the original, `en_US.UTF-8` becomes `en_US.UTF_8`).
|
||||
pub fn canonicalize_locale(locale: &str) -> String {
|
||||
// Fix zh locales
|
||||
if let Some(fixed) = fix_zh_locale(locale) {
|
||||
return fixed.to_owned();
|
||||
}
|
||||
|
||||
// Emulate gnulib's mapping of bare zh script tags.
|
||||
match locale {
|
||||
"zh-Hans" => return "zh_CN".to_owned(),
|
||||
"zh-Hant" => return "zh_TW".to_owned(),
|
||||
_ => {}
|
||||
libintl_locale_name_canonicalize(locale)
|
||||
}
|
||||
|
||||
/// `gl_locale_name_canonicalize` (gnulib `localename-unsafe.c`).
|
||||
fn libintl_locale_name_canonicalize(name: &str) -> String {
|
||||
use crate::i18n_canon_table::{LANGTAG_TABLE, LEGACY_TABLE, SCRIPT_TABLE};
|
||||
|
||||
// Step 1: Convert using legacy_table.
|
||||
if name.as_bytes().first().is_some_and(u8::is_ascii_uppercase)
|
||||
&& let Some((_, unixy)) = LEGACY_TABLE.iter().find(|(l, _)| *l == name)
|
||||
{
|
||||
return (*unixy).to_owned();
|
||||
}
|
||||
|
||||
locale.replace('-', "_")
|
||||
// Step 2: Convert using langtag_table and script_table.
|
||||
if name.len() == 7 && name.as_bytes()[2] == b'-' {
|
||||
if let Some((_, unixy)) = LANGTAG_TABLE.iter().find(|(l, _)| *l == name) {
|
||||
return (*unixy).to_owned();
|
||||
}
|
||||
|
||||
// `name.get(3..)` is None only when byte 3 is not a char boundary,
|
||||
// in which case no script entry can match.
|
||||
if let Some(script) = name.get(3..)
|
||||
&& let Some((_, unixy)) = SCRIPT_TABLE.iter().find(|(s, _)| *s == script)
|
||||
{
|
||||
// name[2] = '@'; strcpy (name + 3, unixy)
|
||||
return format!("{}@{unixy}", &name[..2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Convert new-style dash to Unix underscore.
|
||||
name.replace('-', "_")
|
||||
}
|
||||
|
||||
/// Handles some zh locales canonicalization because internal libintl
|
||||
@@ -183,6 +206,37 @@ mod tests {
|
||||
assert_eq!("en_US.UTF_8", canonicalize_locale("en_US.UTF-8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalize_locale_libintl_tables() {
|
||||
// langtag_table
|
||||
assert_eq!("pa_IN", canonicalize_locale("pa-Guru"));
|
||||
assert_eq!("pa_PK", canonicalize_locale("pa-Arab"));
|
||||
assert_eq!("az", canonicalize_locale("az-Latn"));
|
||||
assert_eq!("sr", canonicalize_locale("sr-Cyrl"));
|
||||
assert_eq!("kk", canonicalize_locale("kk-Cyrl"));
|
||||
assert_eq!("ga", canonicalize_locale("ga-dots"));
|
||||
// 8 bytes long: the `strlen == 7` guard skips the tables (upstream quirk).
|
||||
assert_eq!("yue_Hans", canonicalize_locale("yue-Hans"));
|
||||
// script_table (name[2] = '@')
|
||||
assert_eq!("sr@latin", canonicalize_locale("sr-Latn"));
|
||||
assert_eq!("uz@arabic", canonicalize_locale("uz-Arab"));
|
||||
assert_eq!("mn@mongolian", canonicalize_locale("mn-Mong"));
|
||||
// legacy_table (only for names starting uppercase)
|
||||
assert_eq!("pt_BR", canonicalize_locale("Brazilian Portuguese"));
|
||||
assert_eq!("zh_TW", canonicalize_locale("Traditional Chinese"));
|
||||
assert_eq!("de", canonicalize_locale("German"));
|
||||
assert_eq!("yi", canonicalize_locale("Yiddish"));
|
||||
assert_eq!("english", canonicalize_locale("english"));
|
||||
// dash fallback
|
||||
assert_eq!("pt_BR", canonicalize_locale("pt-BR"));
|
||||
assert_eq!("en_US", canonicalize_locale("en-US"));
|
||||
// non-ASCII input of length 7 must not panic
|
||||
assert_eq!(
|
||||
"\u{e9}\u{e9}\u{e9}x",
|
||||
canonicalize_locale("\u{e9}\u{e9}\u{e9}x")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locales_map_and_static_locale() {
|
||||
assert!(is_supported_locale("zh_CN"));
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Tables for [`crate::i18n::canonicalize_locale`], transcribed from gnulib's
|
||||
//! `localename-unsafe.c` (`gl_locale_name_canonicalize`, Mac OS X flavour) as
|
||||
//! bundled in gettext (`_libintl_locale_name_canonicalize`).
|
||||
|
||||
/// Legacy (NeXTstep inherited) Mac OS X English names to Unix names
|
||||
/// (gnulib `localename-unsafe.c`, `legacy_table`).
|
||||
pub(crate) const LEGACY_TABLE: [(&str, &str); 131] = [
|
||||
("Afrikaans", "af"),
|
||||
("Albanian", "sq"),
|
||||
("Amharic", "am"),
|
||||
("Arabic", "ar"),
|
||||
("Armenian", "hy"),
|
||||
("Assamese", "as"),
|
||||
("Aymara", "ay"),
|
||||
("Azerbaijani", "az"),
|
||||
("Basque", "eu"),
|
||||
("Belarusian", "be"),
|
||||
("Belorussian", "be"),
|
||||
("Bengali", "bn"),
|
||||
("Brazilian Portugese", "pt_BR"),
|
||||
("Brazilian Portuguese", "pt_BR"),
|
||||
("Breton", "br"),
|
||||
("Bulgarian", "bg"),
|
||||
("Burmese", "my"),
|
||||
("Byelorussian", "be"),
|
||||
("Catalan", "ca"),
|
||||
("Chewa", "ny"),
|
||||
("Chichewa", "ny"),
|
||||
("Chinese", "zh"),
|
||||
("Chinese, Simplified", "zh_CN"),
|
||||
("Chinese, Traditional", "zh_TW"),
|
||||
("Chinese, Tradtional", "zh_TW"),
|
||||
("Croatian", "hr"),
|
||||
("Czech", "cs"),
|
||||
("Danish", "da"),
|
||||
("Dutch", "nl"),
|
||||
("Dzongkha", "dz"),
|
||||
("English", "en"),
|
||||
("Esperanto", "eo"),
|
||||
("Estonian", "et"),
|
||||
("Faroese", "fo"),
|
||||
("Farsi", "fa"),
|
||||
("Finnish", "fi"),
|
||||
("Flemish", "nl_BE"),
|
||||
("French", "fr"),
|
||||
("Galician", "gl"),
|
||||
("Gallegan", "gl"),
|
||||
("Georgian", "ka"),
|
||||
("German", "de"),
|
||||
("Greek", "el"),
|
||||
("Greenlandic", "kl"),
|
||||
("Guarani", "gn"),
|
||||
("Gujarati", "gu"),
|
||||
("Hawaiian", "haw"),
|
||||
("Hebrew", "he"),
|
||||
("Hindi", "hi"),
|
||||
("Hungarian", "hu"),
|
||||
("Icelandic", "is"),
|
||||
("Indonesian", "id"),
|
||||
("Inuktitut", "iu"),
|
||||
("Irish", "ga"),
|
||||
("Italian", "it"),
|
||||
("Japanese", "ja"),
|
||||
("Javanese", "jv"),
|
||||
("Kalaallisut", "kl"),
|
||||
("Kannada", "kn"),
|
||||
("Kashmiri", "ks"),
|
||||
("Kazakh", "kk"),
|
||||
("Khmer", "km"),
|
||||
("Kinyarwanda", "rw"),
|
||||
("Kirghiz", "ky"),
|
||||
("Korean", "ko"),
|
||||
("Kurdish", "ku"),
|
||||
("Latin", "la"),
|
||||
("Latvian", "lv"),
|
||||
("Lithuanian", "lt"),
|
||||
("Macedonian", "mk"),
|
||||
("Malagasy", "mg"),
|
||||
("Malay", "ms"),
|
||||
("Malayalam", "ml"),
|
||||
("Maltese", "mt"),
|
||||
("Manx", "gv"),
|
||||
("Marathi", "mr"),
|
||||
("Moldavian", "mo"),
|
||||
("Mongolian", "mn"),
|
||||
("Nepali", "ne"),
|
||||
("Norwegian", "nb"),
|
||||
("Nyanja", "ny"),
|
||||
("Nynorsk", "nn"),
|
||||
("Oriya", "or"),
|
||||
("Oromo", "om"),
|
||||
("Panjabi", "pa"),
|
||||
("Pashto", "ps"),
|
||||
("Persian", "fa"),
|
||||
("Polish", "pl"),
|
||||
("Portuguese", "pt"),
|
||||
("Portuguese, Brazilian", "pt_BR"),
|
||||
("Punjabi", "pa"),
|
||||
("Pushto", "ps"),
|
||||
("Quechua", "qu"),
|
||||
("Romanian", "ro"),
|
||||
("Ruanda", "rw"),
|
||||
("Rundi", "rn"),
|
||||
("Russian", "ru"),
|
||||
("Sami", "se_NO"),
|
||||
("Sanskrit", "sa"),
|
||||
("Scottish", "gd"),
|
||||
("Serbian", "sr"),
|
||||
("Simplified Chinese", "zh_CN"),
|
||||
("Sindhi", "sd"),
|
||||
("Sinhalese", "si"),
|
||||
("Slovak", "sk"),
|
||||
("Slovenian", "sl"),
|
||||
("Somali", "so"),
|
||||
("Spanish", "es"),
|
||||
("Sundanese", "su"),
|
||||
("Swahili", "sw"),
|
||||
("Swedish", "sv"),
|
||||
("Tagalog", "tl"),
|
||||
("Tajik", "tg"),
|
||||
("Tajiki", "tg"),
|
||||
("Tamil", "ta"),
|
||||
("Tatar", "tt"),
|
||||
("Telugu", "te"),
|
||||
("Thai", "th"),
|
||||
("Tibetan", "bo"),
|
||||
("Tigrinya", "ti"),
|
||||
("Tongan", "to"),
|
||||
("Traditional Chinese", "zh_TW"),
|
||||
("Turkish", "tr"),
|
||||
("Turkmen", "tk"),
|
||||
("Uighur", "ug"),
|
||||
("Ukrainian", "uk"),
|
||||
("Urdu", "ur"),
|
||||
("Uzbek", "uz"),
|
||||
("Vietnamese", "vi"),
|
||||
("Welsh", "cy"),
|
||||
("Yiddish", "yi"),
|
||||
];
|
||||
|
||||
/// New-style language tags (ISO 639 + ISO 15924) to Unix names (`langtag_table`).
|
||||
pub(crate) const LANGTAG_TABLE: [(&str, &str); 16] = [
|
||||
("az-Latn", "az"),
|
||||
("bs-Latn", "bs"),
|
||||
("ga-dots", "ga"),
|
||||
("kk-Cyrl", "kk"),
|
||||
("mn-Cyrl", "mn"),
|
||||
("ms-Latn", "ms"),
|
||||
("pa-Arab", "pa_PK"),
|
||||
("pa-Guru", "pa_IN"),
|
||||
("sr-Cyrl", "sr"),
|
||||
("tg-Cyrl", "tg"),
|
||||
("tk-Cyrl", "tk"),
|
||||
("tt-Cyrl", "tt"),
|
||||
("uz-Latn", "uz"),
|
||||
("yue-Hans", "yue"),
|
||||
("zh-Hans", "zh_CN"),
|
||||
("zh-Hant", "zh_TW"),
|
||||
];
|
||||
|
||||
/// Script names (ISO 15924) to Unix conventions (`script_table`).
|
||||
pub(crate) const SCRIPT_TABLE: [(&str, &str); 4] = [
|
||||
("Arab", "arabic"),
|
||||
("Cyrl", "cyrillic"),
|
||||
("Latn", "latin"),
|
||||
("Mong", "mongolian"),
|
||||
];
|
||||
@@ -30,6 +30,7 @@ pub mod file;
|
||||
pub mod file_type;
|
||||
#[cfg(unix)]
|
||||
pub mod flatpak;
|
||||
pub mod osbytes;
|
||||
#[cfg(windows)]
|
||||
pub mod flatpak {
|
||||
//! Flatpak does not exist on Windows.
|
||||
@@ -45,6 +46,8 @@ pub mod homedir;
|
||||
pub mod hostname;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod i18n;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod i18n_canon_table;
|
||||
pub mod i18n_locales;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod kernel_info;
|
||||
@@ -73,10 +76,23 @@ pub mod passwd {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Byte-preserving entry (same shape as the Unix one).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct EntryOs {
|
||||
pub shell: Option<std::ffi::OsString>,
|
||||
pub home: Option<std::ffi::OsString>,
|
||||
pub name: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
/// No passwd database on Windows: always an empty entry.
|
||||
pub fn get() -> std::io::Result<Entry> {
|
||||
Ok(Entry::default())
|
||||
}
|
||||
|
||||
/// See [`get`].
|
||||
pub fn get_os() -> std::io::Result<EntryOs> {
|
||||
Ok(EntryOs::default())
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod path;
|
||||
@@ -123,13 +139,13 @@ pub use env::{
|
||||
get_env_map,
|
||||
getenv,
|
||||
getenv_not_empty,
|
||||
getenv_not_empty_os,
|
||||
getenv_os,
|
||||
prepend_env,
|
||||
prepend_env_os,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use file::alloc_tmp_dir;
|
||||
#[cfg(unix)]
|
||||
pub use file::{Rlimit, fix_max_files, restore_max_files};
|
||||
pub use file::{Rlimit, alloc_tmp_dir, alloc_tmp_dir_os, fix_max_files, restore_max_files};
|
||||
#[cfg(unix)]
|
||||
pub use flatpak::FlatpakHostCommand;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Byte-preserving conversions between OS strings and Rust `String`s.
|
||||
//!
|
||||
//! Zig treats paths, arguments and environment values as byte slices. Rust
|
||||
//! `String` cannot hold invalid UTF-8, so the String based layers (the config
|
||||
//! system) use a reversible encoding that is a true bijection on byte
|
||||
//! strings:
|
||||
//!
|
||||
//! * the escape character is the Unicode noncharacter `U+FDD0` (never used
|
||||
//! in real text);
|
||||
//! * every byte that is not part of a valid UTF-8 sequence (always
|
||||
//! `0x80..=0xFF`) is encoded as `U+FDD0` followed by the char `U+00bb`
|
||||
//! (`bb` = the byte), e.g. `0xFF` becomes `U+FDD0 U+00FF`;
|
||||
//! * a literal `U+FDD0` in valid input is escaped as `U+FDD0 U+FDD0`.
|
||||
//!
|
||||
//! Hence every valid UTF-8 string without `U+FDD0` is encoded unchanged, and
|
||||
//! `config_str_to_bytes(bytes_to_config_string(x)) == x` for all byte strings.
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
|
||||
const ESC: char = '\u{FDD0}';
|
||||
|
||||
/// Encode arbitrary bytes into a `String`, escaping invalid UTF-8 bytes.
|
||||
pub fn bytes_to_config_string(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len());
|
||||
for chunk in bytes.utf8_chunks() {
|
||||
for c in chunk.valid().chars() {
|
||||
out.push(c);
|
||||
if c == ESC {
|
||||
out.push(ESC);
|
||||
}
|
||||
}
|
||||
for &b in chunk.invalid() {
|
||||
out.push(ESC);
|
||||
out.push(char::from(b));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inverse of [`bytes_to_config_string`].
|
||||
pub fn config_str_to_bytes(s: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(s.len());
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == ESC {
|
||||
match chars.peek().copied() {
|
||||
Some(ESC) => {
|
||||
chars.next();
|
||||
}
|
||||
Some(n) if (0x80..=0xFF).contains(&(n as u32)) => {
|
||||
chars.next();
|
||||
out.push(n as u32 as u8);
|
||||
continue;
|
||||
}
|
||||
// Not produced by the encoder: keep the char as-is.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut buf = [0u8; 4];
|
||||
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The raw bytes of an OS string (Unix; lossless). On other platforms the
|
||||
/// string is converted via its UTF-8 (WTF-8) form.
|
||||
#[cfg(unix)]
|
||||
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
s.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
|
||||
s.to_string_lossy().into_owned().into_bytes()
|
||||
}
|
||||
|
||||
/// Build an OS string from raw bytes.
|
||||
#[cfg(unix)]
|
||||
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
OsString::from_vec(b)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
|
||||
OsString::from(String::from_utf8_lossy(&b).into_owned())
|
||||
}
|
||||
|
||||
/// OS string to reversible `String` (see the module docs).
|
||||
pub fn os_to_config_string(s: &OsStr) -> String {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
bytes_to_config_string(s.as_bytes())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
s.to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`os_to_config_string`].
|
||||
pub fn config_str_to_os(s: &str) -> OsString {
|
||||
os_from_bytes(config_str_to_bytes(s))
|
||||
}
|
||||
|
||||
/// Path to reversible `String` (see the module docs).
|
||||
pub fn path_to_config_string(p: &std::path::Path) -> String {
|
||||
os_to_config_string(p.as_os_str())
|
||||
}
|
||||
|
||||
/// Inverse of [`path_to_config_string`].
|
||||
pub fn config_str_to_path(s: &str) -> std::path::PathBuf {
|
||||
std::path::PathBuf::from(config_str_to_os(s))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_invalid_bytes() {
|
||||
let raw = vec![b'a', 0xff, b'b', 0xc3, 0xa9, 0xc3];
|
||||
let s = bytes_to_config_string(&raw);
|
||||
assert_eq!(s, "a\u{fdd0}\u{ff}b\u{e9}\u{fdd0}\u{c3}");
|
||||
assert_eq!(config_str_to_bytes(&s), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_utf8_unchanged() {
|
||||
let s = "h\u{e9}llo \u{1f600}";
|
||||
assert_eq!(bytes_to_config_string(s.as_bytes()), s);
|
||||
assert_eq!(config_str_to_bytes(s), s.as_bytes());
|
||||
// Nerd Font icons, including the old escape range, are untouched.
|
||||
let icons = "\u{f500}\u{f780}\u{f7ff}\u{fd46}\u{e0b0}";
|
||||
assert_eq!(bytes_to_config_string(icons.as_bytes()), icons);
|
||||
assert_eq!(config_str_to_bytes(icons), icons.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_escape_char_is_doubled() {
|
||||
let s = "a\u{fdd0}\u{ff}";
|
||||
let e = bytes_to_config_string(s.as_bytes());
|
||||
assert_eq!(e, "a\u{fdd0}\u{fdd0}\u{ff}");
|
||||
assert_eq!(config_str_to_bytes(&e), s.as_bytes());
|
||||
}
|
||||
|
||||
/// Tiny xorshift generator (no external rand dependency).
|
||||
fn rng(seed: &mut u64) -> u64 {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 7;
|
||||
*seed ^= *seed << 17;
|
||||
*seed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_bytes_round_trip() {
|
||||
// Fragments that stress the boundaries: icon ranges, the escape
|
||||
// char, its doubled form, and truncated/invalid sequences.
|
||||
let frags: [&[u8]; 12] = [
|
||||
"\u{f780}".as_bytes(),
|
||||
"\u{f7ff}".as_bytes(),
|
||||
"\u{f500}".as_bytes(),
|
||||
"\u{fd46}".as_bytes(),
|
||||
"\u{fdd0}".as_bytes(),
|
||||
"\u{fdd0}\u{fdd0}".as_bytes(),
|
||||
"\u{fdd0}\u{ff}".as_bytes(),
|
||||
"\u{1f600}".as_bytes(),
|
||||
b"\xef\xb7",
|
||||
b"\xff",
|
||||
b"\xc3",
|
||||
b"abc",
|
||||
];
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
for _ in 0..5000 {
|
||||
let mut x = Vec::new();
|
||||
for _ in 0..(rng(&mut seed) % 12) {
|
||||
match rng(&mut seed) % 3 {
|
||||
0 => x.push(rng(&mut seed) as u8),
|
||||
_ => x.extend_from_slice(frags[(rng(&mut seed) % 12) as usize]),
|
||||
}
|
||||
}
|
||||
let enc = bytes_to_config_string(&x);
|
||||
assert_eq!(config_str_to_bytes(&enc), x, "bytes {x:?}");
|
||||
if let Ok(valid) = std::str::from_utf8(&x)
|
||||
&& !valid.contains('\u{fdd0}')
|
||||
{
|
||||
assert_eq!(enc, valid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_valid_strings_round_trip() {
|
||||
// Every string (valid UTF-8) decodes back to itself after encoding.
|
||||
let mut seed = 0x1234_5678_9ABC_DEF1u64;
|
||||
for _ in 0..5000 {
|
||||
let mut s = String::new();
|
||||
for _ in 0..(rng(&mut seed) % 10) {
|
||||
let cp = match rng(&mut seed) % 4 {
|
||||
0 => 0xF500 + (rng(&mut seed) % 0x847) as u32,
|
||||
1 => 0xFDD0,
|
||||
2 => 0x80 + (rng(&mut seed) % 0x80) as u32,
|
||||
_ => 0x20 + (rng(&mut seed) % 0x5f) as u32,
|
||||
};
|
||||
s.push(char::from_u32(cp).unwrap());
|
||||
}
|
||||
let enc = bytes_to_config_string(s.as_bytes());
|
||||
assert_eq!(config_str_to_bytes(&enc), s.as_bytes(), "{s:?}");
|
||||
if !s.contains('\u{fdd0}') {
|
||||
assert_eq!(enc, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn os_roundtrip() {
|
||||
let o = os_from_bytes(vec![0xff, b'/', 0x80]);
|
||||
let s = os_to_config_string(&o);
|
||||
assert_eq!(config_str_to_os(&s), o);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
use std::ffi::OsString;
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
use crate::flatpak::is_flatpak;
|
||||
use crate::osbytes;
|
||||
|
||||
/// Entry that is retrieved from the passwd API. This only contains the fields
|
||||
/// we care about.
|
||||
@@ -10,19 +13,45 @@ pub struct Entry {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Get the passwd entry for the currently executing user.
|
||||
/// Byte-preserving [`Entry`] (Zig slices are arbitrary bytes).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct EntryOs {
|
||||
pub shell: Option<OsString>,
|
||||
pub home: Option<OsString>,
|
||||
pub name: Option<OsString>,
|
||||
}
|
||||
|
||||
impl From<EntryOs> for Entry {
|
||||
fn from(e: EntryOs) -> Self {
|
||||
let f = |v: Option<OsString>| v.map(|v| osbytes::os_to_config_string(&v));
|
||||
Entry {
|
||||
shell: f(e.shell),
|
||||
home: f(e.home),
|
||||
name: f(e.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the passwd entry for the currently executing user. Non-UTF-8 bytes
|
||||
/// are carried via the reversible [`crate::osbytes`] encoding; see
|
||||
/// [`get_os`] for the raw bytes.
|
||||
pub fn get() -> std::io::Result<Entry> {
|
||||
get_os().map(Entry::from)
|
||||
}
|
||||
|
||||
/// Byte-preserving [`get`].
|
||||
pub fn get_os() -> std::io::Result<EntryOs> {
|
||||
let uid = nix::unistd::getuid();
|
||||
let user = match nix::unistd::User::from_uid(uid) {
|
||||
Ok(Some(user)) => user,
|
||||
Ok(None) => {
|
||||
// Future: let's check if a better shell is available like zsh
|
||||
log::warn!("no pw entry to detect default shell, will default to 'sh'");
|
||||
return Ok(Entry::default());
|
||||
return Ok(EntryOs::default());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("error retrieving pw entry code={e}");
|
||||
return Ok(Entry::default());
|
||||
return Ok(EntryOs::default());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,10 +64,10 @@ pub fn get() -> std::io::Result<Entry> {
|
||||
return host_entry(&user.name);
|
||||
}
|
||||
|
||||
Ok(Entry {
|
||||
shell: Some(user.shell.to_string_lossy().into_owned()),
|
||||
home: Some(user.dir.to_string_lossy().into_owned()),
|
||||
name: Some(user.name),
|
||||
Ok(EntryOs {
|
||||
shell: Some(user.shell.into_os_string()),
|
||||
home: Some(user.dir.into_os_string()),
|
||||
name: Some(OsString::from(user.name)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,7 +78,7 @@ pub fn get() -> std::io::Result<Entry> {
|
||||
/// `Pty`) depends on this crate, so a minimal openpt/ptsname pair is opened
|
||||
/// here with rustix instead. Like Zig, only `shell` and `home` are filled in.
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
fn host_entry(name: &str) -> std::io::Result<Entry> {
|
||||
fn host_entry(name: &str) -> std::io::Result<EntryOs> {
|
||||
use std::io::Read;
|
||||
|
||||
use rustix::{
|
||||
@@ -112,17 +141,20 @@ fn host_entry(name: &str) -> std::io::Result<Entry> {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(parse_getent(&String::from_utf8_lossy(&output)))
|
||||
Ok(parse_getent(&output))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
/// Shell and home are the last two `:`-separated fields of the output.
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
fn parse_getent(output: &str) -> Entry {
|
||||
let mut it = output.trim_end_matches([' ', '\r', '\n']).rsplit(':');
|
||||
let shell = it.next().map(str::to_owned);
|
||||
let home = it.next().map(str::to_owned);
|
||||
Entry {
|
||||
fn parse_getent(output: &[u8]) -> EntryOs {
|
||||
let end = output
|
||||
.iter()
|
||||
.rposition(|b| !matches!(b, b' ' | b'\r' | b'\n'))
|
||||
.map_or(0, |i| i + 1);
|
||||
let mut it = output[..end].rsplit(|&b| b == b':');
|
||||
let shell = it.next().map(|b| osbytes::os_from_bytes(b.to_vec()));
|
||||
let home = it.next().map(|b| osbytes::os_from_bytes(b.to_vec()));
|
||||
EntryOs {
|
||||
shell,
|
||||
home,
|
||||
name: None,
|
||||
@@ -136,12 +168,24 @@ mod tests {
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
#[test]
|
||||
fn parse_getent_output() {
|
||||
let e = parse_getent("mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n");
|
||||
assert_eq!(e.shell.as_deref(), Some("/bin/zsh"));
|
||||
assert_eq!(e.home.as_deref(), Some("/home/mitchell"));
|
||||
let e = parse_getent(b"mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n");
|
||||
assert_eq!(e.shell.as_deref(), Some(std::ffi::OsStr::new("/bin/zsh")));
|
||||
assert_eq!(
|
||||
e.home.as_deref(),
|
||||
Some(std::ffi::OsStr::new("/home/mitchell"))
|
||||
);
|
||||
assert_eq!(e.name, None);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
#[test]
|
||||
fn parse_getent_keeps_non_utf8() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let e = parse_getent(b"u:x:1:1::/home/\xff:/bin/\xfesh\n");
|
||||
assert_eq!(e.shell.unwrap().as_bytes(), b"/bin/\xfesh");
|
||||
assert_eq!(e.home.unwrap().as_bytes(), b"/home/\xff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_entry() {
|
||||
// We should be able to get an entry
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::io;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::{ffi::OsStr, io, path::PathBuf};
|
||||
|
||||
/// `std.fs.path.delimiter` / `sep`.
|
||||
#[cfg(windows)]
|
||||
@@ -25,28 +25,44 @@ pub enum Error {
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
/// Search for "cmd" in the PATH and return the absolute path.
|
||||
/// Search for "cmd" in the PATH and return the absolute path. Non-UTF-8
|
||||
/// bytes are carried via [`crate::osbytes`]; see [`expand_os`].
|
||||
pub fn expand(cmd: &str) -> Result<Option<String>, Error> {
|
||||
let cmd = crate::osbytes::config_str_to_os(cmd);
|
||||
Ok(expand_os(&cmd)?.map(|p| crate::osbytes::os_to_config_string(p.as_os_str())))
|
||||
}
|
||||
|
||||
/// Byte-preserving [`expand`]: `$PATH` and the command are handled as raw
|
||||
/// bytes, so executables in directories with non-UTF-8 names are found.
|
||||
pub fn expand_os(cmd: &OsStr) -> Result<Option<PathBuf>, Error> {
|
||||
let cmd_bytes = crate::osbytes::os_bytes(cmd);
|
||||
|
||||
// If the command already contains a slash, then we return it as-is
|
||||
// because it is assumed to be absolute or relative.
|
||||
if cmd.contains('/') {
|
||||
return Ok(Some(cmd.to_owned()));
|
||||
if cmd_bytes.contains(&b'/') {
|
||||
return Ok(Some(PathBuf::from(cmd)));
|
||||
}
|
||||
|
||||
let Some(path_var) = std::env::var_os("PATH") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path_var = path_var.to_string_lossy();
|
||||
let path_var = crate::osbytes::os_bytes(&path_var);
|
||||
|
||||
let mut seen_eacces = false;
|
||||
for search_path in path_var.split(DELIMITER).filter(|s| !s.is_empty()) {
|
||||
for search_path in path_var
|
||||
.split(|&b| b == DELIMITER as u8)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
// We need enough space in our path buffer to store this
|
||||
let path_len = search_path.len() + cmd.len() + 1;
|
||||
let path_len = search_path.len() + cmd_bytes.len() + 1;
|
||||
if MAX_PATH_BYTES < path_len {
|
||||
return Err(Error::PathTooLong);
|
||||
}
|
||||
|
||||
let full_path = format!("{search_path}{SEP}{cmd}");
|
||||
let mut full = search_path.to_vec();
|
||||
full.push(SEP as u8);
|
||||
full.extend_from_slice(&cmd_bytes);
|
||||
let full_path = PathBuf::from(crate::osbytes::os_from_bytes(full));
|
||||
|
||||
// Stat it
|
||||
let f = match std::fs::File::open(&full_path) {
|
||||
@@ -127,4 +143,23 @@ mod tests {
|
||||
assert!(expand("adir").unwrap().is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn expand_finds_executable_in_non_utf8_dir() {
|
||||
use std::os::unix::{
|
||||
ffi::{OsStrExt, OsStringExt},
|
||||
fs::PermissionsExt,
|
||||
};
|
||||
let td = crate::TempDir::init().unwrap();
|
||||
let dir = td.path().join(OsStr::from_bytes(b"d\xffir"));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let exe = dir.join("mytool");
|
||||
std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let pv = std::ffi::OsString::from_vec(dir.as_os_str().as_bytes().to_vec());
|
||||
temp_env::with_var("PATH", Some(&pv), || {
|
||||
assert_eq!(expand_os(OsStr::new("mytool")).unwrap(), Some(exe.clone()));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
use std::{io, path::Path};
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::path_join;
|
||||
use crate::osbytes::{config_str_to_os, os_to_config_string};
|
||||
|
||||
fn enc(p: &Path) -> String {
|
||||
os_to_config_string(p.as_os_str())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ResourcesDir {
|
||||
@@ -25,6 +33,17 @@ impl ResourcesDir {
|
||||
pub fn host(&self) -> Option<&str> {
|
||||
self.host_path.as_deref().or(self.app_path.as_deref())
|
||||
}
|
||||
|
||||
/// Byte-preserving [`ResourcesDir::app`]: the string fields carry
|
||||
/// non-UTF-8 bytes via [`crate::osbytes`], this decodes them.
|
||||
pub fn app_os(&self) -> Option<PathBuf> {
|
||||
self.app().map(|p| PathBuf::from(config_str_to_os(p)))
|
||||
}
|
||||
|
||||
/// Byte-preserving [`ResourcesDir::host`].
|
||||
pub fn host_os(&self) -> Option<PathBuf> {
|
||||
self.host().map(|p| PathBuf::from(config_str_to_os(p)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentinels we look for in the path to know we've found the resources
|
||||
@@ -50,7 +69,7 @@ const DEV_SENTINELS: [&str; 1] = ["terminfo/x/xterm-ghostty"];
|
||||
/// Zig's `builtin.mode == .Debug` is mapped to `cfg!(debug_assertions)`.
|
||||
pub fn resources_dir() -> io::Result<ResourcesDir> {
|
||||
let exe = std::env::current_exe().ok();
|
||||
let env = std::env::var("GHOSTTY_RESOURCES_DIR").ok();
|
||||
let env = std::env::var_os("GHOSTTY_RESOURCES_DIR");
|
||||
Ok(resources_dir_impl(
|
||||
cfg!(debug_assertions),
|
||||
exe.as_deref(),
|
||||
@@ -58,7 +77,7 @@ pub fn resources_dir() -> io::Result<ResourcesDir> {
|
||||
))
|
||||
}
|
||||
|
||||
fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> ResourcesDir {
|
||||
fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<OsString>) -> ResourcesDir {
|
||||
// Use the GHOSTTY_RESOURCES_DIR environment variable in release builds.
|
||||
//
|
||||
// In debug builds we try using terminfo detection first instead, since
|
||||
@@ -67,7 +86,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
||||
// freshly built ones under zig-out/share/ghostty.
|
||||
if !debug && let Some(dir) = env.as_ref().filter(|d| !d.is_empty()) {
|
||||
return ResourcesDir {
|
||||
app_path: Some(dir.clone()),
|
||||
app_path: Some(os_to_config_string(dir)),
|
||||
host_path: None,
|
||||
};
|
||||
}
|
||||
@@ -82,14 +101,12 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
||||
// bundle as we expect it.
|
||||
while let Some(dir) = exe.parent() {
|
||||
exe = dir;
|
||||
let dir_str = dir.to_string_lossy();
|
||||
|
||||
// On MacOS, we look for the app bundle path.
|
||||
#[cfg(target_vendor = "apple")]
|
||||
for sentinel in SENTINELS {
|
||||
if let Some(v) = maybe_dir(&dir_str, "Contents/Resources", sentinel) {
|
||||
if let Some(v) = maybe_dir_os(dir, "Contents/Resources", sentinel) {
|
||||
return ResourcesDir {
|
||||
app_path: Some(path_join(&[&v, "ghostty"])),
|
||||
app_path: Some(enc(&v.join("ghostty"))),
|
||||
host_path: None,
|
||||
};
|
||||
}
|
||||
@@ -97,9 +114,9 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
||||
|
||||
// We look for a /usr/share style path.
|
||||
for sentinel in SENTINELS {
|
||||
if let Some(v) = maybe_dir(&dir_str, "share", sentinel) {
|
||||
if let Some(v) = maybe_dir_os(dir, "share", sentinel) {
|
||||
return ResourcesDir {
|
||||
app_path: Some(path_join(&[&v, "ghostty"])),
|
||||
app_path: Some(enc(&v.join("ghostty"))),
|
||||
host_path: None,
|
||||
};
|
||||
}
|
||||
@@ -110,7 +127,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
||||
// fallback and use the provided resources dir.
|
||||
if debug && let Some(dir) = env.filter(|d| !d.is_empty()) {
|
||||
return ResourcesDir {
|
||||
app_path: Some(dir),
|
||||
app_path: Some(os_to_config_string(&dir)),
|
||||
host_path: None,
|
||||
};
|
||||
}
|
||||
@@ -122,7 +139,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
||||
// lives inside this workspace.
|
||||
if debug && let Some(dir) = dev_resources_dir(exe_start) {
|
||||
return ResourcesDir {
|
||||
app_path: Some(dir),
|
||||
app_path: Some(enc(&dir)),
|
||||
host_path: None,
|
||||
};
|
||||
}
|
||||
@@ -137,7 +154,7 @@ fn workspace_root() -> std::path::PathBuf {
|
||||
|
||||
/// Returns `<workspace>/resources` if `exe` is inside the workspace this
|
||||
/// crate was built from and the directory contains our sentinel.
|
||||
fn dev_resources_dir(exe: &Path) -> Option<String> {
|
||||
fn dev_resources_dir(exe: &Path) -> Option<PathBuf> {
|
||||
let root = workspace_root().canonicalize().ok()?;
|
||||
let exe = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
|
||||
if !exe.starts_with(&root) {
|
||||
@@ -148,7 +165,15 @@ fn dev_resources_dir(exe: &Path) -> Option<String> {
|
||||
.iter()
|
||||
.chain(DEV_SENTINELS.iter())
|
||||
.any(|s| res.join(s).exists())
|
||||
.then(|| res.to_string_lossy().into_owned())
|
||||
.then_some(res)
|
||||
}
|
||||
|
||||
/// Byte-preserving [`maybe_dir`].
|
||||
pub fn maybe_dir_os(base: &Path, sub: &str, suffix: &str) -> Option<PathBuf> {
|
||||
let dir = base.join(sub);
|
||||
// Folder doesn't exist. If a different error happens its okay
|
||||
// we just ignore it and move on.
|
||||
dir.join(suffix).exists().then_some(dir)
|
||||
}
|
||||
|
||||
/// Little helper to check if the "base/sub/suffix" directory exists and
|
||||
@@ -204,7 +229,7 @@ mod tests {
|
||||
fn env_in_release_wins_and_in_debug_is_fallback() {
|
||||
let td = make_tree();
|
||||
let exe = td.path().join("bin/ghostty");
|
||||
let env = Some("/env/res".to_owned());
|
||||
let env = Some(OsString::from("/env/res"));
|
||||
assert_eq!(
|
||||
resources_dir_impl(false, Some(&exe), env.clone()).app(),
|
||||
Some("/env/res")
|
||||
@@ -220,7 +245,7 @@ mod tests {
|
||||
);
|
||||
// Empty env is ignored.
|
||||
assert_eq!(
|
||||
resources_dir_impl(false, None, Some(String::new())).app(),
|
||||
resources_dir_impl(false, None, Some(OsString::new())).app(),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -238,6 +263,23 @@ mod tests {
|
||||
assert_eq!(r.app(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_utf8_env_dir_is_honored_and_exe_dir_detected() {
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
let env = OsString::from_vec(b"/env/\xffres".to_vec());
|
||||
let r = resources_dir_impl(false, None, Some(env.clone()));
|
||||
assert_eq!(r.app_os().unwrap().as_os_str(), env.as_os_str());
|
||||
|
||||
// Executable lives under a directory with a non-UTF-8 name.
|
||||
let td = crate::TempDir::init().unwrap();
|
||||
let root = td.path().join(std::ffi::OsStr::from_bytes(b"r\xfeoot"));
|
||||
std::fs::create_dir_all(root.join("share/terminfo/x")).unwrap();
|
||||
std::fs::write(root.join("share/terminfo/x/xterm-ghostty"), b"").unwrap();
|
||||
std::fs::create_dir_all(root.join("bin")).unwrap();
|
||||
let r = resources_dir_impl(true, Some(&root.join("bin/ghostty")), None);
|
||||
assert_eq!(r.app_os().unwrap(), root.join("share/ghostty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_dir_checks_suffix() {
|
||||
let td = make_tree();
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::file::alloc_tmp_dir;
|
||||
use crate::file::alloc_tmp_dir_os;
|
||||
|
||||
// The amount of random bytes to get to determine our filename.
|
||||
const RANDOM_BYTES: usize = 16;
|
||||
@@ -89,8 +89,8 @@ fn fill_random(buf: &mut [u8]) -> io::Result<()> {
|
||||
impl TempDir {
|
||||
/// Create the temporary directory.
|
||||
pub fn init() -> io::Result<TempDir> {
|
||||
let parent = match alloc_tmp_dir() {
|
||||
Some(p) => PathBuf::from(p),
|
||||
let parent = match alloc_tmp_dir_os() {
|
||||
Some(p) => p,
|
||||
None => std::env::current_dir()?,
|
||||
};
|
||||
// Verify the parent can be opened (Zig opens it as a Dir).
|
||||
|
||||
Reference in new issue
Block a user