refactor: type escaped OS strings and fix the final audit findings
Escaped config strings (ghostty_os::osbytes) kept reaching OS boundaries undecoded, and decoding was occasionally applied to plain text. The root cause was that both were plain `String`s. Now: - `EncodedOs` is a newtype (no Display/AsRef<Path>) that decodes only via to_os/to_path/to_bytes/to_wide; plain text enters via from_plain_text. OS helpers (env, home, xdg, passwd, resources dir, path expansion), config::Path, WorkingDirectory, theme/file_load/edit paths, terminal pwd, OpenUrl/Pwd actions and termio cwd use it, so the compiler forces decoding at every boundary. os::open takes &OsStr. Config output is unchanged. Fixes all rows of docs/rewrite/notes/review-rv5-codex.md (final-Z7.md): screen-file copy; regex/OSC 8 URLs no longer decoded (regression); lossless Windows WTF-8/WTF-16 for args, env and CreateProcessW; Windows resources dir/TERMINFO; color OSC request lists and X11 color lookup no longer allocate globally (C zero-allocation test extended); macOS Mach handler resumes all threads before KERN_FAILURE (hang regression) and SIGABRT minidumps go through the exception thread; plus open-config, background images, crash dir, ssh cache, exe dir/PATH, fontconfig filenames, file links, kitty TMPDIR, .hushlogin and benchmark paths. The typing also surfaced and fixed gtk-custom-css, bell audio, bash HISTFILE, OSC 52 writes and the C config getters. Test Plan: - cargo test --workspace (3376 passed) - just check-macos (both), just check-windows, just check-wasm - diff <(ghostty-rs +show-config --default --docs) \ <(ghostty +show-config --default --docs)
This commit is contained in:
1 parent
0f898d3e41
commit
8f3e206d9f
92 files changed
+2967
-992
No files matched your search
@@ -259,13 +259,13 @@ pub fn getenv_not_empty_os(key: &str) -> Option<OsString> {
|
||||
/// 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> {
|
||||
getenv_os(key).map(|v| crate::osbytes::os_to_config_string(&v))
|
||||
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<String> {
|
||||
pub fn getenv_not_empty(key: &str) -> Option<crate::osbytes::EncodedOs> {
|
||||
getenv(key).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
@@ -324,7 +324,10 @@ mod tests {
|
||||
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_eq!(
|
||||
crate::osbytes::config_str_to_bytes(s.as_encoded()),
|
||||
vec![b'a', 0xff]
|
||||
);
|
||||
assert!(getenv_not_empty("GHOSTTY_RS_T3").is_some());
|
||||
});
|
||||
}
|
||||
@@ -361,9 +364,9 @@ mod tests {
|
||||
temp_env::with_vars(
|
||||
[("GHOSTTY_RS_T1", Some("")), ("GHOSTTY_RS_T2", Some("v"))],
|
||||
|| {
|
||||
assert_eq!(getenv("GHOSTTY_RS_T1").as_deref(), Some(""));
|
||||
assert_eq!(getenv("GHOSTTY_RS_T1").unwrap(), "");
|
||||
assert_eq!(getenv_not_empty("GHOSTTY_RS_T1"), None);
|
||||
assert_eq!(getenv_not_empty("GHOSTTY_RS_T2").as_deref(), Some("v"));
|
||||
assert_eq!(getenv_not_empty("GHOSTTY_RS_T2").unwrap(), "v");
|
||||
assert_eq!(getenv_not_empty("GHOSTTY_RS_NOPE"), None);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -101,12 +101,6 @@ pub fn alloc_tmp_dir_os() -> Option<std::path::PathBuf> {
|
||||
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))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -115,13 +109,13 @@ mod tests {
|
||||
#[test]
|
||||
fn tmp_dir_env_precedence() {
|
||||
temp_env::with_vars([("TMPDIR", Some("/a")), ("TMP", Some("/b"))], || {
|
||||
assert_eq!(alloc_tmp_dir().as_deref(), Some("/a"));
|
||||
assert_eq!(alloc_tmp_dir_os(), Some("/a".into()));
|
||||
});
|
||||
temp_env::with_vars([("TMPDIR", None), ("TMP", Some("/b"))], || {
|
||||
assert_eq!(alloc_tmp_dir().as_deref(), Some("/b"));
|
||||
assert_eq!(alloc_tmp_dir_os(), Some("/b".into()));
|
||||
});
|
||||
temp_env::with_vars([("TMPDIR", None::<&str>), ("TMP", None)], || {
|
||||
assert_eq!(alloc_tmp_dir().as_deref(), Some("/tmp"));
|
||||
assert_eq!(alloc_tmp_dir_os(), Some("/tmp".into()));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ pub struct FlatpakHostCommand {
|
||||
|
||||
/// The cwd for the new process. If this is not set then it will use
|
||||
/// the current cwd of the calling process.
|
||||
pub cwd: Option<String>,
|
||||
pub cwd: Option<crate::osbytes::EncodedOs>,
|
||||
|
||||
/// Environment variables for the child process. If this is None, this
|
||||
/// does not send any environment variables.
|
||||
@@ -298,15 +298,15 @@ impl FlatpakSignalHandle {
|
||||
|
||||
/// Decode a reversible config string to the original bytes plus NUL, the
|
||||
/// D-Bus `ay` form of a path/argument.
|
||||
fn nul_terminated_bytes(s: &str) -> Vec<u8> {
|
||||
let mut v = crate::osbytes::config_str_to_bytes(s);
|
||||
fn nul_terminated_bytes(s: &crate::osbytes::EncodedOs) -> Vec<u8> {
|
||||
let mut v = s.to_bytes();
|
||||
v.push(0);
|
||||
v
|
||||
}
|
||||
|
||||
struct Request {
|
||||
argv: Vec<String>,
|
||||
cwd: Option<String>,
|
||||
cwd: Option<crate::osbytes::EncodedOs>,
|
||||
env: HashMap<String, String>,
|
||||
fds: [OwnedFd; 3],
|
||||
}
|
||||
@@ -443,8 +443,8 @@ fn wait_for_exit(shared: &Shared, iter: MessageIterator, pid: u32) {
|
||||
mod tests {
|
||||
#[test]
|
||||
fn dbus_bytes_are_decoded() {
|
||||
let enc = crate::osbytes::bytes_to_config_string(b"/tmp/\xff\xfe");
|
||||
assert_ne!(enc.as_bytes(), b"/tmp/\xff\xfe");
|
||||
let enc = crate::osbytes::EncodedOs::from_bytes(b"/tmp/\xff\xfe");
|
||||
assert_ne!(enc.as_encoded().as_bytes(), b"/tmp/\xff\xfe");
|
||||
assert_eq!(super::nul_terminated_bytes(&enc), b"/tmp/\xff\xfe\0");
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const MAX_PATH_BYTES: usize = 4096;
|
||||
///
|
||||
/// Non-UTF-8 bytes are preserved through [`crate::osbytes`]; use
|
||||
/// [`home_os`] for the raw OS string.
|
||||
pub fn home() -> Result<Option<String>, Error> {
|
||||
pub fn home() -> Result<Option<osbytes::EncodedOs>, Error> {
|
||||
home_with_capacity(MAX_PATH_BYTES)
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ pub fn home_os() -> Result<Option<OsString>, Error> {
|
||||
|
||||
/// 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)))
|
||||
pub fn home_with_capacity(cap: usize) -> Result<Option<osbytes::EncodedOs>, Error> {
|
||||
Ok(home_os_with_capacity(cap)?.map(|h| osbytes::EncodedOs::from_os(&h)))
|
||||
}
|
||||
|
||||
/// Byte-preserving [`home_with_capacity`].
|
||||
@@ -98,25 +98,29 @@ pub fn home_os_with_capacity(cap: usize) -> Result<Option<OsString>, Error> {
|
||||
/// 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))
|
||||
let drive = std::env::var_os("HOMEDRIVE");
|
||||
let path = std::env::var_os("HOMEPATH");
|
||||
home_windows(drive.as_deref(), path.as_deref(), cap)
|
||||
}
|
||||
|
||||
/// Pure part of [`home_with_capacity`] on Windows.
|
||||
#[cfg(any(windows, test))]
|
||||
fn home_windows(
|
||||
drive: Option<&str>,
|
||||
path: Option<&str>,
|
||||
drive: Option<&OsStr>,
|
||||
path: Option<&OsStr>,
|
||||
cap: usize,
|
||||
) -> Result<Option<String>, Error> {
|
||||
) -> Result<Option<OsString>, Error> {
|
||||
let (Some(drive), Some(path)) = (drive, path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if cap < drive.len() + path.len() {
|
||||
// Lossless (WTF-16 safe) concatenation; the size is measured in WTF-8
|
||||
// bytes like the Zig buffer.
|
||||
if cap < osbytes::os_bytes(drive).len() + osbytes::os_bytes(path).len() {
|
||||
return Err(Error::BufferTooSmall);
|
||||
}
|
||||
Ok(Some(format!("{drive}{path}")))
|
||||
let mut home = drive.to_owned();
|
||||
home.push(path);
|
||||
Ok(Some(home))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -135,7 +139,7 @@ fn trim_space(input: &[u8]) -> &[u8] {
|
||||
///
|
||||
/// 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> {
|
||||
pub fn expand_home(path: &osbytes::EncodedOs) -> Result<osbytes::EncodedOs, ExpandError> {
|
||||
expand_home_with_capacity(path, MAX_PATH_BYTES)
|
||||
}
|
||||
|
||||
@@ -146,9 +150,11 @@ pub fn expand_home_os(path: &OsStr) -> Result<OsString, ExpandError> {
|
||||
|
||||
/// 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))
|
||||
pub fn expand_home_with_capacity(
|
||||
path: &osbytes::EncodedOs,
|
||||
cap: usize,
|
||||
) -> Result<osbytes::EncodedOs, ExpandError> {
|
||||
expand_home_os_with_capacity(&path.to_os(), cap).map(osbytes::EncodedOs::from)
|
||||
}
|
||||
|
||||
/// Windows: `~/` is not an idiom generally used on Windows.
|
||||
@@ -183,13 +189,17 @@ mod tests {
|
||||
#[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()))
|
||||
home_windows(
|
||||
Some(OsStr::new("C:")),
|
||||
Some(OsStr::new("\\Users\\me")),
|
||||
4096
|
||||
),
|
||||
Ok(Some(OsString::from("C:\\Users\\me")))
|
||||
);
|
||||
assert_eq!(home_windows(None, Some("\\x"), 4096), Ok(None));
|
||||
assert_eq!(home_windows(Some("C:"), None, 4096), Ok(None));
|
||||
assert_eq!(home_windows(None, Some(OsStr::new("\\x")), 4096), Ok(None));
|
||||
assert_eq!(home_windows(Some(OsStr::new("C:")), None, 4096), Ok(None));
|
||||
assert_eq!(
|
||||
home_windows(Some("C:"), Some("\\Users"), 3),
|
||||
home_windows(Some(OsStr::new("C:")), Some(OsStr::new("\\Users")), 3),
|
||||
Err(Error::BufferTooSmall)
|
||||
);
|
||||
}
|
||||
@@ -204,28 +214,30 @@ mod tests_unix {
|
||||
fn expand_home_unix() {
|
||||
with_env_lock(|| {
|
||||
let cap = MAX_PATH_BYTES;
|
||||
let home_dir = expand_home_with_capacity("~/", cap).unwrap();
|
||||
let ex =
|
||||
|s: &str| expand_home_with_capacity(&osbytes::EncodedOs::from_plain_text(s), cap);
|
||||
let home_dir = ex("~/").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('/'));
|
||||
assert!(home_dir.ends_with_plain("/"));
|
||||
|
||||
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);
|
||||
let downloads = ex("~/Downloads/shader.glsl").unwrap();
|
||||
let expected_downloads = format!("{}Downloads/shader.glsl", home_dir.as_encoded());
|
||||
assert_eq!(downloads, expected_downloads.as_str());
|
||||
|
||||
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());
|
||||
assert_eq!(ex("~").unwrap(), "~");
|
||||
assert_eq!(ex("~abc/").unwrap(), "~abc/");
|
||||
assert_eq!(ex("/home/user").unwrap(), "/home/user");
|
||||
assert_eq!(ex("").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())
|
||||
expand_home_with_capacity(
|
||||
&osbytes::EncodedOs::from_plain_text("~/Downloads"),
|
||||
home_dir.as_encoded().len()
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -248,16 +260,19 @@ mod tests_unix {
|
||||
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");
|
||||
let s = expand_home(&osbytes::EncodedOs::from_plain_text("~/y")).unwrap();
|
||||
assert_eq!(s.to_bytes(), 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().unwrap().unwrap(), "/some/home");
|
||||
assert_eq!(
|
||||
expand_home(&osbytes::EncodedOs::from_plain_text("~/x")).unwrap(),
|
||||
"/some/home/x"
|
||||
);
|
||||
assert_eq!(home_with_capacity(3), Err(Error::BufferTooSmall));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,19 +27,18 @@ pub enum InitError {
|
||||
/// used by libghostty.
|
||||
#[cfg(windows)]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
pub fn init(_resources_dir: &str) -> Result<(), InitError> {
|
||||
pub fn init(_resources_dir: &std::path::Path) -> Result<(), InitError> {
|
||||
// i18n is unsupported on Windows
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// See the Windows variant above.
|
||||
#[cfg(not(windows))]
|
||||
pub fn init(resources_dir: &str) -> Result<(), InitError> {
|
||||
pub fn init(resources_dir: &std::path::Path) -> Result<(), InitError> {
|
||||
// Our resources dir is always nested below the share dir that
|
||||
// is standard for translations.
|
||||
// (`std.fs.path.dirname` returns null for a bare relative name, where
|
||||
// `Path::parent` returns an empty path.)
|
||||
let resources_dir = crate::osbytes::config_str_to_path(resources_dir);
|
||||
let share_dir = resources_dir
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
@@ -248,10 +247,13 @@ mod tests {
|
||||
#[test]
|
||||
fn init_rejects_dirless_resources_dir() {
|
||||
assert!(matches!(
|
||||
init("ghostty"),
|
||||
init(std::path::Path::new("ghostty")),
|
||||
Err(InitError::InvalidResourcesDir)
|
||||
));
|
||||
assert!(matches!(
|
||||
init(std::path::Path::new("/")),
|
||||
Err(InitError::InvalidResourcesDir)
|
||||
));
|
||||
assert!(matches!(init("/"), Err(InitError::InvalidResourcesDir)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -145,7 +145,7 @@ pub use env::{
|
||||
prepend_env_os,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use file::{Rlimit, alloc_tmp_dir, alloc_tmp_dir_os, fix_max_files, restore_max_files};
|
||||
pub use file::{Rlimit, alloc_tmp_dir_os, fix_max_files, restore_max_files};
|
||||
#[cfg(unix)]
|
||||
pub use flatpak::FlatpakHostCommand;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
ffi::{OsStr, OsString},
|
||||
ffi::OsStr,
|
||||
io,
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
@@ -18,9 +18,10 @@ pub enum OpenUrlKind {
|
||||
|
||||
/// Open a URL in the default handling application (via `xdg-open`).
|
||||
///
|
||||
/// `url` is the core's "config string" form (see [`crate::osbytes`]): a
|
||||
/// non-UTF-8 path is carried in an escaped form and is decoded to the real
|
||||
/// path bytes here, right before it is handed to the opener.
|
||||
/// `url` is the exact OS string to hand to the opener. Callers holding an
|
||||
/// [`EncodedOs`](crate::osbytes::EncodedOs) decode it explicitly
|
||||
/// (`to_os`); plain text (a regex link, an OSC 8 URI) is wrapped with
|
||||
/// `EncodedOs::from_plain_text` first, so it is never decoded by mistake.
|
||||
///
|
||||
/// Any output on stderr is logged as a warning in the application logs.
|
||||
/// Output on stdout is ignored.
|
||||
@@ -29,9 +30,8 @@ pub enum OpenUrlKind {
|
||||
/// some portable way to open URLs. If you are implementing an
|
||||
/// apprt for Ghostty, you should consider doing something special-cased
|
||||
/// for your platform.
|
||||
pub fn open(kind: OpenUrlKind, url: &str) -> io::Result<()> {
|
||||
let url: OsString = crate::osbytes::config_str_to_os(url);
|
||||
let mut cmd = open_command(kind, &url)?;
|
||||
pub fn open(kind: OpenUrlKind, url: &OsStr) -> io::Result<()> {
|
||||
let mut cmd = open_command(kind, url)?;
|
||||
|
||||
// Pipe stdout/stderr so we can collect output from the command.
|
||||
cmd.stdin(Stdio::null())
|
||||
@@ -141,12 +141,20 @@ mod tests {
|
||||
assert_eq!(macos_open_args(OpenUrlKind::Unknown, u), ["u"]);
|
||||
}
|
||||
|
||||
/// The opener receives decoded bytes for encoded paths and the text
|
||||
/// unchanged for plain URLs (even when they contain the escape char).
|
||||
#[test]
|
||||
fn encoded_non_utf8_path_is_decoded_for_the_opener() {
|
||||
fn encoded_paths_decode_but_plain_urls_do_not() {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
let enc = crate::osbytes::bytes_to_config_string(b"/tmp/a\xffb");
|
||||
let os = crate::osbytes::config_str_to_os(&enc);
|
||||
assert_eq!(os.as_bytes(), b"/tmp/a\xffb");
|
||||
|
||||
use crate::osbytes::EncodedOs;
|
||||
let path = EncodedOs::from_bytes(b"/tmp/a\xffb");
|
||||
assert_eq!(path.to_os().as_bytes(), b"/tmp/a\xffb");
|
||||
|
||||
// U+FDD0 U+00FF in an ordinary URL must stay two chars, not byte FF.
|
||||
let url = "https://x/\u{fdd0}\u{ff}";
|
||||
let plain = EncodedOs::from_plain_text(url);
|
||||
assert_eq!(plain.to_os().as_bytes(), url.as_bytes());
|
||||
}
|
||||
|
||||
#[cfg(not(target_vendor = "apple"))]
|
||||
@@ -155,7 +163,7 @@ mod tests {
|
||||
// With an empty PATH, xdg-open can't be found; Zig detects this
|
||||
// synchronously and so do we.
|
||||
temp_env::with_var("PATH", Some("/nonexistent-dir"), || {
|
||||
let err = open(OpenUrlKind::Unknown, "https://example.com").unwrap_err();
|
||||
let err = open(OpenUrlKind::Unknown, OsStr::new("https://example.com")).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,52 +62,127 @@ pub fn config_str_to_bytes(s: &str) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The raw bytes of an OS string (Unix; lossless). On other platforms the
|
||||
/// string is converted via its UTF-8 (WTF-8) form.
|
||||
/// The raw bytes of an OS string. Lossless on every platform: Unix returns
|
||||
/// the bytes, Windows returns WTF-8 (unpaired UTF-16 surrogates are encoded
|
||||
/// as 3-byte sequences, like Zig's Windows argument/environment handling).
|
||||
#[cfg(unix)]
|
||||
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
s.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
|
||||
s.to_string_lossy().into_owned().into_bytes()
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
wide_to_wtf8(&s.encode_wide().collect::<Vec<u16>>())
|
||||
}
|
||||
|
||||
/// Build an OS string from raw bytes.
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
|
||||
s.to_str().map_or_else(Vec::new, |s| s.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// Build an OS string from raw bytes (WTF-8 on Windows; bytes that are not
|
||||
/// well-formed WTF-8 become U+FFFD, like Zig's `wtf8ToWtf16Le`/`utf8ToUtf16`).
|
||||
#[cfg(unix)]
|
||||
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
OsString::from_vec(b)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
#[allow(clippy::needless_pass_by_value)] // same signature as the Unix variant
|
||||
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
|
||||
// Windows OS strings are WTF-16; valid UTF-8 converts losslessly and is
|
||||
// reused without copying, anything else is replaced like Zig's utf8ToUtf16.
|
||||
OsString::from(
|
||||
String::from_utf8(b).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()),
|
||||
)
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
OsString::from_wide(&wtf8_to_wide(&b))
|
||||
}
|
||||
|
||||
/// OS string to reversible `String` (see the module docs).
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
|
||||
OsString::from(String::from_utf8_lossy(&b).into_owned())
|
||||
}
|
||||
|
||||
/// Encode UTF-16 code units (possibly with unpaired surrogates) as WTF-8.
|
||||
/// Platform independent so the Windows logic is testable everywhere.
|
||||
pub fn wide_to_wtf8(wide: &[u16]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(wide.len());
|
||||
for r in char::decode_utf16(wide.iter().copied()) {
|
||||
match r {
|
||||
Ok(c) => {
|
||||
let mut buf = [0u8; 4];
|
||||
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
|
||||
}
|
||||
Err(e) => {
|
||||
let u = u32::from(e.unpaired_surrogate());
|
||||
out.push(0xE0 | (u >> 12) as u8);
|
||||
out.push(0x80 | ((u >> 6) & 0x3F) as u8);
|
||||
out.push(0x80 | (u & 0x3F) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode WTF-8 into UTF-16 code units. Surrogate code points encoded as
|
||||
/// 3-byte sequences are kept as lone surrogates; anything not well-formed
|
||||
/// WTF-8 yields U+FFFD for each offending byte.
|
||||
pub fn wtf8_to_wide(b: &[u8]) -> Vec<u16> {
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
let cont = |j: usize| {
|
||||
b.get(j)
|
||||
.filter(|c| (**c & 0xC0) == 0x80)
|
||||
.map(|c| u32::from(*c & 0x3F))
|
||||
};
|
||||
while i < b.len() {
|
||||
let lead = b[i];
|
||||
let (cp, len) = match lead {
|
||||
0x00..=0x7F => (u32::from(lead), 1),
|
||||
0xC2..=0xDF => match cont(i + 1) {
|
||||
Some(c1) => ((u32::from(lead & 0x1F) << 6) | c1, 2),
|
||||
None => (0xFFFD, 1),
|
||||
},
|
||||
0xE0..=0xEF => match (cont(i + 1), cont(i + 2)) {
|
||||
(Some(c1), Some(c2)) if !(lead == 0xE0 && b[i + 1] < 0xA0) => {
|
||||
((u32::from(lead & 0x0F) << 12) | (c1 << 6) | c2, 3)
|
||||
}
|
||||
_ => (0xFFFD, 1),
|
||||
},
|
||||
0xF0..=0xF4 => match (cont(i + 1), cont(i + 2), cont(i + 3)) {
|
||||
(Some(c1), Some(c2), Some(c3)) => {
|
||||
let cp = (u32::from(lead & 0x07) << 18) | (c1 << 12) | (c2 << 6) | c3;
|
||||
if (0x1_0000..=0x10_FFFF).contains(&cp) {
|
||||
(cp, 4)
|
||||
} else {
|
||||
(0xFFFD, 1)
|
||||
}
|
||||
}
|
||||
_ => (0xFFFD, 1),
|
||||
},
|
||||
_ => (0xFFFD, 1),
|
||||
};
|
||||
i += len;
|
||||
if cp >= 0x1_0000 {
|
||||
let c = cp - 0x1_0000;
|
||||
out.push(0xD800 | (c >> 10) as u16);
|
||||
out.push(0xDC00 | (c & 0x3FF) as u16);
|
||||
} else {
|
||||
out.push(cp as u16);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// OS string to reversible `String` (see the module docs). Lossless on every
|
||||
/// platform (Windows goes through WTF-8).
|
||||
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))]
|
||||
{
|
||||
valid_str_to_config_string(&s.to_string_lossy())
|
||||
}
|
||||
bytes_to_config_string(&os_bytes(s))
|
||||
}
|
||||
|
||||
/// Encode a string that is already valid UTF-8 (e.g. the WTF-8 to UTF-8
|
||||
/// conversion of a non-Unix OS string). The only thing that needs escaping is
|
||||
/// a literal escape character, so decoding stays a bijection on every
|
||||
/// platform. Shared (not cfg gated) so it is testable everywhere.
|
||||
/// Encode a string that is already valid UTF-8 (plain text, e.g. a URL). The
|
||||
/// only thing that needs escaping is a literal escape character, so decoding
|
||||
/// stays a bijection on every platform.
|
||||
pub fn valid_str_to_config_string(s: &str) -> String {
|
||||
bytes_to_config_string(s.as_bytes())
|
||||
}
|
||||
@@ -117,6 +192,189 @@ pub fn config_str_to_os(s: &str) -> OsString {
|
||||
os_from_bytes(config_str_to_bytes(s))
|
||||
}
|
||||
|
||||
/// An OS string (path, environment value, home directory, ...) carried as a
|
||||
/// `String` through the reversible escape encoding of this module.
|
||||
///
|
||||
/// This is deliberately a distinct type: it cannot be used as text or passed
|
||||
/// to filesystem/exec APIs without an explicit [`EncodedOs::to_os`] /
|
||||
/// [`EncodedOs::to_path`] decode, and plain text (URLs, regex matches, ...)
|
||||
/// never becomes an `EncodedOs` except through [`EncodedOs::from_plain_text`],
|
||||
/// which escapes instead of decoding.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct EncodedOs(String);
|
||||
|
||||
impl EncodedOs {
|
||||
/// Empty value.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
EncodedOs(String::new())
|
||||
}
|
||||
|
||||
/// Encode an OS string.
|
||||
#[must_use]
|
||||
pub fn from_os(s: &OsStr) -> Self {
|
||||
EncodedOs(os_to_config_string(s))
|
||||
}
|
||||
|
||||
/// Encode a path.
|
||||
#[must_use]
|
||||
pub fn from_path(p: &std::path::Path) -> Self {
|
||||
Self::from_os(p.as_os_str())
|
||||
}
|
||||
|
||||
/// Encode raw bytes.
|
||||
#[must_use]
|
||||
pub fn from_bytes(b: &[u8]) -> Self {
|
||||
EncodedOs(bytes_to_config_string(b))
|
||||
}
|
||||
|
||||
/// Adopt a string that is *already* in the escape encoding (config
|
||||
/// values, CLI arguments collected with `args::collect_config_strings`).
|
||||
#[must_use]
|
||||
pub fn from_encoded(s: impl Into<String>) -> Self {
|
||||
EncodedOs(s.into())
|
||||
}
|
||||
|
||||
/// Wrap plain text (not previously encoded): a literal escape character
|
||||
/// is escaped so decoding returns exactly the same text.
|
||||
#[must_use]
|
||||
pub fn from_plain_text(s: &str) -> Self {
|
||||
EncodedOs(valid_str_to_config_string(s))
|
||||
}
|
||||
|
||||
/// The encoded text (config files, `+show-config`, display of config
|
||||
/// values). Never pass this to the OS; decode first.
|
||||
#[must_use]
|
||||
pub fn as_encoded(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Consume into the encoded string.
|
||||
#[must_use]
|
||||
pub fn into_encoded(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Whether the decoded value ends with the given plain suffix.
|
||||
#[must_use]
|
||||
pub fn ends_with_plain(&self, suffix: &str) -> bool {
|
||||
self.0.ends_with(suffix)
|
||||
}
|
||||
|
||||
/// Decode to the raw bytes.
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
config_str_to_bytes(&self.0)
|
||||
}
|
||||
|
||||
/// Decode to an OS string.
|
||||
#[must_use]
|
||||
pub fn to_os(&self) -> OsString {
|
||||
config_str_to_os(&self.0)
|
||||
}
|
||||
|
||||
/// Decode to UTF-16 code units (WTF-16: lone surrogates round-trip).
|
||||
/// This is the Windows API form; it is platform independent so the
|
||||
/// Windows logic is testable everywhere.
|
||||
#[must_use]
|
||||
pub fn to_wide(&self) -> Vec<u16> {
|
||||
wtf8_to_wide(&self.to_bytes())
|
||||
}
|
||||
|
||||
/// [`EncodedOs::to_wide`] plus a trailing NUL.
|
||||
#[must_use]
|
||||
pub fn to_wide_nul(&self) -> Vec<u16> {
|
||||
let mut w = self.to_wide();
|
||||
w.push(0);
|
||||
w
|
||||
}
|
||||
|
||||
/// Encode UTF-16 code units (possibly with lone surrogates).
|
||||
#[must_use]
|
||||
pub fn from_wide(w: &[u16]) -> Self {
|
||||
Self::from_bytes(&wide_to_wtf8(w))
|
||||
}
|
||||
|
||||
/// Decode to a path.
|
||||
#[must_use]
|
||||
pub fn to_path(&self) -> std::path::PathBuf {
|
||||
config_str_to_path(&self.0)
|
||||
}
|
||||
|
||||
/// Whether the decoded value starts with the given plain prefix.
|
||||
#[must_use]
|
||||
pub fn starts_with_plain(&self, prefix: &str) -> bool {
|
||||
// Valid UTF-8 prefixes (without the escape char) encode to themselves.
|
||||
self.0.starts_with(prefix)
|
||||
}
|
||||
|
||||
/// Join plain-text components like `std.fs.path.join` (empty components
|
||||
/// skipped, exactly one separator in between). Separators are ASCII and
|
||||
/// never part of an escape, so joining the encoded text is exact.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[must_use]
|
||||
pub fn join_plain(&self, parts: &[&str]) -> Self {
|
||||
let esc: Vec<String> = parts
|
||||
.iter()
|
||||
.map(|p| valid_str_to_config_string(p))
|
||||
.collect();
|
||||
let mut all: Vec<&str> = Vec::with_capacity(parts.len() + 1);
|
||||
all.push(&self.0);
|
||||
all.extend(esc.iter().map(String::as_str));
|
||||
EncodedOs(crate::path_join(&all))
|
||||
}
|
||||
|
||||
/// Join another encoded path (same rules as [`EncodedOs::join_plain`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[must_use]
|
||||
pub fn join(&self, other: &EncodedOs) -> Self {
|
||||
EncodedOs(crate::path_join(&[&self.0, &other.0]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares the *encoded* form with a literal (for plain ASCII/UTF-8 text
|
||||
/// without the escape character the two are identical).
|
||||
impl PartialEq<str> for EncodedOs {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<&str> for EncodedOs {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&OsStr> for EncodedOs {
|
||||
fn from(s: &OsStr) -> Self {
|
||||
Self::from_os(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OsString> for EncodedOs {
|
||||
fn from(s: OsString) -> Self {
|
||||
Self::from_os(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&std::path::Path> for EncodedOs {
|
||||
fn from(p: &std::path::Path) -> Self {
|
||||
Self::from_path(p)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::path::PathBuf> for EncodedOs {
|
||||
fn from(p: std::path::PathBuf) -> Self {
|
||||
Self::from_path(&p)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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())
|
||||
@@ -237,6 +495,59 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wtf8_round_trips_unpaired_surrogates() {
|
||||
// "a", lone high, "b", valid pair (U+1F600), lone low.
|
||||
let wide = [0x61u16, 0xD800, 0x62, 0xD83D, 0xDE00, 0xDC00];
|
||||
let b = wide_to_wtf8(&wide);
|
||||
assert_eq!(&b[..1], b"a");
|
||||
assert_eq!(&b[1..4], &[0xED, 0xA0, 0x80]);
|
||||
assert_eq!(wtf8_to_wide(&b), wide);
|
||||
// Through the reversible string encoding too.
|
||||
let s = bytes_to_config_string(&b);
|
||||
assert_eq!(wtf8_to_wide(&config_str_to_bytes(&s)), wide);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wtf8_invalid_bytes_become_replacement() {
|
||||
assert_eq!(wtf8_to_wide(b"a\xffb"), vec![0x61, 0xFFFD, 0x62]);
|
||||
assert_eq!(wtf8_to_wide(b"\xe0\x80\x80"), vec![0xFFFD; 3]);
|
||||
assert_eq!(
|
||||
wtf8_to_wide("\u{e9}\u{1F600}".as_bytes()),
|
||||
vec![0xE9, 0xD83D, 0xDE00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_os_plain_text_is_not_decoded() {
|
||||
let plain = "http://x/\u{fdd0}\u{ff}";
|
||||
let e = EncodedOs::from_plain_text(plain);
|
||||
assert_eq!(e.to_bytes(), plain.as_bytes());
|
||||
// Adopting the same text as "already encoded" would decode to 0xFF.
|
||||
assert_ne!(EncodedOs::from_encoded(plain).to_bytes(), plain.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_os_wide_round_trips_lone_surrogates() {
|
||||
let wide = [0x43u16, 0x3A, 0x5C, 0xD800, 0x61, 0xD83D, 0xDE00, 0xDFFF];
|
||||
let e = EncodedOs::from_wide(&wide);
|
||||
assert_eq!(e.to_wide(), wide);
|
||||
assert_eq!(*e.to_wide_nul().last().unwrap(), 0);
|
||||
// A literal escape char in a Windows name is still preserved.
|
||||
let w2: Vec<u16> = "a\u{fdd0}\u{ff}".encode_utf16().collect();
|
||||
assert_eq!(EncodedOs::from_wide(&w2).to_wide(), w2);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn encoded_os_unix_round_trip() {
|
||||
let o = os_from_bytes(vec![b'/', 0xff, b'x']);
|
||||
let e = EncodedOs::from_os(&o);
|
||||
assert_eq!(e.to_os(), o);
|
||||
assert_eq!(e.to_path(), std::path::PathBuf::from(&o));
|
||||
assert_eq!(e.join_plain(&["a", "", "b"]).to_bytes(), b"/\xffx/a/b");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn os_roundtrip() {
|
||||
|
||||
@@ -2,15 +2,15 @@ use std::ffi::OsString;
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
use crate::flatpak::is_flatpak;
|
||||
use crate::osbytes;
|
||||
use crate::osbytes::EncodedOs;
|
||||
|
||||
/// Entry that is retrieved from the passwd API. This only contains the fields
|
||||
/// we care about.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub shell: Option<String>,
|
||||
pub home: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub shell: Option<EncodedOs>,
|
||||
pub home: Option<EncodedOs>,
|
||||
pub name: Option<EncodedOs>,
|
||||
}
|
||||
|
||||
/// Byte-preserving [`Entry`] (Zig slices are arbitrary bytes).
|
||||
@@ -23,7 +23,7 @@ pub struct EntryOs {
|
||||
|
||||
impl From<EntryOs> for Entry {
|
||||
fn from(e: EntryOs) -> Self {
|
||||
let f = |v: Option<OsString>| v.map(|v| osbytes::os_to_config_string(&v));
|
||||
let f = |v: Option<OsString>| v.map(|v| EncodedOs::from_os(&v));
|
||||
Entry {
|
||||
shell: f(e.shell),
|
||||
home: f(e.home),
|
||||
@@ -152,8 +152,8 @@ fn parse_getent(output: &[u8]) -> EntryOs {
|
||||
.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()));
|
||||
let shell = it.next().map(|b| crate::osbytes::os_from_bytes(b.to_vec()));
|
||||
let home = it.next().map(|b| crate::osbytes::os_from_bytes(b.to_vec()));
|
||||
EntryOs {
|
||||
shell,
|
||||
home,
|
||||
|
||||
@@ -27,9 +27,8 @@ pub enum Error {
|
||||
|
||||
/// 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())))
|
||||
pub fn expand(cmd: &crate::osbytes::EncodedOs) -> Result<Option<crate::osbytes::EncodedOs>, Error> {
|
||||
Ok(expand_os(&cmd.to_os())?.map(crate::osbytes::EncodedOs::from))
|
||||
}
|
||||
|
||||
/// Byte-preserving [`expand`]: `$PATH` and the command are handled as raw
|
||||
@@ -105,6 +104,12 @@ fn is_executable(_meta: &std::fs::Metadata) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Plain-text wrapper over [`expand`] for the literals used below.
|
||||
fn expand(cmd: &str) -> Result<Option<String>, Error> {
|
||||
super::expand(&crate::osbytes::EncodedOs::from_plain_text(cmd))
|
||||
.map(|o| o.map(crate::osbytes::EncodedOs::into_encoded))
|
||||
}
|
||||
|
||||
// `uname -n` is the *nix equivalent of `hostname.exe` on Windows
|
||||
#[test]
|
||||
fn expand_hostname() {
|
||||
|
||||
@@ -4,24 +4,24 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::osbytes::{config_str_to_os, os_to_config_string};
|
||||
use crate::osbytes::EncodedOs;
|
||||
|
||||
fn enc(p: &Path) -> String {
|
||||
os_to_config_string(p.as_os_str())
|
||||
fn enc(p: &Path) -> EncodedOs {
|
||||
EncodedOs::from_path(p)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ResourcesDir {
|
||||
/// Avoid accessing these directly, use the app() and host() methods instead.
|
||||
pub app_path: Option<String>,
|
||||
pub host_path: Option<String>,
|
||||
pub app_path: Option<EncodedOs>,
|
||||
pub host_path: Option<EncodedOs>,
|
||||
}
|
||||
|
||||
impl ResourcesDir {
|
||||
/// Get the directory to the bundled resources directory accessible
|
||||
/// by the application.
|
||||
pub fn app(&self) -> Option<&str> {
|
||||
self.app_path.as_deref()
|
||||
pub fn app(&self) -> Option<&EncodedOs> {
|
||||
self.app_path.as_ref()
|
||||
}
|
||||
|
||||
/// Get the directory to the bundled resources directory accessible
|
||||
@@ -30,19 +30,19 @@ impl ResourcesDir {
|
||||
/// itself.
|
||||
///
|
||||
/// In non-sandboxed environment, this should be the same as app().
|
||||
pub fn host(&self) -> Option<&str> {
|
||||
self.host_path.as_deref().or(self.app_path.as_deref())
|
||||
pub fn host(&self) -> Option<&EncodedOs> {
|
||||
self.host_path.as_ref().or(self.app_path.as_ref())
|
||||
}
|
||||
|
||||
/// 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)))
|
||||
self.app().map(EncodedOs::to_path)
|
||||
}
|
||||
|
||||
/// Byte-preserving [`ResourcesDir::host`].
|
||||
pub fn host_os(&self) -> Option<PathBuf> {
|
||||
self.host().map(|p| PathBuf::from(config_str_to_os(p)))
|
||||
self.host().map(EncodedOs::to_path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<OsString>) ->
|
||||
// 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(os_to_config_string(dir)),
|
||||
app_path: Some(EncodedOs::from_os(dir)),
|
||||
host_path: None,
|
||||
};
|
||||
}
|
||||
@@ -127,7 +127,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<OsString>) ->
|
||||
// fallback and use the provided resources dir.
|
||||
if debug && let Some(dir) = env.filter(|d| !d.is_empty()) {
|
||||
return ResourcesDir {
|
||||
app_path: Some(os_to_config_string(&dir)),
|
||||
app_path: Some(EncodedOs::from_os(&dir)),
|
||||
host_path: None,
|
||||
};
|
||||
}
|
||||
@@ -176,22 +176,6 @@ pub fn maybe_dir_os(base: &Path, sub: &str, suffix: &str) -> Option<PathBuf> {
|
||||
dir.join(suffix).exists().then_some(dir)
|
||||
}
|
||||
|
||||
/// Little helper to check if the "base/sub/suffix" directory exists and
|
||||
/// if so return "base/sub". The "suffix" is just used as a way to verify a
|
||||
/// directory seems roughly right.
|
||||
pub fn maybe_dir(base: &str, sub: &str, suffix: &str) -> Option<String> {
|
||||
let path = format!("{base}/{sub}/{suffix}");
|
||||
|
||||
// Folder doesn't exist. If a different error happens its okay
|
||||
// we just ignore it and move on.
|
||||
if Path::new(&path).exists() {
|
||||
let len = path.len() - suffix.len() - 1;
|
||||
return Some(path[..len].to_owned());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -199,13 +183,13 @@ mod tests {
|
||||
#[test]
|
||||
fn resources_dir_accessors() {
|
||||
let mut r = ResourcesDir::default();
|
||||
assert_eq!(r.app(), None);
|
||||
assert_eq!(r.host(), None);
|
||||
r.app_path = Some("/a".into());
|
||||
assert_eq!(r.host(), Some("/a"));
|
||||
r.host_path = Some("/h".into());
|
||||
assert_eq!(r.app(), Some("/a"));
|
||||
assert_eq!(r.host(), Some("/h"));
|
||||
assert_eq!(r.app().map(EncodedOs::as_encoded), None);
|
||||
assert_eq!(r.host().map(EncodedOs::as_encoded), None);
|
||||
r.app_path = Some(EncodedOs::from_encoded("/a"));
|
||||
assert_eq!(r.host().map(EncodedOs::as_encoded), Some("/a"));
|
||||
r.host_path = Some(EncodedOs::from_encoded("/h"));
|
||||
assert_eq!(r.app().map(EncodedOs::as_encoded), Some("/a"));
|
||||
assert_eq!(r.host().map(EncodedOs::as_encoded), Some("/h"));
|
||||
}
|
||||
|
||||
fn make_tree() -> crate::TempDir {
|
||||
@@ -222,7 +206,7 @@ mod tests {
|
||||
let exe = td.path().join("bin/ghostty");
|
||||
let r = resources_dir_impl(true, Some(&exe), None);
|
||||
let expected = format!("{}/share/ghostty", td.path().display());
|
||||
assert_eq!(r.app(), Some(expected.as_str()));
|
||||
assert_eq!(r.app().map(EncodedOs::as_encoded), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -231,21 +215,29 @@ mod tests {
|
||||
let exe = td.path().join("bin/ghostty");
|
||||
let env = Some(OsString::from("/env/res"));
|
||||
assert_eq!(
|
||||
resources_dir_impl(false, Some(&exe), env.clone()).app(),
|
||||
resources_dir_impl(false, Some(&exe), env.clone())
|
||||
.app()
|
||||
.map(EncodedOs::as_encoded),
|
||||
Some("/env/res")
|
||||
);
|
||||
assert_ne!(
|
||||
resources_dir_impl(true, Some(&exe), env.clone()).app(),
|
||||
resources_dir_impl(true, Some(&exe), env.clone())
|
||||
.app()
|
||||
.map(EncodedOs::as_encoded),
|
||||
Some("/env/res")
|
||||
);
|
||||
// Debug fallback when detection fails.
|
||||
assert_eq!(
|
||||
resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), env).app(),
|
||||
resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), env)
|
||||
.app()
|
||||
.map(EncodedOs::as_encoded),
|
||||
Some("/env/res")
|
||||
);
|
||||
// Empty env is ignored.
|
||||
assert_eq!(
|
||||
resources_dir_impl(false, None, Some(OsString::new())).app(),
|
||||
resources_dir_impl(false, None, Some(OsString::new()))
|
||||
.app()
|
||||
.map(EncodedOs::as_encoded),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -254,13 +246,13 @@ mod tests {
|
||||
fn dev_fallback_finds_workspace_resources() {
|
||||
let exe = workspace_root().join("target/debug/ghostty");
|
||||
let r = resources_dir_impl(true, Some(&exe), None);
|
||||
assert!(r.app().is_some_and(|d| d.ends_with("/resources")));
|
||||
assert!(r.app().is_some_and(|d| d.ends_with_plain("/resources")));
|
||||
// Outside the workspace there is no fallback.
|
||||
let r = resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), None);
|
||||
assert_eq!(r.app(), None);
|
||||
assert_eq!(r.app().map(EncodedOs::as_encoded), None);
|
||||
// Release builds never use it.
|
||||
let r = resources_dir_impl(false, Some(&exe), None);
|
||||
assert_eq!(r.app(), None);
|
||||
assert_eq!(r.app().map(EncodedOs::as_encoded), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -279,15 +271,4 @@ mod tests {
|
||||
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();
|
||||
let base = td.path().to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
maybe_dir(&base, "share", "terminfo/x/xterm-ghostty"),
|
||||
Some(format!("{base}/share"))
|
||||
);
|
||||
assert_eq!(maybe_dir(&base, "share", "terminfo/g/ghostty"), None);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,12 @@
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::osbytes::EncodedOs;
|
||||
|
||||
/// `homedir.zig` is unavailable: nothing can be expanded.
|
||||
pub mod homedir {
|
||||
use crate::osbytes::EncodedOs;
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum ExpandError {
|
||||
#[error("home detection failed")]
|
||||
@@ -17,11 +21,11 @@ pub mod homedir {
|
||||
}
|
||||
|
||||
/// Paths without a leading `~/` pass through; others fail.
|
||||
pub fn expand_home(path: &str) -> Result<String, ExpandError> {
|
||||
if path.starts_with("~/") {
|
||||
pub fn expand_home(path: &EncodedOs) -> Result<EncodedOs, ExpandError> {
|
||||
if path.starts_with_plain("~/") {
|
||||
Err(ExpandError::HomeDetectionFailed)
|
||||
} else {
|
||||
Ok(path.to_owned())
|
||||
Ok(path.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +33,9 @@ pub mod homedir {
|
||||
pub mod passwd {
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub shell: Option<String>,
|
||||
pub home: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub shell: Option<crate::osbytes::EncodedOs>,
|
||||
pub home: Option<crate::osbytes::EncodedOs>,
|
||||
pub name: Option<crate::osbytes::EncodedOs>,
|
||||
}
|
||||
|
||||
/// Zig: `@compileError("passwd is not available for wasm")`.
|
||||
@@ -49,7 +53,7 @@ pub mod xdg {
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Options<'a> {
|
||||
pub subdir: Option<&'a str>,
|
||||
pub home: Option<&'a str>,
|
||||
pub home: Option<&'a crate::osbytes::EncodedOs>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
@@ -58,15 +62,15 @@ pub mod xdg {
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub fn config(_: &Options<'_>) -> Result<String, Error> {
|
||||
pub fn config(_: &Options<'_>) -> Result<crate::osbytes::EncodedOs, Error> {
|
||||
Err(Error::Unavailable)
|
||||
}
|
||||
|
||||
pub fn cache(_: &Options<'_>) -> Result<String, Error> {
|
||||
pub fn cache(_: &Options<'_>) -> Result<crate::osbytes::EncodedOs, Error> {
|
||||
Err(Error::Unavailable)
|
||||
}
|
||||
|
||||
pub fn state(_: &Options<'_>) -> Result<String, Error> {
|
||||
pub fn state(_: &Options<'_>) -> Result<crate::osbytes::EncodedOs, Error> {
|
||||
Err(Error::Unavailable)
|
||||
}
|
||||
|
||||
@@ -78,17 +82,17 @@ pub mod xdg {
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ResourcesDir {
|
||||
pub app_path: Option<String>,
|
||||
pub host_path: Option<String>,
|
||||
pub app_path: Option<EncodedOs>,
|
||||
pub host_path: Option<EncodedOs>,
|
||||
}
|
||||
|
||||
impl ResourcesDir {
|
||||
pub fn app(&self) -> Option<&str> {
|
||||
self.app_path.as_deref()
|
||||
pub fn app(&self) -> Option<&EncodedOs> {
|
||||
self.app_path.as_ref()
|
||||
}
|
||||
|
||||
pub fn host(&self) -> Option<&str> {
|
||||
self.host_path.as_deref()
|
||||
pub fn host(&self) -> Option<&EncodedOs> {
|
||||
self.host_path.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::ffi::CStr;
|
||||
|
||||
use crate::{env::getenv_not_empty, homedir, path_join};
|
||||
use crate::{env::getenv_not_empty, homedir, osbytes::EncodedOs};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Options<'a> {
|
||||
@@ -11,10 +11,10 @@ pub struct Options<'a> {
|
||||
/// when building up the directory. This is commonly the application.
|
||||
pub subdir: Option<&'a str>,
|
||||
|
||||
/// The home directory for the user. If this is not set, we will attempt
|
||||
/// The home directory for the user (encoded OS string). If this is not set, we will attempt
|
||||
/// to look it up which is an expensive process. By setting this, you can
|
||||
/// avoid lookups.
|
||||
pub home: Option<&'a str>,
|
||||
pub home: Option<&'a EncodedOs>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -28,29 +28,25 @@ pub enum Error {
|
||||
}
|
||||
|
||||
/// Get the XDG user config directory.
|
||||
pub fn config(opts: &Options<'_>) -> Result<String, Error> {
|
||||
pub fn config(opts: &Options<'_>) -> Result<EncodedOs, Error> {
|
||||
dir(opts, "XDG_CONFIG_HOME", ".config")
|
||||
}
|
||||
|
||||
/// Get the XDG cache directory.
|
||||
pub fn cache(opts: &Options<'_>) -> Result<String, Error> {
|
||||
pub fn cache(opts: &Options<'_>) -> Result<EncodedOs, Error> {
|
||||
dir(opts, "XDG_CACHE_HOME", ".cache")
|
||||
}
|
||||
|
||||
/// Get the XDG state directory.
|
||||
pub fn state(opts: &Options<'_>) -> Result<String, Error> {
|
||||
pub fn state(opts: &Options<'_>) -> Result<EncodedOs, Error> {
|
||||
dir(opts, "XDG_STATE_HOME", ".local/state")
|
||||
}
|
||||
|
||||
/// Unified helper to get XDG directories that follow a common pattern.
|
||||
fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<String, Error> {
|
||||
fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<EncodedOs, Error> {
|
||||
// If we have a cached home dir, use that.
|
||||
if let Some(home) = opts.home {
|
||||
return Ok(path_join(&[
|
||||
home,
|
||||
default_subdir,
|
||||
opts.subdir.unwrap_or(""),
|
||||
]));
|
||||
return Ok(home.join_plain(&[default_subdir, opts.subdir.unwrap_or("")]));
|
||||
}
|
||||
|
||||
// First check the env var. On Windows we treat `LOCALAPPDATA` as a
|
||||
@@ -61,7 +57,7 @@ fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<String, Er
|
||||
if let Some(env) = env_value {
|
||||
// If we have a subdir, then we join it.
|
||||
if let Some(subdir) = opts.subdir {
|
||||
return Ok(path_join(&[&env, subdir]));
|
||||
return Ok(env.join_plain(&[subdir]));
|
||||
}
|
||||
|
||||
return Ok(env);
|
||||
@@ -69,11 +65,7 @@ fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<String, Er
|
||||
|
||||
// Get our home dir
|
||||
if let Ok(Some(home)) = homedir::home() {
|
||||
return Ok(path_join(&[
|
||||
&home,
|
||||
default_subdir,
|
||||
opts.subdir.unwrap_or(""),
|
||||
]));
|
||||
return Ok(home.join_plain(&[default_subdir, opts.subdir.unwrap_or("")]));
|
||||
}
|
||||
|
||||
Err(Error::NoHomeDir)
|
||||
@@ -110,7 +102,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cache_directory_paths() {
|
||||
let mock_home = "/Users/test";
|
||||
let mock_home = &EncodedOs::from_plain_text("/Users/test");
|
||||
|
||||
// Test base path
|
||||
let p = cache(&Options {
|
||||
@@ -129,7 +121,7 @@ mod tests {
|
||||
assert_eq!(p, "/Users/test/.cache/ghostty");
|
||||
}
|
||||
|
||||
type DirFn = fn(&Options<'_>) -> Result<String, Error>;
|
||||
type DirFn = fn(&Options<'_>) -> Result<EncodedOs, Error>;
|
||||
const CASES: [(&str, DirFn, &str); 3] = [
|
||||
("XDG_CONFIG_HOME", config, ".config"),
|
||||
("XDG_CACHE_HOME", cache, ".cache"),
|
||||
@@ -141,9 +133,9 @@ mod tests {
|
||||
let temp_home = "/tmp/ghostty-test-home";
|
||||
for (name, func, default_subdir) in CASES {
|
||||
temp_env::with_vars([("HOME", Some(temp_home)), (name, Some(""))], || {
|
||||
let expected = path_join(&[temp_home, default_subdir]);
|
||||
let expected = crate::path_join(&[temp_home, default_subdir]);
|
||||
let actual = func(&Options::default()).unwrap();
|
||||
assert_eq!(expected, actual);
|
||||
assert_eq!(actual, expected.as_str());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -153,13 +145,13 @@ mod tests {
|
||||
let temp_home = "/tmp/ghostty-test-home";
|
||||
for (name, func, default_subdir) in CASES {
|
||||
temp_env::with_vars([("HOME", Some(temp_home)), (name, Some(""))], || {
|
||||
let expected = path_join(&[temp_home, default_subdir, "ghostty"]);
|
||||
let expected = crate::path_join(&[temp_home, default_subdir, "ghostty"]);
|
||||
let actual = func(&Options {
|
||||
subdir: Some("ghostty"),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(expected, actual);
|
||||
assert_eq!(actual, expected.as_str());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ fn host_command_spawn_exit_status_and_signal() {
|
||||
w,
|
||||
devnull(),
|
||||
);
|
||||
cmd.cwd = Some("/".into());
|
||||
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/"));
|
||||
let mut env = ghostty_os::env::EnvMap::default();
|
||||
env.insert("FOO", "bar");
|
||||
cmd.env = Some(env);
|
||||
@@ -84,7 +84,7 @@ fn host_command_spawn_exit_status_and_signal() {
|
||||
devnull(),
|
||||
devnull(),
|
||||
);
|
||||
cmd.cwd = Some("/".into());
|
||||
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/"));
|
||||
cmd.spawn().unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
cmd.wait_callback(move |r| tx.send(r).unwrap()).unwrap();
|
||||
@@ -99,7 +99,7 @@ fn host_command_spawn_exit_status_and_signal() {
|
||||
devnull(),
|
||||
devnull(),
|
||||
);
|
||||
cmd.cwd = Some("/".into());
|
||||
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/"));
|
||||
assert_eq!(cmd.spawn(), Err(Error::FlatpakSpawnFail));
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ fn host_command_spawn_exit_status_and_signal() {
|
||||
devnull(),
|
||||
devnull(),
|
||||
);
|
||||
cmd.cwd = Some("/".into());
|
||||
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/"));
|
||||
let pid = cmd.spawn().unwrap();
|
||||
let handle = cmd.signal_handle();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
Reference in new issue
Block a user