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:
ddidderr committed 2026-10-12 10:06:07 +02:00
1 parent 7a3f0b0f2c
commit f7fec6f44d
92 files changed
+2967 -992

No files matched your search

@@ -78,7 +78,7 @@ pub enum QuitTimer {
#[derive(Debug, Default, Clone)]
pub struct WindowOverrides {
pub command: Option<Command>,
pub working_directory: Option<String>,
pub working_directory: Option<ghostty_os::osbytes::EncodedOs>,
pub title: Option<String>,
}
@@ -1141,7 +1141,7 @@ impl Application {
fn action_new_window(&self, parameter: Option<&glib::Variant>) {
log::debug!("received new window action");
let mut working_directory: Option<String> = None;
let mut working_directory: Option<ghostty_os::osbytes::EncodedOs> = None;
let mut title: Option<String> = None;
let mut command: Option<Command> = None;
let mut args: Vec<String> = Vec::new();
@@ -1180,7 +1180,9 @@ impl Application {
continue;
}
if let Some(v) = s.strip_prefix("--working-directory=") {
working_directory = Some(v.trim().to_owned());
// IPC carries the encoded form of the OS string.
working_directory =
Some(ghostty_os::osbytes::EncodedOs::from_encoded(v.trim()));
continue;
}
if let Some(v) = s.strip_prefix("--title=") {
@@ -1791,7 +1793,7 @@ impl Application {
action::OpenUrlKind::Text => ghostty_os::open::OpenUrlKind::Text,
action::OpenUrlKind::Html => ghostty_os::open::OpenUrlKind::Html,
};
if let Err(err) = ghostty_os::open::open(kind, &value.url) {
if let Err(err) = ghostty_os::open::open(kind, &value.url.to_os()) {
log::warn!("unable to open url: {err}");
}
}
@@ -138,7 +138,10 @@ mod command_imp {
let title = surface
.get_effective_title()
.unwrap_or_else(|| "Untitled".to_string());
if let Some(pwd) = surface.get_pwd()
// Display text only.
if let Some(pwd) = surface
.get_pwd()
.map(|p| String::from_utf8_lossy(&p.to_bytes()).into_owned())
&& !title.contains(&pwd)
{
*description = Some(pwd);
+29 -17
View File
@@ -132,7 +132,7 @@ pub enum DebugImKey {
#[derive(Debug, Clone, Default)]
pub struct SurfaceOverrides {
pub command: Option<Command>,
pub working_directory: Option<String>,
pub working_directory: Option<ghostty_os::osbytes::EncodedOs>,
pub title: Option<String>,
}
@@ -268,7 +268,7 @@ impl Surface {
if ghostty_apprt::surface::should_inherit_working_directory(context, &config_obj.get())
&& let Some(pwd) = parent.get_pwd()
{
imp.pwd.replace(Some(pwd));
imp.pwd.replace(Some(pwd.into_encoded()));
self.notify("pwd");
}
}
@@ -1500,7 +1500,7 @@ impl Surface {
pub fn set_clipboard(
&self,
clipboard_type: Clipboard,
contents: &[(String, String)],
contents: &[(String, Vec<u8>)],
confirm: bool,
) {
clipboard::set(self, clipboard_type, contents, confirm);
@@ -1582,7 +1582,7 @@ impl Surface {
}
self.set_clipboard(
Clipboard::Standard,
&[("text/plain".to_owned(), title)],
&[("text/plain".to_owned(), title.into_bytes())],
false,
);
true
@@ -1608,13 +1608,22 @@ impl Surface {
}
/// Returns the pwd property.
pub fn get_pwd(&self) -> Option<String> {
self.imp().pwd.borrow().clone()
pub fn get_pwd(&self) -> Option<ghostty_os::osbytes::EncodedOs> {
// The GObject `pwd` property is a (UTF-8) string, so it stores the
// encoded form of the OS string; this accessor is the only place it
// is converted back to the typed value.
self.imp()
.pwd
.borrow()
.clone()
.map(ghostty_os::osbytes::EncodedOs::from_encoded)
}
/// Set the pwd for this surface, copies the value.
pub fn set_pwd(&self, pwd: Option<&str>) {
self.imp().pwd.replace(pwd.map(str::to_owned));
pub fn set_pwd(&self, pwd: Option<&ghostty_os::osbytes::EncodedOs>) {
self.imp()
.pwd
.replace(pwd.map(|p| p.as_encoded().to_owned()));
self.notify("pwd");
}
@@ -1806,8 +1815,10 @@ impl Surface {
let volume = config.bell_audio_volume.clamp(0.0, 1.0);
debug_assert!(std::path::Path::new(path).is_absolute());
let media_file = gtk::MediaFile::for_filename(path);
// The config path is an encoded OS string: decode it first.
let path = path.to_path();
debug_assert!(path.is_absolute());
let media_file = gtk::MediaFile::for_filename(&path);
// If the audio file is marked as required, we'll emit an error if
// there was a problem playing it. Otherwise there will be silence.
@@ -2125,7 +2136,7 @@ impl Surface {
/// The first `focused` surface other than ourselves, used to inherit the
/// working directory like `App.focusedSurface` does in the core.
fn focused_surface_pwd(&self) -> Option<String> {
fn focused_surface_pwd(&self) -> Option<ghostty_os::osbytes::EncodedOs> {
Surface::all()
.into_iter()
.find(|s| s != self && s.core().is_some() && s.get_focused())
@@ -2178,7 +2189,7 @@ impl Surface {
if let Some(size) = *imp.font_size_request.borrow() {
config.font_size = size.0.points;
}
if let Some(pwd) = imp.pwd.borrow().clone() {
if let Some(pwd) = self.get_pwd() {
let mut wd_val = WorkingDirectory::Path(pwd);
wd_val.finalize();
config.working_directory = Some(wd_val);
@@ -2421,7 +2432,7 @@ impl RtSurface for RtSurfaceHandle {
.is_some_and(|s| s.clipboard_request(clipboard, state))
}
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool) {
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec<u8>)], confirm: bool) {
if let Some(s) = self.gobj() {
s.set_clipboard(clipboard, contents, confirm);
}
@@ -2485,15 +2496,16 @@ mod clipboard {
pub(super) fn set(
surface: &Surface,
clipboard_type: Clipboard,
contents: &[(String, String)],
contents: &[(String, Vec<u8>)],
confirm: bool,
) {
// Grab our plaintext content for use in confirmation dialogs
// and signals. We always expect one to exist.
// and signals (display text only; the clipboard gets the raw bytes).
// We always expect one to exist.
let Some(text) = contents
.iter()
.find(|(mime, _)| mime == "text/plain")
.map(|(_, data)| data.clone())
.map(|(_, data)| String::from_utf8_lossy(data).into_owned())
else {
return;
};
@@ -2505,7 +2517,7 @@ mod clipboard {
let providers: Vec<gdk::ContentProvider> = contents
.iter()
.map(|(mime, data)| {
let bytes = glib::Bytes::from(data.as_bytes());
let bytes = glib::Bytes::from(data.as_slice());
if mime == "text/plain" {
// Add an explicit UTF-8 encoding parameter to the
// text/plain type. The default charset when there is
+1 -1
View File
@@ -73,7 +73,7 @@ pub enum SelectTab {
#[derive(Debug, Clone, Default)]
pub struct TabOverrides {
pub command: Option<ConfigCommand>,
pub working_directory: Option<String>,
pub working_directory: Option<ghostty_os::osbytes::EncodedOs>,
pub title: Option<String>,
}
+9 -8
View File
@@ -110,29 +110,30 @@ impl CssProviders {
for p in &config.gtk_custom_css.value {
let (path, optional) = match p {
Path::Optional(path) => (path, true),
Path::Required(path) => (path, false),
Path::Optional(path) => (path.to_path(), true),
Path::Required(path) => (path.to_path(), false),
};
let meta = match std::fs::metadata(path) {
let shown = path.display();
let meta = match std::fs::metadata(&path) {
Ok(m) => m,
Err(e) => {
if e.kind() != std::io::ErrorKind::NotFound || !optional {
log::warn!("error opening gtk-custom-css file {path}: {e}");
log::warn!("error opening gtk-custom-css file {shown}: {e}");
}
continue;
}
};
log::info!("loading gtk-custom-css path={path}");
log::info!("loading gtk-custom-css path={shown}");
if meta.len() > CSS_FILE_SIZE_LIMIT {
log::warn!(
"gtk-custom-css file {path} was larger than {CSS_FILE_SIZE_LIMIT} bytes"
"gtk-custom-css file {shown} was larger than {CSS_FILE_SIZE_LIMIT} bytes"
);
continue;
}
let contents = match std::fs::read(path) {
let contents = match std::fs::read(&path) {
Ok(c) => c,
Err(e) => {
log::warn!("error reading gtk-custom-css file {path}: {e}");
log::warn!("error reading gtk-custom-css file {shown}: {e}");
continue;
}
};
+6 -8
View File
@@ -1,6 +1,6 @@
//! Flatpak helpers (port of `apprt/gtk/flatpak.zig`).
use ghostty_os::ResourcesDir;
use ghostty_os::{ResourcesDir, osbytes::EncodedOs};
use gtk::glib;
/// Returns the resources directory, consulting the Flatpak runtime data
@@ -9,7 +9,7 @@ pub fn resources_dir() -> std::io::Result<ResourcesDir> {
// Only consult Flatpak runtime data for the host case.
if ghostty_os::is_flatpak() {
let mut result = ResourcesDir {
app_path: Some("/app/share/ghostty".to_owned()),
app_path: Some(EncodedOs::from_plain_text("/app/share/ghostty")),
host_path: None,
};
@@ -24,13 +24,11 @@ pub fn resources_dir() -> std::io::Result<ResourcesDir> {
return Ok(result);
};
result.host_path = Some(
std::path::Path::new(app_dir.as_str())
result.host_path = Some(EncodedOs::from_path(
&std::path::Path::new(app_dir.as_str())
.join("share")
.join("ghostty")
.to_string_lossy()
.into_owned(),
);
.join("ghostty"),
));
return Ok(result);
}
+1
View File
@@ -11,6 +11,7 @@ ghostty-config.workspace = true
ghostty-datastruct.workspace = true
ghostty-inspector.workspace = true
ghostty-terminal.workspace = true
ghostty-os.workspace = true
ghostty-renderer.workspace = true
thiserror = "2.0.21"
+15 -9
View File
@@ -650,16 +650,14 @@ impl std::fmt::Display for SetTitle {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pwd {
/// The working directory as reported by OSC 7. Zig keeps raw bytes; here
/// non-UTF-8 bytes are carried with the reversible escape of
/// `ghostty_os::osbytes` (valid UTF-8 is unchanged). Decode with
/// `osbytes::config_str_to_bytes` / `config_str_to_os` before using it
/// as a path or handing it to C.
pub pwd: String,
/// they are carried as an [`EncodedOs`](ghostty_os::osbytes::EncodedOs)
/// that must be decoded (`to_path`/`to_bytes`) at the OS/C boundary.
pub pwd: ghostty_os::osbytes::EncodedOs,
}
impl std::fmt::Display for Pwd {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Pwd{{ {} }}", self.pwd)
write!(f, "Pwd{{ {} }}", self.pwd.as_encoded())
}
}
@@ -752,8 +750,10 @@ pub struct OpenUrl {
/// The type of data that the URL refers to.
pub kind: OpenUrlKind,
/// The URL.
pub url: String,
/// The URL or path to open, as raw bytes (Zig `[]const u8`). Plain text
/// URLs are wrapped with `EncodedOs::from_plain_text`, file names with
/// `EncodedOs::from_path`/`from_bytes`; the opener decodes it.
pub url: ghostty_os::osbytes::EncodedOs,
}
/// The type of the data at the URL to open. This is used as a hint to
@@ -870,7 +870,13 @@ mod tests {
#[test]
fn display_formats() {
assert_eq!(SetTitle { title: "x".into() }.to_string(), "SetTitle{ x }");
assert_eq!(Pwd { pwd: "/a".into() }.to_string(), "Pwd{ /a }");
assert_eq!(
Pwd {
pwd: ghostty_os::osbytes::EncodedOs::from_encoded("/a")
}
.to_string(),
"Pwd{ /a }"
);
assert_eq!(
DesktopNotification {
title: "t".into(),
+3 -1
View File
@@ -307,7 +307,9 @@ pub trait RtSurface {
/// `CoreSurface::complete_clipboard_request`. Returns true if started.
fn clipboard_request(&self, clipboard: Clipboard, state: ClipboardRequest) -> bool;
/// `setClipboard(type, contents, confirm)`; contents are (mime, data).
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool);
/// The data is raw bytes like Zig's `[:0]const u8` (a copied file name
/// need not be valid UTF-8).
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec<u8>)], confirm: bool);
/// `defaultTermioEnv`: env vars to set for the child (GDK/GTK related
/// cleanups).
fn default_termio_env(&self) -> Vec<(std::ffi::OsString, std::ffi::OsString)>;
+12 -7
View File
@@ -222,7 +222,11 @@ pub fn should_inherit_working_directory(context: NewSurfaceContext, config: &Con
/// `app.focusedSurface()?.pwd()`), which the caller (core) must look up.
// TODO(integration): core could wrap this to resolve the focused surface.
#[must_use]
pub fn new_config(config: &Config, context: NewSurfaceContext, prev_pwd: Option<String>) -> Config {
pub fn new_config(
config: &Config,
context: NewSurfaceContext,
prev_pwd: Option<ghostty_os::osbytes::EncodedOs>,
) -> Config {
let mut copy = config.clone();
if let Some(pwd) = prev_pwd
&& should_inherit_working_directory(context, config)
@@ -238,6 +242,10 @@ mod tests {
use super::*;
fn tmp() -> ghostty_os::osbytes::EncodedOs {
ghostty_os::osbytes::EncodedOs::from_encoded("/tmp")
}
struct Rec(RefCell<Vec<(u32, bool)>>);
impl AppMailbox<u32> for Rec {
fn push_surface_message(&self, s: u32, m: Message, _t: Timeout) -> usize {
@@ -277,12 +285,9 @@ mod tests {
NewSurfaceContext::Tab,
&c
));
let n = new_config(&c, NewSurfaceContext::Window, Some("/tmp".into()));
assert_eq!(
n.working_directory,
Some(WorkingDirectory::Path("/tmp".into()))
);
let n = new_config(&c, NewSurfaceContext::Tab, Some("/tmp".into()));
let n = new_config(&c, NewSurfaceContext::Window, Some(tmp()));
assert_eq!(n.working_directory, Some(WorkingDirectory::Path(tmp())));
let n = new_config(&c, NewSurfaceContext::Tab, Some(tmp()));
assert_eq!(n.working_directory, None);
}
}
+1
View File
@@ -14,6 +14,7 @@ name = "ghostty-synthetic"
path = "src/bin/ghostty-synthetic.rs"
[dependencies]
ghostty-os.workspace = true
ghostty-terminal.workspace = true
ghostty-unicode.workspace = true
ghostty-vt.workspace = true
+88 -24
View File
@@ -2,48 +2,61 @@
//! the benchmark and synthetic CLIs).
use std::{
collections::HashMap,
ffi::{OsStr, OsString},
fs::File,
io::{self, Read},
path::{Path, PathBuf},
str::FromStr,
};
use ghostty_os::osbytes::{os_bytes, os_from_bytes};
/// Parsed `--key[=value]` arguments.
#[derive(Debug, Default)]
pub struct Args {
map: HashMap<String, Option<String>>,
// Values are OS strings: file names (`--data=...`) need not be UTF-8.
map: HashMap<String, Option<OsString>>,
}
/// The action name of a `+action` argument, if `arg` is one.
#[must_use]
pub fn action_name(arg: &OsStr) -> Option<String> {
let bytes = os_bytes(arg);
let name = bytes.strip_prefix(b"+")?;
Some(String::from_utf8_lossy(name).into_owned())
}
impl Args {
/// Parse arguments (skipping `+action` words and the program name).
pub fn parse<I: IntoIterator<Item = String>>(iter: I) -> Result<Args, String> {
pub fn parse<I, S>(iter: I) -> Result<Args, String>
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let mut map = HashMap::new();
for arg in iter {
if arg.starts_with('+') {
let bytes = os_bytes(&arg.into());
if bytes.starts_with(b"+") {
continue;
}
let Some(rest) = arg.strip_prefix("--") else {
return Err(format!("unexpected argument: {arg}"));
let Some(rest) = bytes.strip_prefix(b"--") else {
return Err(format!(
"unexpected argument: {}",
String::from_utf8_lossy(&bytes)
));
};
match rest.split_once('=') {
Some((k, v)) => map.insert(k.to_owned(), Some(v.to_owned())),
None => map.insert(rest.to_owned(), None),
let (key, value) = match rest.iter().position(|b| *b == b'=') {
Some(i) => (&rest[..i], Some(os_from_bytes(rest[i + 1..].to_vec()))),
None => (rest, None),
};
let key = String::from_utf8(key.to_vec())
.map_err(|_| "option names must be valid UTF-8".to_owned())?;
map.insert(key, value);
}
Ok(Args { map })
}
/// Take a typed value, falling back to `default`.
pub fn take<T: FromStr>(&mut self, key: &str, default: T) -> Result<T, String> {
match self.map.remove(key) {
None => Ok(default),
Some(None) => Err(format!("option --{key} requires a value")),
Some(Some(v)) => v
.parse()
.map_err(|_| format!("invalid value for --{key}: {v}")),
}
}
pub fn take_opt_string(&mut self, key: &str) -> Result<Option<String>, String> {
fn take_os(&mut self, key: &str) -> Result<Option<OsString>, String> {
match self.map.remove(key) {
None => Ok(None),
Some(None) => Err(format!("option --{key} requires a value")),
@@ -51,15 +64,44 @@ impl Args {
}
}
/// Take a typed value, falling back to `default`.
pub fn take<T: FromStr>(&mut self, key: &str, default: T) -> Result<T, String> {
match self.take_os(key)? {
None => Ok(default),
Some(v) => v
.to_str()
.and_then(|s| s.parse().ok())
.ok_or_else(|| format!("invalid value for --{key}: {}", v.to_string_lossy())),
}
}
pub fn take_opt_string(&mut self, key: &str) -> Result<Option<String>, String> {
match self.take_os(key)? {
None => Ok(None),
Some(v) => v
.into_string()
.map(Some)
.map_err(|v| format!("invalid value for --{key}: {}", v.to_string_lossy())),
}
}
/// Take a file path value (any bytes are allowed).
pub fn take_opt_path(&mut self, key: &str) -> Result<Option<PathBuf>, String> {
Ok(self.take_os(key)?.map(PathBuf::from))
}
/// Take a boolean flag (`--key`, `--key=true`, `--key=false`).
pub fn take_flag(&mut self, key: &str) -> Result<bool, String> {
match self.map.remove(key) {
None => Ok(false),
Some(None) => Ok(true),
Some(Some(v)) => match v.as_str() {
Some(Some(v)) => match v.to_str().unwrap_or_default() {
"true" | "t" | "1" | "yes" => Ok(true),
"false" | "f" | "0" | "no" => Ok(false),
_ => Err(format!("invalid value for --{key}: {v}")),
_ => Err(format!(
"invalid value for --{key}: {}",
v.to_string_lossy()
)),
},
}
}
@@ -75,10 +117,10 @@ impl Args {
/// Returns the data source for the path: `None` if unset, stdin for `-`,
/// otherwise the opened file (mirrors `benchmark/options.zig`).
pub fn data_file(path: Option<&str>) -> io::Result<Option<Box<dyn Read>>> {
pub fn data_file(path: Option<&Path>) -> io::Result<Option<Box<dyn Read>>> {
match path {
None => Ok(None),
Some("-") => Ok(Some(Box::new(io::stdin()))),
Some(p) if p == Path::new("-") => Ok(Some(Box::new(io::stdin()))),
Some(p) => Ok(Some(Box::new(File::open(p)?))),
}
}
@@ -96,3 +138,25 @@ pub fn read_short(r: &mut dyn Read, buf: &mut [u8]) -> io::Result<usize> {
}
Ok(n)
}
#[cfg(all(test, unix))]
mod tests {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use super::*;
#[test]
fn data_path_keeps_non_utf8_bytes() {
let arg = OsString::from_vec(b"--data=/tmp/a\xffb".to_vec());
let mut args = Args::parse([arg]).unwrap();
let p = args.take_opt_path("data").unwrap().unwrap();
assert_eq!(p.as_os_str().as_bytes(), b"/tmp/a\xffb");
args.finish().unwrap();
}
#[test]
fn action_name_extracts_plus_words() {
assert_eq!(action_name(OsStr::new("+ascii")).as_deref(), Some("ascii"));
assert_eq!(action_name(OsStr::new("--x")), None);
}
}
+16 -12
View File
@@ -40,12 +40,12 @@ fn warn(what: &str, e: &dyn std::fmt::Display) -> BenchmarkFailed {
/// Shared data-file handling: opened in setup, closed in teardown.
struct Data {
path: Option<String>,
path: Option<std::path::PathBuf>,
f: Option<Box<dyn Read>>,
}
impl Data {
fn new(path: Option<String>) -> Data {
fn new(path: Option<std::path::PathBuf>) -> Data {
Data { path, f: None }
}
@@ -103,7 +103,7 @@ impl TerminalStream {
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
let rows = args.take("terminal-rows", 80u16)?;
let cols = args.take("terminal-cols", 120u16)?;
let data = args.take_opt_string("data")?;
let data = args.take_opt_path("data")?;
let full = match args.take_opt_string("handler")?.as_deref() {
None | Some("print") => false,
Some("full") => true,
@@ -160,7 +160,7 @@ pub struct TerminalParser {
impl TerminalParser {
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
Ok(TerminalParser {
data: Data::new(args.take_opt_string("data")?),
data: Data::new(args.take_opt_path("data")?),
})
}
}
@@ -196,7 +196,7 @@ pub struct OscParser {
impl OscParser {
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
Ok(OscParser {
data: Data::new(args.take_opt_string("data")?),
data: Data::new(args.take_opt_path("data")?),
parser: osc::Parser::init(true),
})
}
@@ -267,7 +267,7 @@ impl CodepointWidth {
Some(m) => return Err(format!("invalid value for --mode: {m}")),
};
Ok(CodepointWidth {
data: Data::new(args.take_opt_string("data")?),
data: Data::new(args.take_opt_path("data")?),
mode,
})
}
@@ -390,7 +390,7 @@ impl GraphemeBreak {
Some(m) => return Err(format!("invalid value for --mode: {m}")),
};
Ok(GraphemeBreak {
data: Data::new(args.take_opt_string("data")?),
data: Data::new(args.take_opt_path("data")?),
noop,
})
}
@@ -479,7 +479,7 @@ impl IsSymbol {
Some(m) => return Err(format!("invalid value for --mode: {m}")),
};
Ok(IsSymbol {
data: Data::new(args.take_opt_string("data")?),
data: Data::new(args.take_opt_path("data")?),
mode,
})
}
@@ -544,7 +544,7 @@ impl ScreenClone {
};
let rows = args.take("terminal-rows", 80u16)?;
let cols = args.take("terminal-cols", 120u16)?;
let data = args.take_opt_string("data")?;
let data = args.take_opt_path("data")?;
let terminal = Terminal::new(TermOptions::new(cols, rows)).map_err(|e| format!("{e:?}"))?;
Ok(ScreenClone {
data: Data::new(data),
@@ -674,9 +674,13 @@ mod tests {
fn run(action: &str, mode: &str, data: &std::path::Path) -> Result {
let mut args = Args::parse([
format!("+{action}"),
format!("--mode={mode}"),
format!("--data={}", data.display()),
std::ffi::OsString::from(format!("+{action}")),
std::ffi::OsString::from(format!("--mode={mode}")),
{
let mut a = std::ffi::OsString::from("--data=");
a.push(data);
a
},
])
.unwrap();
match action {
@@ -11,14 +11,14 @@ use ghostty_bench::{
};
fn run() -> Result<(), String> {
let argv: Vec<String> = std::env::args().skip(1).collect();
let argv: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let mut action = None;
for a in &argv {
if let Some(name) = a.strip_prefix('+') {
if let Some(name) = ghostty_bench::args::action_name(a) {
if action.is_some() {
return Err("multiple actions specified".into());
}
action = Some(name.to_owned());
action = Some(name);
}
}
let action = action.ok_or("no action specified; available: ascii, osc, sgr, utf8")?;
+3 -3
View File
@@ -4,15 +4,15 @@ use std::process::ExitCode;
use ghostty_bench::{args::Args, benches};
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
let argv: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let mut action = None;
for a in &argv {
if let Some(name) = a.strip_prefix('+') {
if let Some(name) = ghostty_bench::args::action_name(a) {
if action.is_some() {
eprintln!("error: multiple actions specified");
return ExitCode::FAILURE;
}
action = Some(name.to_owned());
action = Some(name);
}
}
let Some(action) = action else {
+1
View File
@@ -28,3 +28,4 @@ workspace = true
[dev-dependencies]
tempfile = "3.27.0"
temp-env = "0.3.6"
+2 -1
View File
@@ -85,7 +85,8 @@ fn run_inner(
};
format!("{} ago", d.round(1_000_000_000))
};
writeln!(stdout, "{} ({since})", report.name)?;
// Display only: the file name is shown as text.
writeln!(stdout, "{} ({since})", report.name.to_string_lossy())?;
}
Ok(0)
+14 -9
View File
@@ -5,6 +5,7 @@ use std::io::Write;
use std::os::unix::process::CommandExt;
use ghostty_config::config::Config;
use ghostty_os::osbytes::EncodedOs;
use crate::error::Error;
pub use crate::options::NoOptions as Options;
@@ -43,7 +44,7 @@ pub fn run(args: &[String]) -> Result<u8, Error> {
/// Pick the editor from `$VISUAL` then `$EDITOR` (empty values are skipped).
#[must_use]
pub fn find_editor() -> Option<String> {
pub fn find_editor() -> Option<EncodedOs> {
// VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference
for var in ["VISUAL", "EDITOR"] {
if let Some(v) = ghostty_os::getenv(var) {
@@ -76,19 +77,20 @@ fn run_inner(stderr: &mut dyn Write) -> Result<u8, Error> {
/// Windows branch of `run`: print the path and fail.
#[cfg(windows)]
fn write_windows_unsupported(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
fn write_windows_unsupported(stderr: &mut dyn Write, path: &EncodedOs) -> Result<u8, Error> {
write!(
stderr,
"The `ghostty +edit-config` command is not supported on Windows.\n\
Please edit the configuration file manually at the following path:\n\
\n\
{path}\n"
{}\n",
path.as_encoded()
)?;
Ok(1)
}
#[cfg(unix)]
fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
fn exec_editor(stderr: &mut dyn Write, path: &EncodedOs) -> Result<u8, Error> {
// Get our editor
let Some(editor) = find_editor() else {
// If we don't have `$EDITOR` set then we can't do anything
@@ -107,15 +109,16 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
);
// Output the path using the OSC8 sequence so that it is linked.
// (Paths are raw bytes: decode the reversible escape when writing.)
let msg = format!("{msg}\x1b]8;;file://{path}\x1b\\{path}\x1b]8;;\x1b\\\n");
let p = path.as_encoded();
let msg = format!("{msg}\x1b]8;;file://{p}\x1b\\{p}\x1b]8;;\x1b\\\n");
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&msg))?;
return Ok(1);
};
// Flush before exec so no output is lost.
let _ = stderr.flush();
let err = std::process::Command::new(ghostty_os::osbytes::config_str_to_os(&editor))
.arg(ghostty_os::osbytes::config_str_to_os(path))
let err = std::process::Command::new(editor.to_os())
.arg(path.to_os())
.exec();
// If we reached this point then exec failed.
@@ -131,8 +134,10 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
permissions, or the shell environment not being set up\n\
correctly.\n\
\n\
Editor: {editor}\n\
Path: {path}\n"
Editor: {}\n\
Path: {}\n",
editor.as_encoded(),
path.as_encoded()
);
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&msg))?;
Ok(1)
+19 -25
View File
@@ -15,12 +15,7 @@ use ghostty_config::{
config_struct,
theme::{Location, LocationIterator},
};
use ghostty_os::osbytes::{
config_str_to_bytes,
config_str_to_path,
os_to_config_string,
path_to_config_string,
};
use ghostty_os::osbytes::{EncodedOs, config_str_to_bytes, os_to_config_string};
use crate::{error::Error, tui};
@@ -58,7 +53,7 @@ cli_options!(Options, help);
#[derive(Debug, Clone, PartialEq)]
pub struct ThemeListElement {
pub location: Location,
pub path: String,
pub path: EncodedOs,
pub theme: String,
pub rank: Option<f64>,
}
@@ -76,7 +71,7 @@ impl ThemeListElement {
#[must_use]
pub fn to_uri(&self) -> String {
let mut out = String::from("file://");
for b in ghostty_os::osbytes::config_str_to_bytes(&self.path) {
for b in self.path.to_bytes() {
// std.Uri path component escaping: unreserved + sub-delims + ":@/".
if b.is_ascii_alphanumeric()
|| matches!(
@@ -123,15 +118,15 @@ pub fn location_name(loc: Location) -> &'static str {
/// Returns the unsorted list (in directory order). Errors opening a
/// directory other than "not found" are printed to stderr and skipped.
#[must_use]
pub fn collect_themes(resources_dir: Option<&str>) -> Vec<ThemeListElement> {
pub fn collect_themes(resources_dir: Option<&EncodedOs>) -> Vec<ThemeListElement> {
let mut themes = vec![];
let mut it = LocationIterator::new(resources_dir);
while let Some((location, dir)) = it.next_location() {
let rd = match std::fs::read_dir(config_str_to_path(&dir)) {
let rd = match std::fs::read_dir(dir.to_path()) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
eprintln!("error trying to open {dir}: {e}");
eprintln!("error trying to open {}: {e}", dir.as_encoded());
continue;
}
};
@@ -150,7 +145,7 @@ pub fn collect_themes(resources_dir: Option<&str>) -> Vec<ThemeListElement> {
continue;
}
themes.push(ThemeListElement {
path: path_to_config_string(&config_str_to_path(&dir).join(&raw_name)),
path: EncodedOs::from_path(&dir.to_path().join(&raw_name)),
location,
theme: name,
rank: None,
@@ -174,15 +169,14 @@ pub fn should_include_theme(theme_filter: ColorScheme, theme_config: &Config) ->
}
/// Make `path` absolute (`Config::load_file` requires absolute paths).
pub(crate) fn absolute_path(path: &str) -> String {
std::path::absolute(config_str_to_path(path))
.map_or_else(|_| path.to_string(), |p| path_to_config_string(&p))
pub(crate) fn absolute_path(path: &EncodedOs) -> EncodedOs {
std::path::absolute(path.to_path()).map_or_else(|_| path.clone(), |p| EncodedOs::from_path(&p))
}
fn resources_dir() -> Option<String> {
fn resources_dir() -> Option<EncodedOs> {
ghostty_os::resources_dir()
.ok()
.and_then(|r| r.app().map(str::to_string))
.and_then(|r| r.app().cloned())
}
/// The `list-themes` command is used to preview or list all the available
@@ -238,7 +232,7 @@ pub fn run(args: &[String]) -> Result<u8, Error> {
)?;
}
let mut themes = collect_themes(resources_dir.as_deref());
let mut themes = collect_themes(resources_dir.as_ref());
if themes.is_empty() {
write!(
@@ -281,7 +275,7 @@ pub fn write_plain(
// Names/paths are raw bytes in Zig: decode the reversible escape so
// non-UTF-8 bytes are written back unchanged.
let line = if opts.path {
format!("{} ({loc}) {}\n", theme.theme, theme.path)
format!("{} ({loc}) {}\n", theme.theme, theme.path.as_encoded())
} else {
format!("{} ({loc})\n", theme.theme)
};
@@ -314,7 +308,7 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let raw = std::ffi::OsStr::from_bytes(b"th\xffeme");
std::fs::write(dir.join(raw), "background = #000000\n").unwrap();
let res = tmp.path().to_string_lossy().into_owned();
let res = EncodedOs::from_path(tmp.path());
let themes: Vec<_> = collect_themes(Some(&res))
.into_iter()
.filter(|t| t.location == Location::Resources)
@@ -322,7 +316,7 @@ mod tests {
assert_eq!(1, themes.len());
assert_eq!(config_str_to_bytes(&themes[0].theme), b"th\xffeme".to_vec());
// The path points at the real file and the listing loads it.
assert_eq!(config_str_to_path(&themes[0].path), dir.join(raw));
assert_eq!(themes[0].path.to_path(), dir.join(raw));
let mut out = Vec::new();
write_plain(&mut out, &themes, &Options::default()).unwrap();
assert_eq!(out, b"th\xffeme (resources)\n");
@@ -336,7 +330,7 @@ mod tests {
("Beta", "background = 202020\n"),
(".DS_Store", "junk"),
]);
let res = tmp.path().to_string_lossy().into_owned();
let res = EncodedOs::from_path(tmp.path());
let mut themes: Vec<_> = collect_themes(Some(&res))
.into_iter()
.filter(|t| t.location == Location::Resources)
@@ -365,7 +359,7 @@ mod tests {
);
assert_eq!("Zeta (resources)\n", list(ColorScheme::Light, false));
assert_eq!(
format!("Zeta (resources) {res}/themes/Zeta\n"),
format!("Zeta (resources) {}/themes/Zeta\n", res.as_encoded()),
list(ColorScheme::Light, true)
);
}
@@ -392,7 +386,7 @@ mod tests {
fn uri_escaping() {
let t = ThemeListElement {
location: Location::User,
path: "/a b/c%d".into(),
path: EncodedOs::from_encoded("/a b/c%d"),
theme: "x".into(),
rank: None,
};
@@ -403,7 +397,7 @@ mod tests {
fn uri_escaping_percent_encodes_original_bytes() {
let t = ThemeListElement {
location: Location::User,
path: ghostty_os::osbytes::bytes_to_config_string(b"/a/\xff\xc3x"),
path: EncodedOs::from_bytes(b"/a/\xff\xc3x"),
theme: "x".into(),
rank: None,
};
+29 -27
View File
@@ -353,8 +353,8 @@ enum Loaded {
/// A pending side effect requested by the state (needs the terminal).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
/// Copy the text to the clipboard (OSC 52).
Copy(String),
/// Copy the (decoded) bytes to the clipboard (OSC 52).
Copy(ghostty_os::osbytes::EncodedOs),
}
/// The preview state (Zig: `Preview`).
@@ -373,9 +373,9 @@ pub struct Preview {
mouse: Option<MouseEvent>,
/// The list height as of the last draw (for clamping the window).
list_height: u16,
loaded: Option<(String, Loaded)>,
loaded: Option<(ghostty_os::osbytes::EncodedOs, Loaded)>,
/// Where `w` writes the auto theme file (overridable for tests).
pub auto_theme_path: Option<String>,
pub auto_theme_path: Option<std::path::PathBuf>,
effects: Vec<Effect>,
overlays: Vec<Overlay>,
}
@@ -581,7 +581,8 @@ impl Preview {
}
if is_char('c') {
if let Some(t) = self.selected() {
let name = t.theme.clone();
// Theme names are in the reversible OS-string encoding.
let name = ghostty_os::osbytes::EncodedOs::from_encoded(t.theme.clone());
self.effects.push(Effect::Copy(name));
}
} else if code == KeyCode::Char('C')
@@ -965,10 +966,10 @@ impl Preview {
(child.area.width / 2).saturating_sub(gw(&text) / 2),
);
child.print_ex(
&theme.path,
theme.path.as_encoded(),
ui_err,
middle,
(child.area.width / 2).saturating_sub(gw(&theme.path) / 2),
(child.area.width / 2).saturating_sub(gw(theme.path.as_encoded()) / 2),
Some(&theme.to_uri()),
None,
);
@@ -1024,10 +1025,10 @@ impl Preview {
None,
);
child.print_ex(
&theme.path,
theme.path.as_encoded(),
standard,
2,
(child.area.width / 2).saturating_sub(gw(&theme.path) / 2),
(child.area.width / 2).saturating_sub(gw(theme.path.as_encoded()) / 2),
Some(&uri),
None,
);
@@ -1405,25 +1406,25 @@ fn error_name(e: &ghostty_config::config::Error) -> String {
}
/// `resolveAutoThemePath`: `<config dir>/auto/theme.ghostty`.
pub fn resolve_auto_theme_path() -> Result<String, Error> {
pub fn resolve_auto_theme_path() -> Result<std::path::PathBuf, Error> {
let main_cfg_path = ghostty_config::file_load::preferred_default_file_path()
.map_err(|e| Error::Other(e.to_string()))?;
let main_cfg_path = ghostty_os::osbytes::config_str_to_path(&main_cfg_path);
.map_err(|e| Error::Other(e.to_string()))?
.to_path();
let base_dir = main_cfg_path
.parent()
.ok_or_else(|| Error::Other("BadPathName".to_string()))?;
Ok(ghostty_os::osbytes::path_to_config_string(
&base_dir.join("auto").join("theme.ghostty"),
))
Ok(base_dir.join("auto").join("theme.ghostty"))
}
/// `writeAutoThemeFile`.
pub fn write_auto_theme_file(path: Option<&str>, theme_name: &str) -> Result<(), Error> {
pub fn write_auto_theme_file(
path: Option<&std::path::Path>,
theme_name: &str,
) -> Result<(), Error> {
let auto_path = match path {
Some(p) => p.to_string(),
Some(p) => p.to_path_buf(),
None => resolve_auto_theme_path()?,
};
let auto_path = ghostty_os::osbytes::config_str_to_path(&auto_path);
if let Some(dir) = auto_path.parent() {
std::fs::create_dir_all(dir)?;
}
@@ -1737,11 +1738,7 @@ fn run_loop(app: &mut Preview) -> Result<(), Error> {
match effect {
Effect::Copy(text) => {
let out = terminal.backend_mut();
write!(
out,
"\x1b]52;c;{}\x1b\\",
base64(&ghostty_os::osbytes::config_str_to_bytes(&text))
)?;
write!(out, "\x1b]52;c;{}\x1b\\", base64(&text.to_bytes()))?;
out.flush()?;
}
}
@@ -1788,7 +1785,7 @@ mod tests {
.unwrap();
ThemeListElement {
location: Location::Resources,
path: path.to_string_lossy().into_owned(),
path: ghostty_os::osbytes::EncodedOs::from_path(&path),
theme: (*name).to_string(),
rank: None,
}
@@ -1939,14 +1936,19 @@ mod tests {
KeyModifiers::SHIFT,
)));
let effects = p.take_effects();
assert_eq!(Effect::Copy("alpha".into()), effects[0]);
assert_eq!(
Effect::Copy(tmp.path().join("alpha").to_string_lossy().into_owned()),
Effect::Copy(ghostty_os::osbytes::EncodedOs::from_encoded("alpha")),
effects[0]
);
assert_eq!(
Effect::Copy(ghostty_os::osbytes::EncodedOs::from_path(
&tmp.path().join("alpha")
)),
effects[1]
);
let auto = tmp.path().join("cfg/auto/theme.ghostty");
p.auto_theme_path = Some(auto.to_string_lossy().into_owned());
p.auto_theme_path = Some(auto.clone());
p.update(&key(KeyCode::Enter));
assert_eq!(Mode::Save, p.mode);
p.update(&ch('w'));
+7 -6
View File
@@ -8,6 +8,7 @@ use std::sync::OnceLock;
use ghostty_apprt::ipc;
use ghostty_config::cli_args::{ArgError, ArgIter, CliTarget};
use ghostty_os::osbytes::EncodedOs;
use crate::error::Error;
@@ -56,15 +57,15 @@ impl Options {
if stripped == "home" || stripped == "inherit" {
return Ok(Some(arg.to_string()));
}
let expanded =
ghostty_os::expand_home(stripped).map_err(|e| ArgError::Other(e.to_string()))?;
let realpath =
std::fs::canonicalize(ghostty_os::osbytes::config_str_to_path(&expanded))
.map_err(|e| ArgError::Other(e.to_string()))?;
// The CLI argument is an encoded OS string.
let expanded = ghostty_os::expand_home(&EncodedOs::from_encoded(stripped))
.map_err(|e| ArgError::Other(e.to_string()))?;
let realpath = std::fs::canonicalize(expanded.to_path())
.map_err(|e| ArgError::Other(e.to_string()))?;
self.working_directory_seen = true;
return Ok(Some(format!(
"--working-directory={}",
ghostty_os::osbytes::path_to_config_string(&realpath)
EncodedOs::from_path(&realpath).as_encoded()
)));
}
+15 -1
View File
@@ -59,7 +59,7 @@ impl DiskCache {
home: None,
})
.map_err(|_| Error::XdgLookupFailed)?;
Ok(PathBuf::from(state_dir).join("ssh_cache"))
Ok(state_dir.to_path().join("ssh_cache"))
}
/// Clear all cache data stored in the disk cache.
@@ -346,6 +346,20 @@ fn is_valid_user(user: &str) -> bool {
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
#[cfg(unix)]
#[test]
fn default_path_decodes_non_utf8_xdg_state() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let v = std::ffi::OsString::from_vec(b"/tmp/st\xffate".to_vec());
let p = temp_env::with_var("XDG_STATE_HOME", Some(&v), || {
super::DiskCache::default_path("ghostty").unwrap()
});
assert_eq!(
p.as_os_str().as_bytes(),
b"/tmp/st\xffate/ghostty/ssh_cache"
);
}
#[cfg(not(windows))]
#[test]
fn lock_is_exclusive_until_dropped() {
+3 -2
View File
@@ -3,6 +3,7 @@
use std::io::Write;
use ghostty_config::{config::Config, config_struct};
use ghostty_os::osbytes::EncodedOs;
use crate::error::Error;
@@ -41,8 +42,8 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result<u8, Error> {
if let Some(config_path) = &opts.config_file {
// Byte-preserving: the option string carries non-UTF-8 bytes via the
// reversible `osbytes` escape and the canonical path may too.
let abs_path = std::fs::canonicalize(ghostty_os::osbytes::config_str_to_path(config_path))?;
cfg.load_file(&ghostty_os::osbytes::path_to_config_string(&abs_path))?;
let abs_path = std::fs::canonicalize(EncodedOs::from_encoded(config_path).to_path())?;
cfg.load_file(&EncodedOs::from_path(&abs_path))?;
cfg.load_recursive_files()?;
} else {
cfg = Config::load()?;
+1
View File
@@ -18,3 +18,4 @@ workspace = true
[dev-dependencies]
onig = "6.5.3"
temp-env = "0.3.6"
+69 -44
View File
@@ -6,6 +6,8 @@
use std::collections::HashSet;
use ghostty_os::osbytes::EncodedOs;
use crate::{
cli_args::{self, ArgError, ArgIter, CliTarget},
command::Command,
@@ -3765,7 +3767,7 @@ pub enum ReplayStep {
Arg(String),
/// A base path to expand relative paths against.
Expand(String),
Expand(EncodedOs),
/// A conditional argument. This arg is parsed only if all
/// conditions match (an "AND"). An "OR" can be achieved by
@@ -3876,12 +3878,15 @@ impl Config {
/// Load configuration from the target config file at `path`.
///
/// `path` must be resolved and absolute.
pub fn load_file(&mut self, path: &str) -> Result<(), Error> {
assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute());
pub fn load_file(&mut self, path: &EncodedOs) -> Result<(), Error> {
assert!(path.to_path().is_absolute());
let file = match crate::file_load::open(path) {
Ok(f) => f,
Err(crate::file_load::OpenFileError::NotAFile) => {
log::warn!("config-file {path}: not reading because it is not a file");
log::warn!(
"config-file {}: not reading because it is not a file",
path.as_encoded()
);
return Ok(());
}
Err(crate::file_load::OpenFileError::FileNotFound) => {
@@ -3894,8 +3899,8 @@ impl Config {
}
/// Load config from the given File.
fn load_fs_file(&mut self, file: std::fs::File, path: &str) -> Result<(), Error> {
log::info!("reading configuration file path={path}");
fn load_fs_file(&mut self, file: std::fs::File, path: &EncodedOs) -> Result<(), Error> {
log::info!("reading configuration file path={}", path.as_encoded());
let mut reader = std::io::BufReader::with_capacity(2048, file);
self.load_reader(&mut reader, path)
}
@@ -3904,7 +3909,7 @@ impl Config {
fn load_reader<R: std::io::BufRead>(
&mut self,
reader: &mut R,
path: &str,
path: &EncodedOs,
) -> Result<(), Error> {
// If the file starts with a UTF-8 byte order mark, skip it.
// https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8
@@ -3914,11 +3919,12 @@ impl Config {
reader.consume(3);
}
}
let mut iter = cli_args::LineIterator::with_path(reader, path);
let mut iter = cli_args::LineIterator::with_path(reader, path.as_encoded());
self.load_iter(&mut iter)?;
let dir = ghostty_os::osbytes::config_str_to_path(path)
let dir = path
.to_path()
.parent()
.map(ghostty_os::osbytes::path_to_config_string)
.map(EncodedOs::from_path)
.unwrap_or_default();
self.expand_paths(&dir)?;
Ok(())
@@ -3927,23 +3933,26 @@ impl Config {
/// Load optional configuration file from `path`. All errors are ignored.
///
/// Returns the action that was taken.
pub fn load_optional_file(&mut self, path: &str) -> OptionalFileAction {
pub fn load_optional_file(&mut self, path: &EncodedOs) -> OptionalFileAction {
match self.load_file(path) {
Ok(()) => OptionalFileAction::Loaded,
Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
OptionalFileAction::NotFound
}
Err(err) => {
log::warn!("error reading optional config file, not loading err={err} path={path}");
log::warn!(
"error reading optional config file, not loading err={err} path={}",
path.as_encoded()
);
OptionalFileAction::Error
}
}
}
/// The default template with the (decoded, byte-exact) path substituted.
fn render_config_template(path: &str) -> Vec<u8> {
fn render_config_template(path: &EncodedOs) -> Vec<u8> {
let tmpl = include_str!("config-template");
let path_bytes = ghostty_os::osbytes::config_str_to_bytes(path);
let path_bytes = path.to_bytes();
let mut out = Vec::with_capacity(tmpl.len() + path_bytes.len());
for (i, part) in tmpl.split("{[path]s}").enumerate() {
if i > 0 {
@@ -3954,10 +3963,9 @@ impl Config {
out
}
fn write_config_template(path: &str) -> std::io::Result<()> {
log::info!("creating template config file: path={path}");
// `path` carries the reversible byte encoding; decode it for the OS.
let os_path = ghostty_os::osbytes::config_str_to_path(path);
fn write_config_template(path: &EncodedOs) -> std::io::Result<()> {
log::info!("creating template config file: path={}", path.as_encoded());
let os_path = path.to_path();
if let Some(dir) = os_path.parent() {
std::fs::create_dir_all(dir)?;
}
@@ -3982,7 +3990,11 @@ impl Config {
if xdg_action != OptionalFileAction::NotFound
&& legacy_xdg_action != OptionalFileAction::NotFound
{
log::warn!("both config files `{legacy_xdg_path}` and `{xdg_path}` exist.");
log::warn!(
"both config files `{}` and `{}` exist.",
legacy_xdg_path.as_encoded(),
xdg_path.as_encoded()
);
log::warn!("loading them both in that order");
true
} else {
@@ -4014,7 +4026,9 @@ impl Config {
&& legacy_app_support_action != OptionalFileAction::NotFound
{
log::warn!(
"both config files `{legacy_app_support_path}` and `{app_support_path}` exist."
"both config files `{}` and `{}` exist.",
legacy_app_support_path.as_encoded(),
app_support_path.as_encoded()
);
log::warn!("loading them both in that order");
true
@@ -4142,7 +4156,7 @@ impl Config {
// Any paths referenced from the CLI are relative to the current working
// directory.
let cwd = std::fs::canonicalize(".")?;
self.expand_paths(&ghostty_os::osbytes::path_to_config_string(&cwd))?;
self.expand_paths(&EncodedOs::from_path(&cwd))?;
Ok(())
}
@@ -4153,7 +4167,7 @@ impl Config {
}
// Keeps track of loaded files to prevent cycles.
let mut loaded: HashSet<String> = HashSet::new();
let mut loaded: HashSet<EncodedOs> = HashSet::new();
// We need to insert all of our loaded config-file values
// PRIOR to the "-e" in our replay steps, since everything
@@ -4186,12 +4200,12 @@ impl Config {
// All paths should already be absolute at this point because
// they're fixed up after each load.
assert!(ghostty_os::osbytes::config_str_to_path(&path).is_absolute());
assert!(path.to_path().is_absolute());
// We must only load a unique file once
if !loaded.insert(path.clone()) {
let diag = Diagnostic {
message: format!("config-file {path}: cycle detected"),
message: format!("config-file {}: cycle detected", path.as_encoded()),
..Diagnostic::default()
};
self.diagnostics.append(diag.clone());
@@ -4199,13 +4213,14 @@ impl Config {
continue;
}
let file = match std::fs::File::open(ghostty_os::osbytes::config_str_to_path(&path)) {
let file = match std::fs::File::open(path.to_path()) {
Ok(f) => f,
Err(err) => {
if err.kind() != std::io::ErrorKind::NotFound || !optional {
let diag = Diagnostic {
message: format!(
"error opening config-file {path}: {}",
"error opening config-file {}: {}",
path.as_encoded(),
zig_io_error_name(&err)
),
..Diagnostic::default()
@@ -4221,7 +4236,8 @@ impl Config {
if !meta.is_file() {
let diag = Diagnostic {
message: format!(
"config-file {path}: not reading because file type is {}",
"config-file {}: not reading because file type is {}",
path.as_encoded(),
file_kind_name(&meta)
),
..Diagnostic::default()
@@ -4347,9 +4363,9 @@ impl Config {
/// Expand the relative paths in config-files to be absolute paths
/// relative to the base directory.
fn expand_paths(&mut self, base: &str) -> Result<(), Error> {
fn expand_paths(&mut self, base: &EncodedOs) -> Result<(), Error> {
// Keep track of this step for replays
self.replay_steps.push(ReplayStep::Expand(base.to_string()));
self.replay_steps.push(ReplayStep::Expand(base.clone()));
// Expand all of our paths
if let Some(path) = &mut self.background_image {
@@ -4376,7 +4392,7 @@ impl Config {
// Find our theme file and open it. See the open function for details.
let resources_dir = ghostty_os::resources_dir().ok();
let Some(themefile) = crate::theme::open(
name,
&EncodedOs::from_encoded(name),
resources_dir.as_ref().and_then(|r| r.app()),
&mut self.diagnostics,
) else {
@@ -4400,7 +4416,7 @@ impl Config {
// Load our theme
let mut reader = std::io::BufReader::with_capacity(2048, file);
let mut iter = cli_args::LineIterator::with_path(&mut reader, path);
let mut iter = cli_args::LineIterator::with_path(&mut reader, path.as_encoded());
new_config.load_iter(&mut iter)?;
// Setup our replay to be conditional.
@@ -4641,14 +4657,17 @@ impl Config {
let pw = ghostty_os::passwd::get()?;
if self.command.is_none() {
if let Some(sh) = pw.shell {
log::info!("default shell src=passwd value={sh}");
self.command = Some(Command::Shell(sh));
log::info!("default shell src=passwd value={}", sh.as_encoded());
self.command = Some(Command::Shell(sh.into_encoded()));
}
}
if *wd == WorkingDirectory::Home {
if let Some(home) = pw.home {
log::info!("default working directory src=passwd value={home}");
log::info!(
"default working directory src=passwd value={}",
home.as_encoded()
);
*wd = WorkingDirectory::Path(home);
} else {
*wd = WorkingDirectory::Inherit;
@@ -4951,7 +4970,7 @@ mod tests {
let td = ghostty_os::TempDir::init().unwrap();
let dir = td.path().join(std::ffi::OsStr::from_bytes(b"cfg\xff"));
let file = dir.join("config.ghostty");
let enc = ghostty_os::osbytes::path_to_config_string(&file);
let enc = EncodedOs::from_path(&file);
Config::write_config_template(&enc).unwrap();
let data = std::fs::read(&file).unwrap();
let raw = file.as_os_str().as_bytes();
@@ -4985,8 +5004,11 @@ mod tests {
let data = "\u{feff}abnormal-command-exit-runtime = 2500\n";
let mut reader = Cursor::new(data.as_bytes().to_vec());
let mut cfg = Config::default();
cfg.load_reader(&mut reader, "/home/ghostty/.config/ghostty/config.ghostty")
.unwrap();
cfg.load_reader(
&mut reader,
&EncodedOs::from_encoded("/home/ghostty/.config/ghostty/config.ghostty"),
)
.unwrap();
cfg.finalize().unwrap();
assert!(cfg.diagnostics.empty());
@@ -4997,8 +5019,11 @@ mod tests {
let data = "abnormal-command-exit-runtime = 2500\n";
let mut reader = Cursor::new(data.as_bytes().to_vec());
let mut cfg = Config::default();
cfg.load_reader(&mut reader, "/home/ghostty/.config/ghostty/config.ghostty")
.unwrap();
cfg.load_reader(
&mut reader,
&EncodedOs::from_encoded("/home/ghostty/.config/ghostty/config.ghostty"),
)
.unwrap();
cfg.finalize().unwrap();
assert!(cfg.diagnostics.empty());
@@ -5141,11 +5166,11 @@ mod tests {
cfg.load_iter(&mut i).unwrap();
cfg.finalize().unwrap();
let expected = ghostty_os::expand_home("~/projects/ghostty")
.unwrap_or_else(|_| "~/projects/ghostty".to_string());
let expected = ghostty_os::expand_home(&EncodedOs::from_encoded("~/projects/ghostty"))
.unwrap_or_else(|_| EncodedOs::from_encoded("~/projects/ghostty"));
assert_eq!(
cfg.working_directory.as_ref().unwrap().value().unwrap(),
expected
&expected
);
}
@@ -5420,7 +5445,7 @@ mod tests {
let mut cfg = Config::default();
cfg.load_iter(&mut it(&[&format!("--config-file={}", a.display())]))
.unwrap();
cfg.expand_paths("/").unwrap();
cfg.expand_paths(&EncodedOs::from_encoded("/")).unwrap();
cfg.load_recursive_files().unwrap();
assert!(
cfg.diagnostics
@@ -5435,7 +5460,7 @@ mod tests {
let mut cfg = Config::default();
cfg.load_iter(&mut it(&["--config-file=?/nonexistent/ghostty/file"]))
.unwrap();
cfg.expand_paths("/").unwrap();
cfg.expand_paths(&EncodedOs::from_encoded("/")).unwrap();
cfg.load_recursive_files().unwrap();
assert!(cfg.diagnostics.empty());
}
+49 -8
View File
@@ -1,6 +1,8 @@
//! Opening the config for editing (port of `config/edit.zig`).
use std::{fs::OpenOptions, io, path::Path};
use std::{fs::OpenOptions, io};
use ghostty_os::osbytes::EncodedOs;
use crate::file_load;
@@ -13,12 +15,16 @@ use crate::file_load;
/// come first.
///
/// The config file (and its directory) is created if it doesn't exist.
pub fn open_path() -> io::Result<String> {
pub fn open_path() -> io::Result<EncodedOs> {
// Get the path we should open
let config_path = config_path()?;
// The config path is an encoded OS string: decode before touching the
// filesystem.
let real_path = config_path.to_path();
// Create config directory recursively.
if let Some(config_dir) = Path::new(&config_path).parent() {
if let Some(config_dir) = real_path.parent() {
std::fs::create_dir_all(config_dir)?;
}
@@ -26,7 +32,7 @@ pub fn open_path() -> io::Result<String> {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&config_path)
.open(&real_path)
{
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
@@ -41,15 +47,15 @@ fn xdg_err(e: ghostty_os::xdg::Error) -> io::Error {
}
/// Returns the config path to use for open for the current OS.
fn config_path() -> io::Result<String> {
fn config_path() -> io::Result<EncodedOs> {
let paths = config_path_candidates()?;
// Find the first path that exists and is non-empty. If no paths are
// non-empty but at least one exists, we will return the first path that
// exists.
let mut exists: Option<&String> = None;
let mut exists: Option<&EncodedOs> = None;
for path in &paths {
let meta = match std::fs::File::open(path).and_then(|f| f.metadata()) {
let meta = match std::fs::File::open(path.to_path()).and_then(|f| f.metadata()) {
Ok(m) => m,
// File doesn't exist, continue.
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
@@ -79,7 +85,7 @@ fn config_path() -> io::Result<String> {
/// Returns a list of possible paths the main config file could be
/// in for the current OS.
fn config_path_candidates() -> io::Result<Vec<String>> {
fn config_path_candidates() -> io::Result<Vec<EncodedOs>> {
let mut paths = Vec::with_capacity(4);
#[cfg(target_os = "macos")]
@@ -92,3 +98,38 @@ fn config_path_candidates() -> io::Result<Vec<String>> {
paths.push(file_load::legacy_default_xdg_path().map_err(xdg_err)?);
Ok(paths)
}
#[cfg(all(test, unix, not(target_os = "macos")))]
mod tests {
#![allow(clippy::unwrap_used)]
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use super::*;
/// "Open config" must create/return the file under the *decoded* XDG
/// directory, not a second file at the escaped pathname.
#[test]
fn open_path_uses_decoded_non_utf8_xdg_dir() {
let td = ghostty_os::TempDir::init().unwrap();
let xdg = td.path().join(std::ffi::OsStr::from_bytes(b"cfg\xff"));
let value = std::ffi::OsString::from_vec(xdg.as_os_str().as_bytes().to_vec());
temp_env::with_var("XDG_CONFIG_HOME", Some(&value), || {
let p = open_path().unwrap();
let real = p.to_path();
assert_eq!(real, xdg.join("ghostty/config.ghostty"));
assert!(real.is_file());
// Nothing was created at the escaped (encoded) pathname.
assert_ne!(p.as_encoded().as_bytes(), real.as_os_str().as_bytes());
assert!(!std::path::Path::new(p.as_encoded()).exists());
});
}
#[test]
fn candidates_are_xdg_paths_in_priority_order() {
temp_env::with_var("XDG_CONFIG_HOME", Some("/x/cfg"), || {
let c = config_path_candidates().unwrap();
assert_eq!(c[0], "/x/cfg/ghostty/config.ghostty");
assert_eq!(c[1], "/x/cfg/ghostty/config");
});
}
}
+46 -39
View File
@@ -7,10 +7,10 @@
use std::fs::File;
use ghostty_os::xdg;
use ghostty_os::{osbytes::EncodedOs, xdg};
/// Default path for the XDG home configuration file.
pub fn default_xdg_path() -> Result<String, xdg::Error> {
pub fn default_xdg_path() -> Result<EncodedOs, xdg::Error> {
xdg::config(&xdg::Options {
subdir: Some("ghostty/config.ghostty"),
home: None,
@@ -18,7 +18,7 @@ pub fn default_xdg_path() -> Result<String, xdg::Error> {
}
/// Ghostty <1.3.0 default path for the XDG home configuration file.
pub fn legacy_default_xdg_path() -> Result<String, xdg::Error> {
pub fn legacy_default_xdg_path() -> Result<EncodedOs, xdg::Error> {
xdg::config(&xdg::Options {
subdir: Some("ghostty/config"),
home: None,
@@ -26,7 +26,7 @@ pub fn legacy_default_xdg_path() -> Result<String, xdg::Error> {
}
/// Preferred default path for the XDG home configuration file.
pub fn preferred_xdg_path() -> Result<String, xdg::Error> {
pub fn preferred_xdg_path() -> Result<EncodedOs, xdg::Error> {
// If the XDG path exists, use that.
let xdg_path = default_xdg_path()?;
if open(&xdg_path).is_ok() {
@@ -45,28 +45,28 @@ pub fn preferred_xdg_path() -> Result<String, xdg::Error> {
/// Default path for the macOS Application Support configuration file.
#[cfg(target_os = "macos")]
pub fn default_app_support_path() -> Result<String, xdg::Error> {
pub fn default_app_support_path() -> Result<EncodedOs, xdg::Error> {
app_support_dir("config.ghostty")
}
/// Ghostty <1.3.0 default path for the macOS Application Support
/// configuration file.
#[cfg(target_os = "macos")]
pub fn legacy_default_app_support_path() -> Result<String, xdg::Error> {
pub fn legacy_default_app_support_path() -> Result<EncodedOs, xdg::Error> {
app_support_dir("config")
}
#[cfg(target_os = "macos")]
fn app_support_dir(sub_path: &str) -> Result<String, xdg::Error> {
fn app_support_dir(sub_path: &str) -> Result<EncodedOs, xdg::Error> {
ghostty_os::macos::app_support_dir(sub_path)
.map(|p| ghostty_os::osbytes::path_to_config_string(&p))
.map(|p| EncodedOs::from_path(&p))
.map_err(|_| xdg::Error::AppleApiFailed)
}
/// Preferred default path for the macOS Application Support configuration
/// file.
#[cfg(target_os = "macos")]
pub fn preferred_app_support_path() -> Result<String, xdg::Error> {
pub fn preferred_app_support_path() -> Result<EncodedOs, xdg::Error> {
Ok(preferred_of(
default_app_support_path()?,
legacy_default_app_support_path()?,
@@ -76,7 +76,7 @@ pub fn preferred_app_support_path() -> Result<String, xdg::Error> {
/// Pick between a new path and its legacy path: the new one if it exists,
/// else the legacy one if it exists, else the new one.
#[cfg(any(target_os = "macos", test))]
fn preferred_of(new: String, legacy: String) -> String {
fn preferred_of(new: EncodedOs, legacy: EncodedOs) -> EncodedOs {
// If the new path exists, use that.
if open(&new).is_ok() {
return new;
@@ -95,9 +95,9 @@ fn preferred_of(new: String, legacy: String) -> String {
/// one if it exists, else Application Support.
#[cfg(any(target_os = "macos", test))]
fn preferred_macos(
app_support_path: String,
xdg_path: &dyn Fn() -> Result<String, xdg::Error>,
) -> Result<String, xdg::Error> {
app_support_path: EncodedOs,
xdg_path: &dyn Fn() -> Result<EncodedOs, xdg::Error>,
) -> Result<EncodedOs, xdg::Error> {
// macOS prefers the Application Support directory if it exists.
if open(&app_support_path).is_ok() {
return Ok(app_support_path);
@@ -119,7 +119,7 @@ fn preferred_macos(
/// This doesn't create or populate the file with any default
/// contents; downstream callers must handle this.
#[cfg(target_os = "macos")]
pub fn preferred_default_file_path() -> Result<String, xdg::Error> {
pub fn preferred_default_file_path() -> Result<EncodedOs, xdg::Error> {
preferred_macos(preferred_app_support_path()?, &preferred_xdg_path)
}
@@ -129,7 +129,7 @@ pub fn preferred_default_file_path() -> Result<String, xdg::Error> {
/// This doesn't create or populate the file with any default
/// contents; downstream callers must handle this.
#[cfg(not(target_os = "macos"))]
pub fn preferred_default_file_path() -> Result<String, xdg::Error> {
pub fn preferred_default_file_path() -> Result<EncodedOs, xdg::Error> {
// All other platforms use XDG only
preferred_xdg_path()
}
@@ -158,21 +158,24 @@ impl std::error::Error for OpenFileError {}
/// Opens the file at the given path and returns the file handle
/// if it exists and is non-empty. This also constrains the possible
/// errors to a smaller set that we can explicitly handle.
pub fn open(path: &str) -> Result<File, OpenFileError> {
assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute());
pub fn open(path: &EncodedOs) -> Result<File, OpenFileError> {
let real = path.to_path();
assert!(real.is_absolute());
// `path` may carry the reversible byte escape of non-UTF-8 names.
let file = File::open(ghostty_os::osbytes::config_str_to_os(path)).map_err(|err| {
let file = File::open(&real).map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
OpenFileError::FileNotFound
} else {
log::warn!("unexpected file open error path={path} err={err}");
log::warn!(
"unexpected file open error path={} err={err}",
real.display()
);
OpenFileError::FileOpenFailed
}
})?;
let meta = file.metadata().map_err(|err| {
log::warn!("error getting file stat path={path} err={err}");
log::warn!("error getting file stat path={} err={err}", real.display());
OpenFileError::FileOpenFailed
})?;
if !meta.is_file() {
@@ -190,21 +193,25 @@ pub fn open(path: &str) -> Result<File, OpenFileError> {
mod tests {
use super::*;
fn e(s: &str) -> EncodedOs {
EncodedOs::from_encoded(s)
}
#[test]
fn open_missing_dir_and_empty() {
assert_eq!(
open("/nonexistent/ghostty/file").unwrap_err(),
open(&e("/nonexistent/ghostty/file")).unwrap_err(),
OpenFileError::FileNotFound
);
assert_eq!(open("/tmp").unwrap_err(), OpenFileError::NotAFile);
assert_eq!(open(&e("/tmp")).unwrap_err(), OpenFileError::NotAFile);
let p = std::env::temp_dir().join(format!("ghostty-file-load-{}", std::process::id()));
std::fs::write(&p, "").unwrap();
assert_eq!(
open(p.to_str().unwrap()).unwrap_err(),
open(&EncodedOs::from_path(&p)).unwrap_err(),
OpenFileError::FileIsEmpty
);
std::fs::write(&p, "a=b").unwrap();
assert!(open(p.to_str().unwrap()).is_ok());
assert!(open(&EncodedOs::from_path(&p)).is_ok());
std::fs::remove_file(&p).unwrap();
}
@@ -212,18 +219,18 @@ mod tests {
fn preferred_of_prefers_existing_new_then_legacy() {
let dir = std::env::temp_dir().join(format!("ghostty-pref-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let new = dir.join("config.ghostty").to_str().unwrap().to_owned();
let legacy = dir.join("config").to_str().unwrap().to_owned();
let _ = std::fs::remove_file(&new);
let _ = std::fs::remove_file(&legacy);
let new = EncodedOs::from_path(&dir.join("config.ghostty"));
let legacy = EncodedOs::from_path(&dir.join("config"));
let _ = std::fs::remove_file(new.to_path());
let _ = std::fs::remove_file(legacy.to_path());
// Neither exists: the new one.
assert_eq!(preferred_of(new.clone(), legacy.clone()), new);
// Only legacy exists: the legacy one.
std::fs::write(&legacy, "a=b").unwrap();
std::fs::write(legacy.to_path(), "a=b").unwrap();
assert_eq!(preferred_of(new.clone(), legacy.clone()), legacy);
// Both exist: the new one.
std::fs::write(&new, "a=b").unwrap();
std::fs::write(new.to_path(), "a=b").unwrap();
assert_eq!(preferred_of(new.clone(), legacy.clone()), new);
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -232,19 +239,19 @@ mod tests {
fn preferred_macos_order() {
let dir = std::env::temp_dir().join(format!("ghostty-pref-mac-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let app = dir.join("app").to_str().unwrap().to_owned();
let xdg_p = dir.join("xdg").to_str().unwrap().to_owned();
let _ = std::fs::remove_file(&app);
let _ = std::fs::remove_file(&xdg_p);
let app = EncodedOs::from_path(&dir.join("app"));
let xdg_p = EncodedOs::from_path(&dir.join("xdg"));
let _ = std::fs::remove_file(app.to_path());
let _ = std::fs::remove_file(xdg_p.to_path());
let xdg_fn = || Ok(xdg_p.clone());
// Neither exists: app support.
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app);
// XDG exists only: XDG.
std::fs::write(&xdg_p, "a=b").unwrap();
std::fs::write(xdg_p.to_path(), "a=b").unwrap();
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), xdg_p);
// App support exists: app support wins.
std::fs::write(&app, "a=b").unwrap();
std::fs::write(app.to_path(), "a=b").unwrap();
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app);
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -252,8 +259,8 @@ mod tests {
#[test]
fn default_paths_end_with_expected_names() {
if let (Ok(a), Ok(b)) = (default_xdg_path(), legacy_default_xdg_path()) {
assert!(a.ends_with("ghostty/config.ghostty"));
assert!(b.ends_with("ghostty/config"));
assert!(a.ends_with_plain("ghostty/config.ghostty"));
assert!(b.ends_with_plain("ghostty/config"));
}
}
}
+70 -62
View File
@@ -5,7 +5,7 @@ use std::{
path::{Component, PathBuf},
};
use ghostty_os::osbytes::{config_str_to_path, path_to_config_string};
use ghostty_os::osbytes::EncodedOs;
use crate::{
c_value::CValue,
@@ -20,21 +20,21 @@ use crate::{
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Path {
/// No error if the file does not exist.
Optional(String),
Optional(EncodedOs),
/// The file is required to exist.
Required(String),
Required(EncodedOs),
}
impl Default for Path {
fn default() -> Self {
Path::Required(String::new())
Path::Required(EncodedOs::new())
}
}
/// Lexically resolve `base/path` like `std.fs.path.resolve`.
fn resolve_lexical(base: &str, path: &str) -> String {
let joined = config_str_to_path(base).join(config_str_to_path(path));
fn resolve_lexical(base: &EncodedOs, path: &EncodedOs) -> EncodedOs {
let joined = base.to_path().join(path.to_path());
let mut out = PathBuf::new();
for c in joined.components() {
match c {
@@ -45,29 +45,24 @@ fn resolve_lexical(base: &str, path: &str) -> String {
other => out.push(other.as_os_str()),
}
}
path_to_config_string(&out)
EncodedOs::from_path(&out)
}
impl Path {
/// The inner path string.
#[must_use]
pub fn path(&self) -> &str {
pub fn path(&self) -> &EncodedOs {
match self {
Path::Optional(p) | Path::Required(p) => p,
}
}
fn path_mut(&mut self) -> &mut String {
fn path_mut(&mut self) -> &mut EncodedOs {
match self {
Path::Optional(p) | Path::Required(p) => p,
}
}
#[must_use]
pub fn len(&self) -> usize {
self.path().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.path().is_empty()
@@ -98,9 +93,9 @@ impl Path {
}
Ok(Some(if optional {
Path::Optional(value.to_string())
Path::Optional(EncodedOs::from_encoded(value))
} else {
Path::Required(value.to_string())
Path::Required(EncodedOs::from_encoded(value))
}))
}
@@ -110,16 +105,15 @@ impl Path {
/// `base` is the base directory to expand relative paths; it must be an
/// absolute path. Errors are added to `diags` if they occur; an `Err` is
/// only returned if the base directory cannot be opened.
pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> {
// Config strings carry non-UTF-8 bytes via a reversible escape
// encoding; filesystem calls use the decoded OS paths.
let base_path = config_str_to_path(base);
pub fn expand(&mut self, base: &EncodedOs, diags: &mut DiagnosticList) -> std::io::Result<()> {
// Filesystem calls use the decoded OS paths.
let base_path = base.to_path();
assert!(base_path.is_absolute());
let path = self.path().to_string();
let path = self.path().clone();
// If it is already absolute we can ignore it.
if path.is_empty() || config_str_to_path(&path).is_absolute() {
if path.is_empty() || path.to_path().is_absolute() {
return Ok(());
}
@@ -127,21 +121,27 @@ impl Path {
// home directory. We explicitly look for "~/" because we don't
// support alternate users such as "~alice/"
// (Windows isn't supported yet: Zig skips this there.)
if !cfg!(windows) && path.starts_with("~/") {
if !cfg!(windows) && path.starts_with_plain("~/") {
match ghostty_os::expand_home(&path) {
Ok(expanded) => {
log::debug!("expanding file path from home directory: path={expanded}");
log::debug!(
"expanding file path from home directory: path={}",
expanded.as_encoded()
);
*self.path_mut() = expanded;
}
Err(err) => {
diags.append(Diagnostic {
message: format!("error expanding home directory for path {path}: {err}"),
message: format!(
"error expanding home directory for path {}: {err}",
path.as_encoded()
),
..Diagnostic::default()
});
// Blank this path so that we don't attempt to resolve it
// again
*self = Path::Required(String::new());
*self = Path::Required(EncodedOs::new());
}
}
return Ok(());
@@ -150,9 +150,9 @@ impl Path {
// Make sure the base directory can be opened.
std::fs::read_dir(&base_path)?;
let full = base_path.join(config_str_to_path(&path));
let full = base_path.join(path.to_path());
let abs = match std::fs::canonicalize(&full) {
Ok(p) => path_to_config_string(&p),
Ok(p) => EncodedOs::from_path(&p),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
// The file doesn't exist. Try to resolve the relative path
// another way.
@@ -160,17 +160,21 @@ impl Path {
}
Err(err) => {
diags.append(Diagnostic {
message: format!("error resolving file path {path}: {err}"),
message: format!("error resolving file path {}: {err}", path.as_encoded()),
..Diagnostic::default()
});
// Blank this path so that we don't attempt to resolve it again
*self = Path::Required(String::new());
*self = Path::Required(EncodedOs::new());
return Ok(());
}
};
log::debug!("expanding file path relative={path} abs={abs}");
log::debug!(
"expanding file path relative={} abs={}",
path.as_encoded(),
abs.as_encoded()
);
*self.path_mut() = abs;
Ok(())
}
@@ -180,11 +184,11 @@ impl ConfigField for Path {
fn c_value(&self) -> CValue {
match self {
Path::Optional(p) => CValue::Path {
path: p.clone(),
path: p.as_encoded().to_owned(),
optional: true,
},
Path::Required(p) => CValue::Path {
path: p.clone(),
path: p.as_encoded().to_owned(),
optional: false,
},
}
@@ -205,8 +209,8 @@ impl ConfigField for Path {
/// Used by formatter.
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
match self {
Path::Optional(p) => writeln!(f.writer, "{} = ?{p}", f.name),
Path::Required(p) => writeln!(f.writer, "{} = {p}", f.name),
Path::Optional(p) => writeln!(f.writer, "{} = ?{}", f.name, p.as_encoded()),
Path::Required(p) => writeln!(f.writer, "{} = {}", f.name, p.as_encoded()),
}
}
}
@@ -222,7 +226,7 @@ pub struct RepeatablePath {
impl RepeatablePath {
/// Expand all the paths relative to the base directory.
pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> {
pub fn expand(&mut self, base: &EncodedOs, diags: &mut DiagnosticList) -> std::io::Result<()> {
for path in &mut self.value {
path.expand(base, diags)?;
}
@@ -267,6 +271,10 @@ mod tests {
use super::*;
use crate::formatter::format_entry;
fn e(s: &str) -> EncodedOs {
EncodedOs::from_encoded(s)
}
fn fmt<T: ConfigField>(v: &T) -> String {
let mut s = String::new();
format_entry("a", v, &mut s).unwrap();
@@ -276,13 +284,13 @@ mod tests {
#[test]
fn path_parse() {
let p = |s| Path::parse(Some(s)).unwrap().unwrap();
assert_eq!(p("config.1"), Path::Required("config.1".into()));
assert_eq!(p("?config.2"), Path::Optional("config.2".into()));
assert_eq!(p("\"?config.3\""), Path::Required("?config.3".into()));
assert_eq!(p("?\"config.4\""), Path::Optional("config.4".into()));
assert_eq!(p("?"), Path::Optional(String::new()));
assert_eq!(p("\"\""), Path::Required(String::new()));
assert_eq!(p("?\"\""), Path::Optional(String::new()));
assert_eq!(p("config.1"), Path::Required(e("config.1")));
assert_eq!(p("?config.2"), Path::Optional(e("config.2")));
assert_eq!(p("\"?config.3\""), Path::Required(e("?config.3")));
assert_eq!(p("?\"config.4\""), Path::Optional(e("config.4")));
assert_eq!(p("?"), Path::Optional(EncodedOs::new()));
assert_eq!(p("\"\""), Path::Required(EncodedOs::new()));
assert_eq!(p("?\"\""), Path::Optional(EncodedOs::new()));
assert_eq!(Path::parse(None), Err(ArgError::ValueRequired));
assert_eq!(Path::parse(Some("")), Ok(None));
}
@@ -292,21 +300,21 @@ mod tests {
let mut item = Path::default();
item.parse_cli(Some("config.1")).unwrap();
assert_eq!(item, Path::Required("config.1".into()));
assert_eq!(item, Path::Required(e("config.1")));
item.parse_cli(Some("?config.2")).unwrap();
assert_eq!(item, Path::Optional("config.2".into()));
assert_eq!(item, Path::Optional(e("config.2")));
item.parse_cli(Some("\"?config.3\"")).unwrap();
assert_eq!(item, Path::Required("?config.3".into()));
assert_eq!(item, Path::Required(e("?config.3")));
// Zero-length values, ignored
item.parse_cli(Some("?")).unwrap();
assert_eq!(item, Path::Required("?config.3".into()));
assert_eq!(item, Path::Required(e("?config.3")));
item.parse_cli(Some("\"\"")).unwrap();
assert_eq!(item, Path::Required("?config.3".into()));
assert_eq!(item, Path::Required(e("?config.3")));
item.parse_cli(Some("?\"\"")).unwrap();
assert_eq!(item, Path::Required("?config.3".into()));
assert_eq!(item, Path::Required(e("?config.3")));
}
#[test]
@@ -339,9 +347,9 @@ mod tests {
assert_eq!(list.value.len(), 3);
assert_eq!(list.value[0], Path::Required("config.1".into()));
assert_eq!(list.value[1], Path::Optional("config.2".into()));
assert_eq!(list.value[2], Path::Required("?config.3".into()));
assert_eq!(list.value[0], Path::Required(e("config.1")));
assert_eq!(list.value[1], Path::Optional(e("config.2")));
assert_eq!(list.value[2], Path::Required(e("?config.3")));
list.parse_cli(Some("")).unwrap();
assert_eq!(list.value.len(), 0);
@@ -370,13 +378,13 @@ mod tests {
#[test]
fn path_expand_relative_nonexistent_and_absolute() {
let mut diags = DiagnosticList::default();
let mut p = Path::Required("../nope/x.conf".into());
p.expand("/tmp", &mut diags).unwrap();
assert_eq!(p, Path::Required("/nope/x.conf".into()));
let mut p = Path::Required(e("../nope/x.conf"));
p.expand(&e("/tmp"), &mut diags).unwrap();
assert_eq!(p, Path::Required(e("/nope/x.conf")));
let mut p = Path::Optional("/abs/path".into());
p.expand("/tmp", &mut diags).unwrap();
assert_eq!(p, Path::Optional("/abs/path".into()));
let mut p = Path::Optional(e("/abs/path"));
p.expand(&e("/tmp"), &mut diags).unwrap();
assert_eq!(p, Path::Optional(e("/abs/path")));
assert!(diags.is_empty());
}
@@ -390,12 +398,12 @@ mod tests {
std::fs::write(&target, b"x").unwrap();
std::os::unix::fs::symlink(&target, base.join("link")).unwrap();
let mut p = Path::Required("link".into());
let mut p = Path::Required(e("link"));
let mut diags = DiagnosticList::default();
p.expand(&path_to_config_string(&base), &mut diags).unwrap();
p.expand(&EncodedOs::from_path(&base), &mut diags).unwrap();
assert_eq!(diags.items().len(), 0);
// The canonical (non-UTF-8) target is preserved byte for byte.
assert_eq!(config_str_to_path(p.path()), target);
assert!(std::fs::metadata(config_str_to_path(p.path())).is_ok());
assert_eq!(p.path().to_path(), target);
assert!(std::fs::metadata(p.path().to_path()).is_ok());
}
}
+46 -39
View File
@@ -5,12 +5,7 @@
use std::fs::File;
use ghostty_os::osbytes::{
config_str_to_os,
config_str_to_path,
os_to_config_string,
path_to_config_string,
};
use ghostty_os::osbytes::EncodedOs;
use crate::diagnostics::{Diagnostic, DiagnosticList};
@@ -33,7 +28,7 @@ impl Location {
/// invalid for any reason. For example, it is perfectly valid to
/// install and run Ghostty without the resources directory.
#[must_use]
pub fn dir(self, resources_dir: Option<&str>) -> Option<String> {
pub fn dir(self, resources_dir: Option<&EncodedOs>) -> Option<EncodedOs> {
match self {
Location::User => ghostty_os::xdg::config(&ghostty_os::xdg::Options {
subdir: Some("ghostty/themes"),
@@ -42,9 +37,7 @@ impl Location {
.ok(),
Location::Resources => {
let base = resources_dir?;
Some(path_to_config_string(
&config_str_to_path(base).join("themes"),
))
Some(EncodedOs::from_path(&base.to_path().join("themes")))
}
}
}
@@ -54,24 +47,24 @@ impl Location {
/// order of priority.
#[derive(Debug, Clone)]
pub struct LocationIterator {
pub resources_dir: Option<String>,
pub resources_dir: Option<EncodedOs>,
pub i: usize,
}
impl LocationIterator {
#[must_use]
pub fn new(resources_dir: Option<&str>) -> Self {
pub fn new(resources_dir: Option<&EncodedOs>) -> Self {
Self {
resources_dir: resources_dir.map(str::to_string),
resources_dir: resources_dir.cloned(),
i: 0,
}
}
pub fn next_location(&mut self) -> Option<(Location, String)> {
pub fn next_location(&mut self) -> Option<(Location, EncodedOs)> {
while self.i < Location::ALL.len() {
let location = Location::ALL[self.i];
self.i += 1;
if let Some(dir) = location.dir(self.resources_dir.as_deref()) {
if let Some(dir) = location.dir(self.resources_dir.as_ref()) {
return Some((location, dir));
}
}
@@ -86,7 +79,7 @@ impl LocationIterator {
/// An opened theme.
#[derive(Debug)]
pub struct OpenedTheme {
pub path: String,
pub path: EncodedOs,
pub file: File,
}
@@ -160,31 +153,34 @@ fn check_regular(file: &File, theme: &str, diags: &mut DiagnosticList) -> bool {
/// the theme resolves to something other than a regular file a diagnostic entry
/// will be added to the list and None will be returned.
pub fn open(
theme: &str,
resources_dir: Option<&str>,
theme: &EncodedOs,
resources_dir: Option<&EncodedOs>,
diags: &mut DiagnosticList,
) -> Option<OpenedTheme> {
let theme_path = theme.to_path();
let theme_text = theme.as_encoded();
// Absolute themes are loaded a different path.
if config_str_to_path(theme).is_absolute() {
if theme_path.is_absolute() {
let file = open_absolute(theme, diags)?;
if !check_regular(&file, theme, diags) {
if !check_regular(&file, theme_text, diags) {
return None;
}
return Some(OpenedTheme {
path: theme.to_string(),
path: theme.clone(),
file,
});
}
let basename = config_str_to_path(theme)
let basename = theme_path
.file_name()
.map(os_to_config_string)
.map(EncodedOs::from_os)
.unwrap_or_default();
if theme != basename {
if *theme != basename {
diag(
diags,
format!(
"theme \"{theme}\" cannot include path separators unless it is an absolute path"
"theme \"{theme_text}\" cannot include path separators unless it is an absolute path"
),
);
return None;
@@ -194,10 +190,10 @@ pub fn open(
// one that exists.
let mut it = LocationIterator::new(resources_dir);
while let Some((_, dir)) = it.next_location() {
let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme)));
match File::open(config_str_to_path(&path)) {
let path = EncodedOs::from_path(&dir.to_path().join(theme.to_os()));
match File::open(path.to_path()) {
Ok(file) => {
if !check_regular(&file, theme, diags) {
if !check_regular(&file, theme_text, diags) {
return None;
}
return Some(OpenedTheme { path, file });
@@ -208,7 +204,10 @@ pub fn open(
Err(err) => {
diag(
diags,
format!("failed to load theme \"{theme}\" from the file \"{path}\": {err}"),
format!(
"failed to load theme \"{theme_text}\" from the file \"{}\": {err}",
path.as_encoded()
),
);
return None;
}
@@ -219,10 +218,13 @@ pub fn open(
// our iterator, reiterate over in order to build a better error message.
it.reset();
while let Some((_, dir)) = it.next_location() {
let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme)));
let path = EncodedOs::from_path(&dir.to_path().join(theme.to_os()));
diag(
diags,
format!("theme \"{theme}\" not found, tried path \"{path}\""),
format!(
"theme \"{theme_text}\" not found, tried path \"{}\"",
path.as_encoded()
),
);
}
@@ -232,8 +234,9 @@ pub fn open(
/// Open the given theme from an absolute path. If there are any errors
/// then messages will be appended to the given error list and None is
/// returned.
pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option<File> {
match File::open(config_str_to_path(theme)) {
pub fn open_absolute(theme: &EncodedOs, diags: &mut DiagnosticList) -> Option<File> {
let theme = theme.as_encoded();
match File::open(EncodedOs::from_encoded(theme).to_path()) {
Ok(f) => Some(f),
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
@@ -257,10 +260,14 @@ pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option<File> {
mod tests {
use super::*;
fn e(s: &str) -> EncodedOs {
EncodedOs::from_encoded(s)
}
#[test]
fn theme_path_separators_rejected() {
let mut d = DiagnosticList::default();
assert!(open("a/b", None, &mut d).is_none());
assert!(open(&e("a/b"), None, &mut d).is_none());
assert!(
d.items()[0]
.message
@@ -271,13 +278,13 @@ mod tests {
#[test]
fn theme_absolute_missing_and_dir() {
let mut d = DiagnosticList::default();
assert!(open("/nonexistent/ghostty/theme", None, &mut d).is_none());
assert!(open(&e("/nonexistent/ghostty/theme"), None, &mut d).is_none());
assert_eq!(
d.items()[0].message,
"failed to load theme from the path \"/nonexistent/ghostty/theme\""
);
let mut d = DiagnosticList::default();
assert!(open("/tmp", None, &mut d).is_none());
assert!(open(&e("/tmp"), None, &mut d).is_none());
assert_eq!(
d.items()[0].message,
"not reading theme from \"/tmp\": it is a directory"
@@ -290,8 +297,8 @@ mod tests {
std::fs::create_dir_all(dir.join("themes")).unwrap();
std::fs::write(dir.join("themes/mytheme-zzz"), "background = #000000\n").unwrap();
let mut d = DiagnosticList::default();
let t = open("mytheme-zzz", Some(dir.to_str().unwrap()), &mut d).unwrap();
assert!(t.path.ends_with("themes/mytheme-zzz"));
let t = open(&e("mytheme-zzz"), Some(&EncodedOs::from_path(&dir)), &mut d).unwrap();
assert!(t.path.ends_with_plain("themes/mytheme-zzz"));
assert!(d.is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -299,7 +306,7 @@ mod tests {
#[test]
fn theme_not_found_lists_tried_paths() {
let mut d = DiagnosticList::default();
assert!(open("nope-zzz", Some("/nonexistent-res"), &mut d).is_none());
assert!(open(&e("nope-zzz"), Some(&e("/nonexistent-res")), &mut d).is_none());
assert!(d.items().iter().any(|x| x.message
== "theme \"nope-zzz\" not found, tried path \"/nonexistent-res/themes/nope-zzz\""));
}
+25 -12
View File
@@ -147,7 +147,7 @@ pub enum WorkingDirectory {
/// Use an explicit working directory path. This may be not be
/// expanded until finalize is called.
Path(String),
Path(ghostty_os::osbytes::EncodedOs),
}
impl ConfigField for WorkingDirectory {
@@ -169,7 +169,7 @@ impl ConfigField for WorkingDirectory {
*self = match input {
"home" => WorkingDirectory::Home,
"inherit" => WorkingDirectory::Inherit,
_ => WorkingDirectory::Path(input.to_string()),
_ => WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded(input)),
};
Ok(())
}
@@ -178,7 +178,7 @@ impl ConfigField for WorkingDirectory {
match self {
WorkingDirectory::Home => write_str(f, "home"),
WorkingDirectory::Inherit => write_str(f, "inherit"),
WorkingDirectory::Path(p) => write_str(f, p),
WorkingDirectory::Path(p) => write_str(f, p.as_encoded()),
}
}
}
@@ -190,7 +190,7 @@ impl WorkingDirectory {
return;
};
if !path.starts_with("~/") {
if !path.starts_with_plain("~/") {
return;
}
@@ -198,7 +198,8 @@ impl WorkingDirectory {
Ok(e) => e,
Err(err) => {
log::warn!(
"error expanding home directory for working-directory path={path}: {err}"
"error expanding home directory for working-directory path={}: {err}",
path.as_encoded()
);
return;
}
@@ -211,7 +212,7 @@ impl WorkingDirectory {
}
#[must_use]
pub fn value(&self) -> Option<&str> {
pub fn value(&self) -> Option<&ghostty_os::osbytes::EncodedOs> {
match self {
WorkingDirectory::Path(p) => Some(p),
WorkingDirectory::Home | WorkingDirectory::Inherit => None,
@@ -1044,6 +1045,8 @@ impl ConfigField for RepeatableLink {
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use ghostty_os::osbytes::EncodedOs;
use super::*;
use crate::cli_args::ConfigField;
@@ -1069,18 +1072,28 @@ mod tests {
parse(&mut wd, "home").unwrap();
assert_eq!(wd, WorkingDirectory::Home);
parse(&mut wd, "~/projects/ghostty").unwrap();
assert_eq!(wd, WorkingDirectory::Path("~/projects/ghostty".into()));
assert_eq!(
wd,
WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded(
"~/projects/ghostty"
))
);
parse(&mut wd, "\"/tmp path\"").unwrap();
assert_eq!(wd, WorkingDirectory::Path("/tmp path".into()));
assert_eq!(
wd,
WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded("/tmp path"))
);
}
#[test]
fn working_directory_finalize() {
let mut wd = WorkingDirectory::Path("~/projects/ghostty".into());
let mut wd = WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded(
"~/projects/ghostty",
));
wd.finalize();
let expected = ghostty_os::expand_home("~/projects/ghostty")
.unwrap_or_else(|_| "~/projects/ghostty".to_string());
assert_eq!(wd.value().unwrap(), expected);
let expected = ghostty_os::expand_home(&EncodedOs::from_encoded("~/projects/ghostty"))
.unwrap_or_else(|_| EncodedOs::from_encoded("~/projects/ghostty"));
assert_eq!(wd.value().unwrap(), &expected);
}
}
+3
View File
@@ -26,6 +26,9 @@ onig = "6.5.3"
png = "0.18.1"
thiserror = "2"
[dev-dependencies]
temp-env = "0.3.6"
[lints]
workspace = true
+1 -1
View File
@@ -234,7 +234,7 @@ fn init_with(opts: InitOptions, default_logging: Logging) -> &'static GlobalStat
// Setup i18n
if let Some(v) = resources_dir.app()
&& let Err(err) = ghostty_os::i18n::init(v)
&& let Err(err) = ghostty_os::i18n::init(&v.to_path())
{
log::warn!("failed to init i18n, translations will not be available err={err}");
}
+29 -25
View File
@@ -419,11 +419,16 @@ pub fn screen_file_contents(
/// Write `contents` to `<tempdir>/<loc>.<ext>` (mode 0600) and return the
/// canonical path. The temporary directory is intentionally left behind so
/// the file stays around for the user (Zig does not delete it either).
/// The raw bytes of a path (what Zig copies/pastes for a screen file).
fn path_bytes(path: &std::path::Path) -> Vec<u8> {
ghostty_os::osbytes::os_bytes(path.as_os_str())
}
pub fn write_screen_temp_file(
contents: &str,
loc: WriteScreenLoc,
emit: WriteScreenFormat,
) -> Result<String, CoreError> {
) -> Result<std::path::PathBuf, CoreError> {
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt as _;
@@ -432,7 +437,7 @@ pub fn write_screen_temp_file(
let filename = screen_file_name(loc, emit);
let file_path = tmp_dir.path().join(&filename);
let result = (|| -> Result<String, CoreError> {
let result = (|| -> Result<std::path::PathBuf, CoreError> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
@@ -440,11 +445,7 @@ pub fn write_screen_temp_file(
let mut file = opts.open(&file_path)?;
file.write_all(contents.as_bytes())?;
file.flush()?;
// Reversible config-string encoding: a non-UTF-8 temp dir keeps its
// bytes (decode with `config_str_to_path` at OS boundaries).
Ok(ghostty_os::osbytes::path_to_config_string(
&std::fs::canonicalize(&file_path)?,
))
Ok(std::fs::canonicalize(&file_path)?)
})();
match result {
@@ -628,13 +629,10 @@ impl Surface {
match write_screen.action {
WriteScreenAction::Copy => {
// The file name is copied as the raw bytes of the path.
self.rt_surface.set_clipboard(
Clipboard::Standard,
&[(
"text/plain".to_owned(),
String::from_utf8_lossy(&ghostty_os::osbytes::config_str_to_bytes(&path))
.into_owned(),
)],
&[("text/plain".to_owned(), path_bytes(&path))],
false,
);
}
@@ -643,11 +641,11 @@ impl Surface {
WriteScreenFormat::Plain | WriteScreenFormat::Vt => OpenUrlKind::Text,
WriteScreenFormat::Html => OpenUrlKind::Html,
},
url: path,
url: ghostty_os::osbytes::EncodedOs::from_path(&path),
})?,
WriteScreenAction::Paste => self.queue_io(IoMessage::write_req(
&ghostty_os::osbytes::config_str_to_bytes(&path),
)),
WriteScreenAction::Paste => {
self.queue_io(IoMessage::write_req(&path_bytes(&path)));
}
}
Ok(())
}
@@ -1278,6 +1276,7 @@ mod tests {
let path =
write_screen_temp_file(&s, WriteScreenLoc::Screen, WriteScreenFormat::Plain).unwrap();
assert!(path.ends_with("screen.txt"));
let path = path.to_string_lossy().into_owned(); // test-only: ASCII temp path
let read = std::fs::read_to_string(&path).unwrap();
assert_eq!(read, s);
#[cfg(unix)]
@@ -1292,18 +1291,23 @@ mod tests {
#[cfg(unix)]
#[test]
fn screen_temp_file_returns_encoded_non_utf8_path() {
use std::os::unix::ffi::OsStrExt;
fn screen_temp_file_keeps_non_utf8_path_bytes() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let td = ghostty_os::temp_dir::TempDir::init().unwrap();
let bad = td.path().join(std::ffi::OsStr::from_bytes(b"d\xff"));
std::fs::create_dir_all(&bad).unwrap();
// Use TMPDIR to force the non-UTF-8 directory.
let file = bad.join("x.txt");
std::fs::write(&file, b"x").unwrap();
let canon = std::fs::canonicalize(&file).unwrap();
let enc = ghostty_os::osbytes::path_to_config_string(&canon);
assert!(enc.contains('\u{fdd0}'));
assert_eq!(ghostty_os::osbytes::config_str_to_path(&enc), canon);
let tmpdir = std::ffi::OsString::from_vec(bad.as_os_str().as_bytes().to_vec());
// Force the non-UTF-8 directory through TMPDIR.
let path = temp_env::with_var("TMPDIR", Some(&tmpdir), || {
write_screen_temp_file("x", WriteScreenLoc::Screen, WriteScreenFormat::Plain).unwrap()
});
assert!(path.as_os_str().as_bytes().contains(&0xff));
assert_eq!(std::fs::read(&path).unwrap(), b"x");
// Copy/paste carry the exact path bytes (no U+FFFD), and the open
// action's EncodedOs decodes back to the same path.
assert_eq!(path_bytes(&path), path.as_os_str().as_bytes());
let url = ghostty_os::osbytes::EncodedOs::from_path(&path);
assert_eq!(url.to_path(), path);
}
#[test]
+9 -8
View File
@@ -64,7 +64,8 @@ impl Surface {
log::info!("application sent invalid base64 data for OSC 52");
return;
};
let text = String::from_utf8_lossy(&decoded).into_owned();
// OSC 52 data is written as the application sent it (bytes).
let text = decoded;
// When clipboard-write is "ask" a prompt is displayed to the user
// asking them to confirm the clipboard access. Each app runtime
@@ -111,11 +112,11 @@ impl Surface {
}
};
let mut contents: Vec<(String, String)> = Vec::new();
let mut contents: Vec<(String, Vec<u8>)> = Vec::new();
match format {
CopyToClipboard::Plain => {
let Some(s) = run(opts) else { return };
contents.push(("text/plain".to_owned(), s));
contents.push(("text/plain".to_owned(), s.into_bytes()));
}
CopyToClipboard::Vt => {
@@ -125,7 +126,7 @@ impl Surface {
// it contains escape sequences that should be preserved
// as-is
let Some(s) = run(o) else { return };
contents.push(("text/plain".to_owned(), s));
contents.push(("text/plain".to_owned(), s.into_bytes()));
}
CopyToClipboard::Html => {
@@ -135,7 +136,7 @@ impl Surface {
// since HTML has its own character encoding and entity
// system
let Some(s) = run(o) else { return };
contents.push(("text/html".to_owned(), s));
contents.push(("text/html".to_owned(), s.into_bytes()));
}
CopyToClipboard::Mixed => {
@@ -143,7 +144,7 @@ impl Surface {
let Some(plain) = run(opts.clone()) else {
return;
};
contents.push(("text/plain".to_owned(), plain));
contents.push(("text/plain".to_owned(), plain.into_bytes()));
// Second, generate HTML without codepoint mappings
let mut o = opts;
@@ -154,7 +155,7 @@ impl Surface {
o.background = None;
o.foreground = None;
let Some(html) = run(o) else { return };
contents.push(("text/html".to_owned(), html));
contents.push(("text/html".to_owned(), html.into_bytes()));
}
}
@@ -207,7 +208,7 @@ impl Surface {
ClipboardRequest::Osc52Write(clipboard) => {
self.rt_surface.set_clipboard(
clipboard,
&[("text/plain".to_owned(), data.to_owned())],
&[("text/plain".to_owned(), data.as_bytes().to_vec())],
!confirmed,
);
Ok(())
+6 -7
View File
@@ -469,8 +469,8 @@ impl Surface {
.working_directory
.as_ref()
.and_then(|wd| wd.value())
.map(str::to_owned);
ecfg.resources_dir = global::state().resources_dir.host().map(str::to_owned);
.cloned();
ecfg.resources_dir = global::state().resources_dir.host().cloned();
// The Windows exec backend has no pre-exec/post-fork hooks,
// cgroup scopes or rlimits (Zig: Linux/POSIX only).
#[cfg(unix)]
@@ -940,9 +940,8 @@ impl Surface {
}
Message::PwdChange(w) => {
// Keep the OSC 7 bytes (see `Pwd`): non-UTF-8 bytes are
// carried with the reversible byte escape.
let pwd = ghostty_os::osbytes::bytes_to_config_string(w.slice());
// Keep the OSC 7 bytes (see `Pwd`).
let pwd = ghostty_os::osbytes::EncodedOs::from_bytes(w.slice());
self.perform(RtAction::Pwd(Pwd { pwd }));
}
@@ -1368,10 +1367,10 @@ impl Surface {
/// Returns the pwd of the terminal, if any. This is always copied
/// because the pwd can change at any point from termio.
#[must_use]
pub fn pwd(&self) -> Option<String> {
pub fn pwd(&self) -> Option<ghostty_os::osbytes::EncodedOs> {
let t = self.lock_terminal();
let pwd = t.get_pwd()?;
Some(ghostty_os::osbytes::bytes_to_config_string(pwd))
Some(ghostty_os::osbytes::EncodedOs::from_bytes(pwd))
}
/// The raw bytes of [`Surface::pwd`] (Zig returns the OSC 7 bytes as is).
+76 -20
View File
@@ -394,15 +394,20 @@ fn drag_left_click_triple_selection(
/// Resolve a relative file path to an absolute path using `pwd` (Zig
/// `resolvePathForOpening`). Returns `None` for absolute paths (use as-is),
/// when there's no pwd, or when the resolved path doesn't exist.
fn resolve_path_for_opening(pwd: Option<&str>, path: &str) -> Option<String> {
///
/// `pwd` is the raw OSC 7 byte string; `path` is plain terminal text.
fn resolve_path_for_opening(
pwd: Option<&[u8]>,
path: &str,
) -> Option<ghostty_os::osbytes::EncodedOs> {
use std::path::{Component, Path, PathBuf};
if Path::new(path).is_absolute() {
return None;
}
let pwd = pwd?;
let joined = Path::new(pwd).join(path);
let pwd = ghostty_os::osbytes::os_from_bytes(pwd?.to_vec());
let joined = Path::new(&pwd).join(path);
// Lexically normalize (std.fs.path.resolve).
let mut out = PathBuf::new();
@@ -419,7 +424,16 @@ fn resolve_path_for_opening(pwd: Option<&str>, path: &str) -> Option<String> {
if !out.exists() {
return None;
}
Some(out.to_string_lossy().into_owned())
Some(ghostty_os::osbytes::EncodedOs::from_path(&out))
}
/// What to hand to the opener for a matched `open` link: a relative path
/// that resolves against the terminal's pwd becomes that (OS) path, anything
/// else (a URL, an absolute path) is the matched *plain text*, wrapped so it
/// is never mistaken for an escaped OS string.
fn open_link_target(pwd: Option<&[u8]>, text: &str) -> ghostty_os::osbytes::EncodedOs {
resolve_path_for_opening(pwd, text)
.unwrap_or_else(|| ghostty_os::osbytes::EncodedOs::from_plain_text(text))
}
impl Surface {
@@ -1603,7 +1617,7 @@ impl Surface {
/// Return the URI for an OSC8 hyperlink at the given position or `None`
/// if there is no hyperlink.
fn osc8_uri(screen: &Screen, pin: Pin) -> Option<String> {
fn osc8_uri(screen: &Screen, pin: Pin) -> Option<Vec<u8>> {
let node = screen.pages.node(pin.node)?;
let page = &node.data;
let idx = page
@@ -1611,13 +1625,13 @@ impl Surface {
.ok()?;
let id = page.lookup_hyperlink(idx)?;
let entry = page.hyperlink_set.get(id)?;
Some(String::from_utf8_lossy(&entry.uri).into_owned())
Some(entry.uri.clone())
}
/// The URL of the link (`open` regex link or OSC8) under the current
/// cursor position, if the mouse is over a link (Zig
/// `copy_url_to_clipboard`). Takes the renderer lock.
pub(crate) fn link_url_at_cursor(&self) -> Option<String> {
pub(crate) fn link_url_at_cursor(&self) -> Option<Vec<u8>> {
// If the mouse isn't over a link, nothing we can do.
if !self.mouse.borrow().over_link {
return None;
@@ -1632,10 +1646,14 @@ impl Surface {
let link = self.link_at_pos(&t, vp, mods)?;
let screen = t.screen();
match link.action {
LinkAction::Open => Some(screen.selection_string(SelectionString {
sel: link.selection,
trim,
})),
LinkAction::Open => Some(
screen
.selection_string(SelectionString {
sel: link.selection,
trim,
})
.into_bytes(),
),
LinkAction::_OpenOsc8 => {
let uri = Self::osc8_uri(screen, link.selection.start(&screen.pages));
if uri.is_none() {
@@ -1664,8 +1682,9 @@ impl Surface {
sel: link.selection,
trim: false,
});
let pwd = t.get_pwd().map(ghostty_os::osbytes::bytes_to_config_string);
resolve_path_for_opening(pwd.as_deref(), &str).unwrap_or(str)
// The matched text is plain terminal text (a URL or a
// path), never an escaped OS string.
open_link_target(t.get_pwd(), &str)
}
LinkAction::_OpenOsc8 => {
@@ -1674,7 +1693,7 @@ impl Surface {
log::warn!("failed to get URI for OSC8 hyperlink");
return Ok(false);
};
uri
ghostty_os::osbytes::EncodedOs::from_bytes(&uri)
}
}
};
@@ -1703,7 +1722,7 @@ impl Surface {
OpenUrlKind::Text => ghostty_os::open::OpenUrlKind::Text,
OpenUrlKind::Html => ghostty_os::open::OpenUrlKind::Html,
};
ghostty_os::open::open(kind, &url)?;
ghostty_os::open::open(kind, &url.to_os())?;
Ok(())
}
@@ -1795,7 +1814,10 @@ impl Surface {
break 'link (None, false);
};
(
Some(MouseOverLink { url: uri }),
// Display-only text for the status bar.
Some(MouseOverLink {
url: String::from_utf8_lossy(&uri).into_owned(),
}),
cfg.link_previews != LinkPreviews::False,
)
}
@@ -2484,21 +2506,55 @@ mod tests {
#[test]
fn resolve_path_for_opening_semantics() {
// Absolute paths are used as-is.
assert_eq!(resolve_path_for_opening(Some("/tmp"), "/etc/passwd"), None);
assert_eq!(resolve_path_for_opening(Some(b"/tmp"), "/etc/passwd"), None);
// No pwd, no resolution.
assert_eq!(resolve_path_for_opening(None, "foo"), None);
// Non-existent relative paths are not resolved.
assert_eq!(
resolve_path_for_opening(Some("/tmp"), "does-not-exist-xyz"),
resolve_path_for_opening(Some(b"/tmp"), "does-not-exist-xyz"),
None
);
// Existing relative path resolves, including `..`.
assert_eq!(
resolve_path_for_opening(Some("/tmp"), "../etc"),
Some("/etc".to_owned())
resolve_path_for_opening(Some(b"/tmp"), "../etc"),
Some(ghostty_os::osbytes::EncodedOs::from_plain_text("/etc"))
);
}
#[test]
fn open_link_plain_text_is_never_decoded() {
// Regex-matched text is ordinary text: the escape character followed
// by U+00FF must not turn into the byte 0xFF.
let text = "https://example.com/\u{fdd0}\u{ff}";
let t = open_link_target(Some(b"/tmp"), text);
assert_eq!(t.to_bytes(), text.as_bytes());
let abs = open_link_target(None, "/etc/\u{fdd0}\u{ff}");
assert_eq!(abs.to_bytes(), "/etc/\u{fdd0}\u{ff}".as_bytes());
}
#[cfg(unix)]
#[test]
fn open_link_resolves_against_non_utf8_pwd() {
use std::os::unix::ffi::OsStrExt;
let td = ghostty_os::TempDir::init().unwrap();
let dir = td.path().join(std::ffi::OsStr::from_bytes(b"d\xffir"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("file.txt"), "x").unwrap();
// The OSC 7 pwd is raw bytes.
let pwd = dir.as_os_str().as_bytes().to_vec();
let t = open_link_target(Some(&pwd), "file.txt");
assert_eq!(t.to_path(), dir.join("file.txt"));
assert!(t.to_path().exists());
}
#[test]
fn osc8_uri_bytes_are_carried_unchanged() {
let uri = b"https://example.com/a\xffb".to_vec();
let t = ghostty_os::osbytes::EncodedOs::from_bytes(&uri);
assert_eq!(t.to_bytes(), uri);
assert_ne!(t.as_encoded().as_bytes(), uri.as_slice());
}
#[test]
fn scroll_amount_direction() {
let a = ScrollAmount { delta: -3 };
+5 -3
View File
@@ -50,7 +50,7 @@ impl Waker for NopWaker {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipboardWrite {
pub clipboard: Clipboard,
pub contents: Vec<(String, String)>,
pub contents: Vec<(String, Vec<u8>)>,
pub confirm: bool,
}
@@ -131,13 +131,15 @@ impl RtSurface for FakeRt {
self.pending.borrow_mut().push_back((clipboard, state));
true
}
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool) {
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec<u8>)], confirm: bool) {
self.writes.borrow_mut().push(ClipboardWrite {
clipboard,
contents: contents.to_vec(),
confirm,
});
if let Some((_, text)) = contents.iter().find(|(m, _)| m == "text/plain") {
if let Some((_, bytes)) = contents.iter().find(|(m, _)| m == "text/plain") {
// Test harness: the fake clipboard stores text.
let text = &String::from_utf8_lossy(bytes).into_owned();
// Like GTK, a confirm=true write shows a dialog first; the
// harness only stores unconfirmed writes.
if confirm {
+3 -6
View File
@@ -111,7 +111,7 @@ fn pwd_osc7() {
_ => None,
});
assert_eq!(pwd, "/tmp/a b");
assert_eq!(h.surface.pwd().as_deref(), Some("/tmp/a b"));
assert_eq!(h.surface.pwd().unwrap(), "/tmp/a b");
}
#[test]
@@ -124,12 +124,9 @@ fn pwd_osc7_keeps_non_utf8_bytes() {
Action::Pwd(p) if !p.pwd.is_empty() => Some(p.pwd.clone()),
_ => None,
});
assert_eq!(
ghostty_os::osbytes::config_str_to_bytes(&pwd),
b"/tmp/a\xffb"
);
assert_eq!(pwd.to_bytes(), b"/tmp/a\xffb");
assert_eq!(h.surface.pwd_bytes().as_deref(), Some(&b"/tmp/a\xffb"[..]));
assert_eq!(h.surface.pwd().as_deref(), Some(pwd.as_str()));
assert_eq!(h.surface.pwd(), Some(pwd));
}
#[test]
+1
View File
@@ -37,6 +37,7 @@ windows-sys = { version = "0.61", features = [
[dev-dependencies]
minidump = "0.27.0"
tempfile = "3.27.0"
temp-env = "0.3.6"
# The fatal-signal handler needs sigaction/sigaltstack/raw syscalls. Unsafe is
# denied crate-wide and only allowed in `signal.rs` with SAFETY comments.
+28 -3
View File
@@ -19,7 +19,8 @@ pub fn default_dir() -> io::Result<Dir> {
})
.map_err(|e| io::Error::other(e.to_string()))?;
Ok(Dir {
path: PathBuf::from(path),
// The XDG helpers return the encoded form of the OS string.
path: path.to_path(),
})
}
@@ -62,7 +63,7 @@ impl ReportIterator {
if !entry.file_type()?.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let name = entry.file_name();
// statFile follows symlinks; entries here are regular files.
let mtime = fs::metadata(self.dir.join(entry.file_name()))?.modified()?;
let mtime = match mtime.duration_since(UNIX_EPOCH) {
@@ -84,7 +85,8 @@ impl Iterator for ReportIterator {
/// A crash report file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
pub name: String,
/// The file name as the raw OS string (Zig `[]const u8`).
pub name: std::ffi::OsString,
/// Modification time in nanoseconds since the unix epoch.
pub mtime: i128,
}
@@ -121,6 +123,29 @@ mod tests {
assert_eq!("a.ghosttycrash", r[0].name);
}
#[cfg(unix)]
#[test]
fn default_dir_decodes_non_utf8_xdg_state() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let v = std::ffi::OsString::from_vec(b"/tmp/state\xff".to_vec());
let d = temp_env::with_var("XDG_STATE_HOME", Some(&v), || default_dir().unwrap());
assert_eq!(
d.path.as_os_str().as_bytes(),
b"/tmp/state\xff/ghostty/crash"
);
}
#[cfg(unix)]
#[test]
fn non_utf8_report_name_is_kept() {
use std::os::unix::ffi::OsStrExt;
let t = tempfile::tempdir().unwrap();
let name = std::ffi::OsStr::from_bytes(b"a\xff.ghosttycrash");
fs::write(t.path().join(name), "x").unwrap();
let r = list_reports(t.path()).unwrap();
assert_eq!(r[0].name, name);
}
#[test]
fn default_dir_uses_xdg_state() {
let d = default_dir().unwrap();
+66
View File
@@ -15,8 +15,51 @@ pub(crate) const MACH_EXCEPTION_RAISE: i32 = 2405;
pub(crate) const EXC_BAD_ACCESS: u32 = 1;
pub(crate) const EXC_BAD_INSTRUCTION: u32 = 2;
pub(crate) const EXC_ARITHMETIC: u32 = 3;
pub(crate) const EXC_SOFTWARE: u32 = 5;
pub(crate) const EXC_BREAKPOINT: u32 = 6;
/// `EXC_SOFT_SIGNAL`: the code of an `EXC_SOFTWARE` exception that stands for
/// a BSD signal (the subcode is the signal number). This is how an
/// `abort()` (SIGABRT) is described, as Apple's crash reporter does.
pub(crate) const EXC_SOFT_SIGNAL: i64 = 0x10003;
/// Message id of the "write a dump for this signal" request the signal
/// handler sends to the exception handler thread (not a Mach exception id).
pub(crate) const ABORT_MSG_ID: i32 = 0x6768_6f73;
impl MachException {
/// The exception describing a fatal BSD signal that never went through
/// the Mach exception path (`abort()` raises SIGABRT directly).
pub(crate) fn from_signal(signo: i32) -> MachException {
MachException {
kind: EXC_SOFTWARE,
code: EXC_SOFT_SIGNAL,
subcode: i64::from(signo),
}
}
}
/// Wire layout of the abort request after the 24 byte Mach header:
/// `thread: u32`, `signo: i32` (native endian).
pub(crate) const ABORT_BODY_LEN: usize = 8;
/// Encode the body of an abort request.
pub(crate) fn abort_body(thread: u32, signo: i32) -> [u8; ABORT_BODY_LEN] {
let mut b = [0u8; ABORT_BODY_LEN];
b[..4].copy_from_slice(&thread.to_ne_bytes());
b[4..].copy_from_slice(&signo.to_ne_bytes());
b
}
/// Decode an abort request message (header included): the faulting thread
/// and the signal. `None` for any other message.
pub(crate) fn parse_abort(msg: &[u8]) -> Option<(u32, i32)> {
if u32_at(msg, 20)? as i32 != ABORT_MSG_ID {
return None;
}
Some((u32_at(msg, HEADER)?, u32_at(msg, HEADER + 4)? as i32))
}
/// A decoded Mach exception.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct MachException {
@@ -61,6 +104,7 @@ impl MachException {
EXC_BAD_INSTRUCTION => (4, 1), // SIGILL, ILL_ILLOPC
EXC_ARITHMETIC => (8, 0), // SIGFPE
EXC_BREAKPOINT => (5, 1), // SIGTRAP, TRAP_BRKPT
EXC_SOFTWARE if self.code == EXC_SOFT_SIGNAL => (self.subcode as i32, 0),
_ => (0, 0),
}
}
@@ -248,6 +292,28 @@ mod tests {
assert_eq!(e(EXC_BREAKPOINT, 1).signal().0, 5);
}
#[test]
fn abort_request_round_trips() {
let mut msg = vec![0u8; 20];
msg.extend_from_slice(&ABORT_MSG_ID.to_ne_bytes());
msg.extend_from_slice(&abort_body(77, 6));
assert_eq!(parse_abort(&msg), Some((77, 6)));
// A Mach exception message or a truncated one is not an abort request.
assert_eq!(parse_abort(&raise(EXC_BAD_ACCESS, &[1, 2])), None);
assert_eq!(parse_abort(&msg[..26]), None);
}
#[test]
fn sigabrt_is_described_as_a_soft_signal() {
let e = MachException::from_signal(6);
assert_eq!(e.name(), "EXC_SOFTWARE");
assert_eq!(e.signal(), (6, 0));
assert_eq!(e.fault_addr(), 0);
let mut s = String::new();
e.describe(&mut s);
assert_eq!(s, "EXC_SOFTWARE (code=0x10003, subcode=0x6)");
}
#[test]
fn windows_names() {
assert_eq!(
+1
View File
@@ -28,6 +28,7 @@ mod signal;
#[cfg(windows)]
#[path = "signal_windows.rs"]
mod signal;
mod suspend;
pub use context::{DimsSnapshot, SurfaceDims, ThreadGuard, ThreadType, current_dims};
pub use dir::{Dir, Report, ReportIterator, default_dir};
+196 -25
View File
@@ -10,7 +10,9 @@
//! sends a `mach_exception_raise` message; the handler thread (which runs on
//! its own healthy stack, so stack overflows work) then:
//!
//! 1. suspends all other threads,
//! 1. suspends all other threads (and resumes *all* of them, the faulting one
//! included, before replying; otherwise the process hangs instead of
//! dying),
//! 2. reads the crashed thread's registers with `thread_get_state` for the
//! event's stack trace,
//! 3. writes a minidump with `minidump-writer` for the whole task and attaches
@@ -20,9 +22,11 @@
//! the process dies as usual (Apple's crash reporter included). The signal
//! path (`signal.rs`) sees `BUSY` set and writes nothing more.
//!
//! Signals that are not Mach exceptions (`SIGABRT` from `abort()`) keep going
//! through the signal handler. A watchdog `alarm(2)` kills the process if the
//! dump hangs (e.g. a suspended thread held the allocator lock).
//! `SIGABRT` from `abort()` is not a Mach exception. Like Breakpad, the signal
//! handler asks this handler thread for the dump (`request_abort_dump`: a
//! message to the exception port, then it waits on a reply port), so
//! `abort()` gets a minidump too. A watchdog `alarm(2)` kills the process if
//! the dump hangs (e.g. a suspended thread held the allocator lock).
//!
//! Unlike Breakpad this does not forward the exception to previously
//! registered handlers; it restores them, which only matters for debuggers
@@ -53,12 +57,14 @@ use mach2::{
mach_types::{thread_act_t, thread_t},
message::{
MACH_MSG_TIMEOUT_NONE,
MACH_MSG_TYPE_COPY_SEND,
MACH_MSG_TYPE_MAKE_SEND,
MACH_MSGH_BITS,
MACH_MSGH_BITS_REMOTE_MASK,
MACH_RCV_LARGE,
MACH_RCV_MSG,
MACH_RCV_PORT_DIED,
MACH_RCV_TIMEOUT,
MACH_SEND_MSG,
mach_msg,
mach_msg_header_t,
@@ -77,6 +83,7 @@ use crate::{
native::Native,
sentry,
signal::BUSY,
suspend::{SuspendGuard, Threads},
};
/// Maximum number of registered exception handlers (`EXC_TYPES_COUNT`).
@@ -99,6 +106,11 @@ struct Previous {
static PREVIOUS: OnceLock<Previous> = OnceLock::new();
static PORT: AtomicU32 = AtomicU32::new(MACH_PORT_NULL);
/// Receive port the signal handler waits on while the handler thread dumps.
static REPLY_PORT: AtomicU32 = AtomicU32::new(MACH_PORT_NULL);
/// How long the signal handler waits for the dump (a bit above the watchdog).
const ABORT_WAIT_MS: u32 = (WATCHDOG_SECS + 10) * 1000;
#[cfg(target_arch = "x86_64")]
type ThreadState = mach2::structs::x86_thread_state64_t;
@@ -136,6 +148,21 @@ pub(crate) fn install() -> bool {
return false;
}
// Reply port for the SIGABRT path (best effort: without it SIGABRT
// keeps the event-only report).
let mut reply: mach_port_t = MACH_PORT_NULL;
if mach2::mach_port::mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &raw mut reply)
== KERN_SUCCESS
{
if mach2::mach_port::mach_port_insert_right(task, reply, reply, MACH_MSG_TYPE_MAKE_SEND)
== KERN_SUCCESS
{
REPLY_PORT.store(reply, Ordering::Release);
} else {
mach2::mach_port::mach_port_destroy(task, reply);
}
}
let mut prev = Previous {
count: MAX_PORTS as u32,
masks: [0; MAX_PORTS],
@@ -237,6 +264,9 @@ fn handler_thread(port: mach_port_t) {
continue;
}
let Some(req) = exception::parse_raise(&buf.0) else {
if let Some((thread, signo)) = exception::parse_abort(&buf.0) {
handle_abort(thread, signo, handler);
}
continue;
};
handle_exception(&req, handler);
@@ -273,23 +303,40 @@ fn handler_thread(port: mach_port_t) {
}
}
/// Suspend every thread of the task except `keep` (the handler thread).
fn suspend_other_threads(keep: thread_act_t) {
// SAFETY: task_threads returns a vm-allocated array we only read.
unsafe {
let task = mach2::traps::mach_task_self();
let mut list: mach2::mach_types::thread_act_array_t = std::ptr::null_mut();
let mut n: u32 = 0;
if mach2::task::task_threads(task, &raw mut list, &raw mut n) != KERN_SUCCESS {
return;
}
for i in 0..n as usize {
let t = *list.add(i);
if t != keep {
mach2::thread_act::thread_suspend(t);
/// The Mach threads of this task (for [`SuspendGuard`]).
struct MachThreads;
impl Threads for MachThreads {
type Id = thread_act_t;
fn list(&self) -> Vec<thread_act_t> {
// SAFETY: task_threads returns a vm-allocated array we copy and free.
unsafe {
let task = mach2::traps::mach_task_self();
let mut list: mach2::mach_types::thread_act_array_t = std::ptr::null_mut();
let mut n: u32 = 0;
if mach2::task::task_threads(task, &raw mut list, &raw mut n) != KERN_SUCCESS {
return Vec::new();
}
let threads = std::slice::from_raw_parts(list, n as usize).to_vec();
mach2::vm::mach_vm_deallocate(
task,
list as mach2::vm_types::mach_vm_address_t,
u64::from(n) * size_of::<thread_act_t>() as u64,
);
threads
}
}
fn suspend(&self, t: thread_act_t) {
// SAFETY: plain Mach call on a thread port of this task.
unsafe { mach2::thread_act::thread_suspend(t) };
}
fn resume(&self, t: thread_act_t) {
// SAFETY: as above.
unsafe { mach2::thread_act::thread_resume(t) };
}
}
fn handle_exception(req: &exception::RaiseRequest, handler: thread_t) {
@@ -299,17 +346,141 @@ fn handle_exception(req: &exception::RaiseRequest, handler: thread_t) {
// SAFETY: alarm has no preconditions. SIGALRM's default action ends
// the process if the dump below hangs.
unsafe { libc::alarm(WATCHDOG_SECS) };
suspend_other_threads(handler);
write_report(st, req, handler);
{
// Every thread (the faulting one included) is resumed when the
// guard drops, before the exception is handed back to the kernel.
let _threads = SuspendGuard::suspend_all_except(&MachThreads, handler);
write_report(st, req.exception, req.thread, req.task, handler);
}
// SAFETY: as above.
unsafe { libc::alarm(0) };
}
restore_ports();
}
fn write_report(st: &sentry::State, req: &exception::RaiseRequest, handler: thread_t) {
let ex: MachException = req.exception;
/// A `SIGABRT` dump request from the signal handler: dump the whole task with
/// `thread` as the crashed thread, then wake the waiting signal handler.
fn handle_abort(thread: u32, signo: i32, handler: thread_t) {
if let Some(st) = sentry::state()
&& !BUSY.swap(true, Ordering::Acquire)
{
// SAFETY: as in `handle_exception`.
unsafe { libc::alarm(WATCHDOG_SECS) };
{
let _threads = SuspendGuard::suspend_all_except(&MachThreads, handler);
// SAFETY: plain Mach trap.
let task = unsafe { mach2::traps::mach_task_self() };
write_report(st, MachException::from_signal(signo), thread, task, handler);
}
// SAFETY: as above.
unsafe { libc::alarm(0) };
}
notify_signal_handler();
}
/// Wake the signal handler waiting in [`request_abort_dump`].
fn notify_signal_handler() {
let reply = REPLY_PORT.load(Ordering::Acquire);
if reply == MACH_PORT_NULL {
return;
}
let mut head = mach_msg_header_t {
msgh_bits: MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0),
msgh_size: size_of::<mach_msg_header_t>() as u32,
msgh_remote_port: reply,
msgh_local_port: MACH_PORT_NULL,
msgh_voucher_port: 0,
msgh_id: exception::ABORT_MSG_ID + 1,
};
// SAFETY: `head` is a complete message with no body.
unsafe {
mach_msg(
&raw mut head,
MACH_SEND_MSG,
size_of::<mach_msg_header_t>() as u32,
0,
MACH_PORT_NULL,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL,
);
}
}
#[repr(C, packed(4))]
struct Request {
head: mach_msg_header_t,
body: [u8; exception::ABORT_BODY_LEN],
}
/// Called from the `SIGABRT` signal handler: ask the exception handler thread
/// to write the report with a minidump and wait for it. Returns false if the
/// handler thread is unavailable (the caller then writes the event-only
/// report itself).
pub(crate) fn request_abort_dump(signo: libc::c_int) -> bool {
let port = PORT.load(Ordering::Acquire);
let reply = REPLY_PORT.load(Ordering::Acquire);
if port == MACH_PORT_NULL || reply == MACH_PORT_NULL || sentry::state().is_none() {
return false;
}
if BUSY.load(Ordering::Acquire) {
// Another crash is already being written.
return false;
}
// SAFETY: mach_thread_self has no preconditions.
let thread = unsafe { mach2::mach_init::mach_thread_self() };
let mut req = Request {
head: mach_msg_header_t {
msgh_bits: MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0),
msgh_size: size_of::<Request>() as u32,
msgh_remote_port: port,
msgh_local_port: MACH_PORT_NULL,
msgh_voucher_port: 0,
msgh_id: exception::ABORT_MSG_ID,
},
body: exception::abort_body(thread, signo),
};
// SAFETY: `req` is a fully initialized message of the given size.
let sent = unsafe {
mach_msg(
(&raw mut req).cast(),
MACH_SEND_MSG,
size_of::<Request>() as u32,
0,
MACH_PORT_NULL,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL,
)
};
if sent != 0 {
return false;
}
// Block until the handler thread has finished (it resumes this thread's
// suspension before replying). Bounded, in case the dump hangs.
let mut buf = Buffer([0; 1024]);
// SAFETY: `buf` is a writable 1024 byte aligned buffer.
unsafe {
mach_msg(
buf.0.as_mut_ptr().cast(),
MACH_RCV_MSG | MACH_RCV_TIMEOUT,
0,
buf.0.len() as u32,
reply,
ABORT_WAIT_MS,
MACH_PORT_NULL,
);
}
true
}
fn write_report(
st: &sentry::State,
ex: MachException,
thread: thread_t,
task: mach_port_t,
handler: thread_t,
) {
// Registers and frame-pointer walk of the faulting thread.
let mut native = Box::new(Native::new());
(native.signo, native.code) = ex.signal();
@@ -319,7 +490,7 @@ fn write_report(st: &sentry::State, req: &exception::RaiseRequest, handler: thre
// SAFETY: `state` is a writable thread state of the requested flavor.
let kr = unsafe {
mach2::thread_act::thread_get_state(
req.thread,
thread,
state_flavor(),
(&raw mut state).cast(),
&raw mut count,
@@ -331,8 +502,8 @@ fn write_report(st: &sentry::State, req: &exception::RaiseRequest, handler: thre
}
let cc = CrashContext {
task: req.task,
thread: req.thread,
task,
thread,
handler_thread: handler,
exception: Some(ExceptionInfo {
kind: ex.kind,
+4 -12
View File
@@ -138,7 +138,8 @@ pub fn install(opts: &Options) -> Result<(), InitError> {
}
let state = State {
dir: dir.as_os_str().as_encoded_bytes().to_vec(),
// Bytes on Unix, WTF-8 on Windows (lossless both ways).
dir: ghostty_os::osbytes::os_bytes(dir.as_os_str()),
prefix,
};
if STATE.set(state).is_err() {
@@ -415,17 +416,8 @@ pub(crate) fn write_report_ext(
data.extend_from_slice(&enc.payload);
}
#[cfg(unix)]
let dir = {
use std::os::unix::ffi::OsStrExt as _;
std::path::Path::new(std::ffi::OsStr::from_bytes(&st.dir))
};
// Windows paths are not arbitrary bytes; the dir was UTF-8 in practice.
#[cfg(not(unix))]
let dir_buf = PathBuf::from(String::from_utf8_lossy(&st.dir).into_owned());
#[cfg(not(unix))]
let dir = dir_buf.as_path();
std::fs::create_dir_all(dir)?;
let dir = PathBuf::from(ghostty_os::osbytes::os_from_bytes(st.dir.clone()));
std::fs::create_dir_all(&dir)?;
let path = dir.join(report_file_name(uuid));
std::fs::write(&path, data)?;
Ok(path)
+8 -1
View File
@@ -252,7 +252,14 @@ fn write_signal_report(name: &str, desc: &str, info: *mut libc::siginfo_t, uc: *
}
extern "C" fn handler(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut libc::c_void) {
if let Some((_, name, desc)) = SIGNALS.iter().find(|(s, ..)| *s == sig) {
// macOS: `abort()` raises SIGABRT directly (no Mach exception), so ask the
// exception handler thread for the report with a minidump, like
// Breakpad. The event-only report below is the fallback.
#[cfg(target_os = "macos")]
let dumped = sig == libc::SIGABRT && crate::mach_handler::request_abort_dump(sig);
#[cfg(not(target_os = "macos"))]
let dumped = false;
if !dumped && let Some((_, name, desc)) = SIGNALS.iter().find(|(s, ..)| *s == sig) {
write_signal_report(name, desc, info, ctx);
}
// Restore the default action and re-raise so the process dies with the
+1 -1
View File
@@ -124,7 +124,7 @@ fn write_report(st: &sentry::State, info: *mut EXCEPTION_POINTERS, tid: u32) {
.map_or(0, |r| r.ExceptionCode) as u32;
let (kind, desc) = exception::windows_exception(code);
let dir = std::path::PathBuf::from(String::from_utf8_lossy(&st.dir).into_owned());
let dir = std::path::PathBuf::from(ghostty_os::osbytes::os_from_bytes(st.dir.clone()));
let dump = (|| -> Result<Vec<u8>, String> {
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let tmp = dir.join(format!("minidump-{}.tmp", std::process::id()));
+112
View File
@@ -0,0 +1,112 @@
//! Suspending every other thread of the process while a crash report is
//! written, and *always* resuming them afterwards.
//!
//! The macOS exception handler thread suspends all other threads so the dump
//! is consistent. Breakpad resumes them before handing the exception back to
//! the kernel; without that, the faulting thread (which the kernel parked
//! until the handler replies) stays suspended forever and the process hangs
//! instead of dying through BSD signal delivery. The platform specific
//! thread operations are behind [`Threads`] so the bookkeeping is testable
//! everywhere.
// Only the macOS handler uses this outside of tests.
#![cfg_attr(not(target_os = "macos"), allow(dead_code))]
/// The thread operations needed by [`SuspendGuard`].
pub(crate) trait Threads {
type Id: Copy + PartialEq;
/// All threads of the process.
fn list(&self) -> Vec<Self::Id>;
fn suspend(&self, id: Self::Id);
fn resume(&self, id: Self::Id);
}
/// Resumes the threads it suspended when dropped (also on panic).
pub(crate) struct SuspendGuard<'a, T: Threads> {
ops: &'a T,
suspended: Vec<T::Id>,
}
impl<'a, T: Threads> SuspendGuard<'a, T> {
/// Suspend every thread except `keep` (the calling handler thread).
pub(crate) fn suspend_all_except(ops: &'a T, keep: T::Id) -> Self {
let suspended: Vec<T::Id> = ops.list().into_iter().filter(|t| *t != keep).collect();
for t in &suspended {
ops.suspend(*t);
}
SuspendGuard { ops, suspended }
}
/// The number of threads this guard suspended.
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.suspended.len()
}
}
impl<T: Threads> Drop for SuspendGuard<'_, T> {
fn drop(&mut self) {
for t in self.suspended.drain(..) {
self.ops.resume(t);
}
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use super::*;
#[derive(Default)]
struct Mock {
// (id, suspend count)
threads: RefCell<Vec<(u32, i32)>>,
}
impl Threads for Mock {
type Id = u32;
fn list(&self) -> Vec<u32> {
self.threads.borrow().iter().map(|t| t.0).collect()
}
fn suspend(&self, id: u32) {
for t in self.threads.borrow_mut().iter_mut().filter(|t| t.0 == id) {
t.1 += 1;
}
}
fn resume(&self, id: u32) {
for t in self.threads.borrow_mut().iter_mut().filter(|t| t.0 == id) {
t.1 -= 1;
}
}
}
#[test]
fn suspends_all_but_handler_and_resumes_all_on_drop() {
// Thread 1 is the faulting thread, 9 the handler.
let m = Mock {
threads: RefCell::new(vec![(1, 0), (2, 0), (9, 0)]),
};
{
let g = SuspendGuard::suspend_all_except(&m, 9);
assert_eq!(g.len(), 2);
assert_eq!(*m.threads.borrow(), [(1, 1), (2, 1), (9, 0)]);
}
// Every thread, including the faulting one, runs again.
assert_eq!(*m.threads.borrow(), [(1, 0), (2, 0), (9, 0)]);
}
#[test]
fn resumes_even_if_the_dump_panics() {
let m = Mock {
threads: RefCell::new(vec![(1, 0), (9, 0)]),
};
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _g = SuspendGuard::suspend_all_except(&m, 9);
panic!("dump failed");
}));
assert!(r.is_err());
assert_eq!(*m.threads.borrow(), [(1, 0), (9, 0)]);
}
}
+3 -2
View File
@@ -69,10 +69,11 @@ impl DeferredFace {
.expect("deferred face has no fontconfig data");
// Filename and index for our face so we can load it
let filename = fc.pattern.get_string(fc::Property::File, 0)?;
// File names are raw OS bytes, not text.
let filename = fc.pattern.get_os_string(fc::Property::File, 0)?;
let face_index = fc.pattern.get_integer(fc::Property::Index, 0)?;
let mut face = Face::init_file(lib, &filename, face_index, opts)?;
let mut face = Face::init_file(lib, std::path::Path::new(&filename), face_index, opts)?;
face.set_variations(&fc.variations, opts)?;
Ok(face)
}
+4 -2
View File
@@ -131,11 +131,13 @@ impl Face {
/// Initialize a new font face from a file.
pub fn init_file(
lib: &Library,
path: &str,
path: impl AsRef<std::path::Path>,
index: i32,
opts: Options,
) -> Result<Face, FaceError> {
let cpath = CString::new(path).map_err(|_| FaceError::InvalidPath)?;
// File names are raw OS bytes (they need not be UTF-8).
let cpath = CString::new(path.as_ref().as_os_str().as_encoded_bytes())
.map_err(|_| FaceError::InvalidPath)?;
let face = {
let _g = lib.lock();
ft::FtFace::new_file(&lib.inner.lib, &cpath, index).map_err(FaceError::Freetype)?
+46 -2
View File
@@ -186,6 +186,11 @@ impl Pattern {
}
pub fn add_string(&mut self, prop: Property, v: &str) -> bool {
self.add_string_bytes(prop, v.as_bytes())
}
/// [`Pattern::add_string`] for arbitrary bytes (file names).
pub fn add_string_bytes(&mut self, prop: Property, v: &[u8]) -> bool {
let Ok(c) = CString::new(v) else {
return false;
};
@@ -205,8 +210,31 @@ impl Pattern {
unsafe { sys::FcPatternAddCharSet(self.as_ptr(), prop.cstr().as_ptr(), cs.0.as_ptr()) != 0 }
}
/// Get the string value at index `id`.
/// Get the string value at index `id` as the raw OS string. Use this for
/// file names: they are arbitrary bytes, not necessarily UTF-8.
pub fn get_os_string(&self, prop: Property, id: u32) -> Result<std::ffi::OsString, Error> {
let bytes = self.get_string_bytes(prop, id)?;
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
Ok(std::ffi::OsString::from_vec(bytes))
}
#[cfg(not(unix))]
{
Ok(std::ffi::OsString::from(
String::from_utf8_lossy(&bytes).into_owned(),
))
}
}
/// Get the string value at index `id` as text. Only for properties that
/// fontconfig defines as UTF-8 (family/style/full names); invalid bytes
/// are replaced. File names use [`Pattern::get_os_string`].
pub fn get_string(&self, prop: Property, id: u32) -> Result<String, Error> {
Ok(String::from_utf8_lossy(&self.get_string_bytes(prop, id)?).into_owned())
}
fn get_string_bytes(&self, prop: Property, id: u32) -> Result<Vec<u8>, Error> {
let mut out: *mut sys::FcChar8 = ptr::null_mut();
// SAFETY: valid pattern; out is a valid out pointer.
let r = unsafe {
@@ -223,7 +251,7 @@ impl Pattern {
}
// SAFETY: fontconfig returned a NUL-terminated string owned by the pattern.
let s = unsafe { CStr::from_ptr(out.cast()) };
Ok(s.to_string_lossy().into_owned())
Ok(s.to_bytes().to_vec())
}
pub fn get_integer(&self, prop: Property, id: u32) -> Result<i32, Error> {
@@ -420,6 +448,22 @@ mod tests {
assert!(!emoji.has_lang("en-US"));
}
#[cfg(unix)]
#[test]
fn file_name_keeps_non_utf8_bytes() {
use std::os::unix::ffi::OsStrExt;
let mut p = Pattern::create();
assert!(p.add_string_bytes(Property::File, b"/fonts/a\xffb.ttf"));
let f = p.get_os_string(Property::File, 0).unwrap();
assert_eq!(f.as_bytes(), b"/fonts/a\xffb.ttf");
// The text accessor is for UTF-8 names only and is lossy.
assert!(
p.get_string(Property::File, 0)
.unwrap()
.contains('\u{fffd}')
);
}
#[test]
fn charset_add_has() {
let mut cs = CharSet::create();
+2 -2
View File
@@ -131,7 +131,7 @@ pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s {
Action::SetTabTitle(t) => v.set_tab_title.title = keep.cstr(&t.title),
Action::PromptTitle(p) => v.prompt_title = *p as c_int,
Action::Pwd(p) => {
v.pwd.pwd = keep.cstr_bytes(&ghostty_os::osbytes::config_str_to_bytes(&p.pwd));
v.pwd.pwd = keep.cstr_bytes(&p.pwd.to_bytes());
}
Action::MouseShape(s) => v.mouse_shape = *s as c_int,
Action::MouseVisibility(m) => v.mouse_visibility = *m as c_int,
@@ -185,7 +185,7 @@ pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s {
v.open_url.kind = u.kind as c_int;
// The URL may be the escaped form of a non-UTF-8 path; hand the
// embedder the real bytes.
let url = ghostty_os::osbytes::config_str_to_bytes(&u.url);
let url = u.url.to_bytes();
v.open_url.url = keep.cstr_bytes(&url);
v.open_url.len = url.len();
}
+24 -15
View File
@@ -7,27 +7,27 @@ use ghostty_bench::{args::Args, benches};
/// Split the argument string like `std.process.ArgIteratorGeneral(.{})`:
/// whitespace separated, with double quotes grouping and backslash
/// escapes inside quotes.
fn split_args(s: &str) -> Vec<String> {
/// escapes inside quotes. Works on raw bytes: file names need not be UTF-8.
fn split_args(s: &[u8]) -> Vec<std::ffi::OsString> {
let mut out = Vec::new();
let mut cur = String::new();
let mut cur: Vec<u8> = Vec::new();
let mut have = false;
let mut in_quote = false;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
let mut bytes = s.iter().copied();
while let Some(c) = bytes.next() {
match c {
'"' => {
b'"' => {
in_quote = !in_quote;
have = true;
}
'\\' if in_quote => {
if let Some(n) = chars.next() {
b'\\' if in_quote => {
if let Some(n) = bytes.next() {
cur.push(n);
}
}
c if c.is_whitespace() && !in_quote => {
c if c.is_ascii_whitespace() && !in_quote => {
if have {
out.push(std::mem::take(&mut cur));
out.push(ghostty_os::osbytes::os_from_bytes(std::mem::take(&mut cur)));
have = false;
}
}
@@ -38,7 +38,7 @@ fn split_args(s: &str) -> Vec<String> {
}
}
if have {
out.push(cur);
out.push(ghostty_os::osbytes::os_from_bytes(cur));
}
out
}
@@ -62,7 +62,7 @@ pub unsafe extern "C" fn ghostty_benchmark_cli(
log::warn!("unknown action={action}");
return false;
}
let argv = split_args(&args.to_string_lossy());
let argv = split_args(args.to_bytes());
let parsed = match Args::parse(argv) {
Ok(a) => a,
Err(err) => {
@@ -85,9 +85,18 @@ mod tests {
#[test]
fn split_args_basic() {
assert_eq!(split_args("a b c"), ["a", "b", "c"]);
assert_eq!(split_args("--x=\"a b\" c"), ["--x=a b", "c"]);
assert_eq!(split_args(" ").len(), 0);
assert_eq!(split_args(b"a b c"), ["a", "b", "c"]);
assert_eq!(split_args(b"--x=\"a b\" c"), ["--x=a b", "c"]);
assert_eq!(split_args(b" ").len(), 0);
}
#[cfg(unix)]
#[test]
fn split_args_keeps_non_utf8_bytes() {
use std::os::unix::ffi::OsStrExt;
let v = split_args(b"--data=/tmp/a\xffb x");
assert_eq!(v[0].as_bytes(), b"--data=/tmp/a\xffb");
assert_eq!(v[1], "x");
}
#[test]
+40 -10
View File
@@ -16,7 +16,7 @@ use ghostty_config::{
use ghostty_input::binding::{Action as BindingAction, TriggerC, TriggerCTag};
use crate::{
main_c::{EMPTY_STRING, string_from_owned},
main_c::{EMPTY_STRING, string_from_bytes},
types::{
ghostty_command_s,
ghostty_config_color_list_s,
@@ -103,6 +103,15 @@ fn cstring(s: &str) -> CString {
CString::new(s.replace('\0', "")).unwrap_or_default()
}
/// A C string for a config *value*: config strings carry non-UTF-8 bytes in
/// the reversible [`ghostty_os::osbytes`] encoding, so the embedder gets the
/// decoded bytes (Zig hands out the original byte slice).
fn cstring_value(s: &str) -> CString {
let mut bytes = ghostty_os::osbytes::config_str_to_bytes(s);
bytes.retain(|b| *b != 0);
CString::new(bytes).unwrap_or_default()
}
/// Intern a string for the lifetime of the process (enum tag names).
fn intern(s: &str) -> *const c_char {
static POOL: OnceLock<Mutex<HashSet<Box<CStr>>>> = OnceLock::new();
@@ -122,8 +131,8 @@ fn intern(s: &str) -> *const c_char {
fn build_backing(value: &CValue) -> Backing {
let mut b = Backing::default();
match value {
CValue::String(s) => b.strings.push(cstring(s)),
CValue::Path { path, .. } => b.strings.push(cstring(path)),
CValue::String(s) => b.strings.push(cstring_value(s)),
CValue::Path { path, .. } => b.strings.push(cstring_value(path)),
CValue::ColorList(list) => {
b.colors = list
.iter()
@@ -136,10 +145,10 @@ fn build_backing(value: &CValue) -> Backing {
}
CValue::Commands(cmds) => {
for c in cmds {
b.strings.push(cstring(&c.action_key));
b.strings.push(cstring(&c.action));
b.strings.push(cstring(&c.title));
b.strings.push(cstring(&c.description));
b.strings.push(cstring_value(&c.action_key));
b.strings.push(cstring_value(&c.action));
b.strings.push(cstring_value(&c.title));
b.strings.push(cstring_value(&c.description));
}
// The CStrings' heap buffers don't move when the Vec grows.
b.commands = b
@@ -252,9 +261,12 @@ pub unsafe extern "C" fn ghostty_config_load_file(ptr: ghostty_config_t, path: *
// Non-UTF-8 file names are valid on Unix; keep the bytes (reversible
// escape understood by the config file loader).
let path =
ghostty_os::osbytes::bytes_to_config_string(unsafe { CStr::from_ptr(path) }.to_bytes());
ghostty_os::osbytes::EncodedOs::from_bytes(unsafe { CStr::from_ptr(path) }.to_bytes());
if let Err(err) = c.config.load_file(&path) {
log::error!("error loading config from file path={path} err={err}");
log::error!(
"error loading config from file path={} err={err}",
path.as_encoded()
);
}
}
@@ -574,7 +586,8 @@ pub unsafe extern "C" fn ghostty_config_get_diagnostic(
#[unsafe(no_mangle)]
pub extern "C" fn ghostty_config_open_path() -> ghostty_string_s {
match ghostty_config::edit::open_path() {
Ok(path) => string_from_owned(&path),
// The embedder gets the real bytes of the path.
Ok(path) => string_from_bytes(&path.to_bytes()),
Err(err) => {
log::error!("error opening config in editor err={err}");
EMPTY_STRING
@@ -588,6 +601,23 @@ mod tests {
use super::*;
#[test]
fn config_values_reach_c_as_decoded_bytes() {
use ghostty_os::osbytes::EncodedOs;
let enc = EncodedOs::from_bytes(b"/a\xffb");
assert_ne!(enc.as_encoded().as_bytes(), b"/a\xffb");
let b = build_backing(&CValue::Path {
path: enc.as_encoded().to_owned(),
optional: false,
});
assert_eq!(b.strings[0].as_bytes(), b"/a\xffb");
let b = build_backing(&CValue::String(enc.into_encoded()));
assert_eq!(b.strings[0].as_bytes(), b"/a\xffb");
// Plain text (even with the escape char) is unchanged.
let b = build_backing(&CValue::String("h\u{e9}llo".to_owned()));
assert_eq!(b.strings[0].to_str().unwrap(), "h\u{e9}llo");
}
fn get<T: Copy>(cfg: ghostty_config_t, key: &str, init: T) -> (bool, T) {
let mut out = init;
// SAFETY: `out` is a valid destination of the key's C type.
+5 -4
View File
@@ -619,7 +619,7 @@ impl RtSurface for SurfaceRt {
true
}
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool) {
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec<u8>)], confirm: bool) {
let Some(cb) = self.app.opts.write_clipboard_cb else {
return;
};
@@ -628,7 +628,8 @@ impl RtSurface for SurfaceRt {
.map(|(m, d)| {
(
CString::new(m.replace('\0', "")).unwrap_or_default(),
CString::new(d.replace('\0', "")).unwrap_or_default(),
CString::new(d.iter().copied().filter(|b| *b != 0).collect::<Vec<u8>>())
.unwrap_or_default(),
)
})
.collect();
@@ -889,7 +890,7 @@ unsafe fn apply_surface_options(config: &mut Config, opts: &ghostty_surface_conf
&& !wd.is_empty()
{
// Raw bytes are kept (reversible escape in the String based config).
let wd_str = osbytes::bytes_to_config_string(wd);
let wd_str = osbytes::EncodedOs::from_bytes(wd);
let wd_os = osbytes::os_from_bytes(wd.to_vec());
let wd_disp = String::from_utf8_lossy(wd);
match std::fs::metadata(&wd_os) {
@@ -2207,7 +2208,7 @@ mod tests {
.as_ref()
.and_then(|w| w.value())
.unwrap();
assert_eq!(osbytes::config_str_to_bytes(got_wd), dir);
assert_eq!(got_wd.to_bytes(), dir);
let Some(Command::Shell(c)) = &config.command else {
panic!("no command");
};
+7 -1
View File
@@ -21,9 +21,15 @@ static ARGS: OnceLock<Vec<String>> = OnceLock::new();
/// Build the `ghostty_string_s` for an owned string (Zig:
/// `String.fromSlice` of an allocated `[:0]const u8`). Free with
/// `ghostty_string_free`.
#[cfg(test)]
pub(crate) fn string_from_owned(s: &str) -> ghostty_string_s {
string_from_bytes(s.as_bytes())
}
/// [`string_from_owned`] for raw bytes (paths need not be UTF-8).
pub(crate) fn string_from_bytes(s: &[u8]) -> ghostty_string_s {
let mut bytes = Vec::with_capacity(s.len() + 1);
bytes.extend_from_slice(s.as_bytes());
bytes.extend_from_slice(s);
bytes.push(0);
let boxed = bytes.into_boxed_slice();
let len = boxed.len() - 1;
+9 -6
View File
@@ -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);
},
);
+3 -9
View File
@@ -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()));
});
}
+6 -6
View File
@@ -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");
}
+52 -37
View File
@@ -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));
});
}
+7 -5
View File
@@ -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]
+1 -1
View File
@@ -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"))]
+20 -12
View File
@@ -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);
});
}
+336 -25
View File
@@ -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() {
+7 -7
View File
@@ -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,
+8 -3
View File
@@ -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() {
+36 -55
View File
@@ -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);
}
}
+20 -16
View File
@@ -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()
}
}
+16 -24
View File
@@ -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());
});
}
}
+4 -4
View File
@@ -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();
+30 -18
View File
@@ -244,8 +244,9 @@ impl DerivedConfig {
.value
.iter()
.map(|p| match p {
ConfigPath::Optional(s) => PathItem::Optional(s.clone()),
ConfigPath::Required(s) => PathItem::Required(s.clone()),
// Decode the config path (encoded OS string) here, once.
ConfigPath::Optional(s) => PathItem::Optional(s.to_path()),
ConfigPath::Required(s) => PathItem::Required(s.to_path()),
})
.collect();
@@ -858,7 +859,8 @@ impl<G: GraphicsApi> DrawState<G> {
fn prep_background_image(&mut self) {
// Then we try to load the background image if we have a path.
if let Some(p) = &self.config.bg_image
&& let Some(image) = load_background_image(p.path()).map(image::Image::Pending)
&& let Some(image) =
load_background_image(&p.path().to_path()).map(image::Image::Pending)
{
// If we have an existing background image, replace it.
// Otherwise, set this as our background image directly.
@@ -1094,16 +1096,17 @@ fn upload_background_image<G: GraphicsApi>(bg_image: &mut BgImageState<G>, gpu:
/// Decode a background image file (`prepBackgroundImage`). Failures are
/// logged and result in no image, as in Zig.
fn load_background_image(path: &str) -> Option<image::Pending> {
fn load_background_image(path: &std::path::Path) -> Option<image::Pending> {
let shown = path.display();
// Read it (Max size of 4 GiB, for now.)
let contents = match std::fs::read(path) {
Ok(c) if u32::try_from(c.len()).is_ok() => c,
Ok(_) => {
log::warn!("background image file \"{path}\" is too large");
log::warn!("background image file \"{shown}\" is too large");
return None;
}
Err(err) => {
log::warn!("error opening background image file \"{path}\": {err}");
log::warn!("error opening background image file \"{shown}\": {err}");
return None;
}
};
@@ -1119,23 +1122,23 @@ fn load_background_image(path: &str) -> Option<image::Pending> {
FileType::Png => match decode_png(&contents) {
Ok(v) => v,
Err(err) => {
log::warn!("error decoding png background image \"{path}\": {err}");
log::warn!("error decoding png background image \"{shown}\": {err}");
return None;
}
},
FileType::Jpeg => match decode_jpeg(&contents) {
Ok(v) => v,
Err(err) => {
log::warn!("error decoding jpeg background image \"{path}\": {err}");
log::warn!("error decoding jpeg background image \"{shown}\": {err}");
return None;
}
},
FileType::Unknown => {
log::warn!("Cannot determine file type for background image file \"{path}\"!");
log::warn!("Cannot determine file type for background image file \"{shown}\"!");
return None;
}
f => {
log::warn!("Unsupported file type {f:?} for background image file \"{path}\"!");
log::warn!("Unsupported file type {f:?} for background image file \"{shown}\"!");
return None;
}
};
@@ -1179,8 +1182,8 @@ fn detect_file_type(contents: &[u8]) -> FileType {
}
}
fn guess_file_type_from_extension(path: &str) -> FileType {
let ext = std::path::Path::new(path)
fn guess_file_type_from_extension(path: &std::path::Path) -> FileType {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
@@ -3634,9 +3637,18 @@ mod tests {
assert_eq!(detect_file_type(&[0xFF, 0xD8, 0xFF, 0xE0]), FileType::Jpeg);
assert_eq!(detect_file_type(b"GIF89a.."), FileType::Gif);
assert_eq!(detect_file_type(b"hello"), FileType::Unknown);
assert_eq!(guess_file_type_from_extension("/a/b.JPG"), FileType::Jpeg);
assert_eq!(guess_file_type_from_extension("/a/b.png"), FileType::Png);
assert_eq!(guess_file_type_from_extension("/a/b"), FileType::Unknown);
assert_eq!(
guess_file_type_from_extension("/a/b.JPG".as_ref()),
FileType::Jpeg
);
assert_eq!(
guess_file_type_from_extension("/a/b.png".as_ref()),
FileType::Png
);
assert_eq!(
guess_file_type_from_extension("/a/b".as_ref()),
FileType::Unknown
);
}
#[test]
@@ -3655,15 +3667,15 @@ mod tests {
let path = dir.join("bg.png");
std::fs::write(&path, &bytes).unwrap();
let img = load_background_image(path.to_str().unwrap()).expect("decoded");
let img = load_background_image(&path).expect("decoded");
assert_eq!((img.width, img.height), (2, 1));
assert_eq!(img.data, [1, 2, 3, 255, 4, 5, 6, 128]);
// Missing / undecodable files are not an error, just no image.
assert!(load_background_image(dir.join("missing.png").to_str().unwrap()).is_none());
assert!(load_background_image(&dir.join("missing.png")).is_none());
let bad = dir.join("bad.txt");
std::fs::write(&bad, b"not an image").unwrap();
assert!(load_background_image(bad.to_str().unwrap()).is_none());
assert!(load_background_image(&bad).is_none());
std::fs::remove_dir_all(&dir).unwrap();
}
}
+7 -7
View File
@@ -80,9 +80,9 @@ pub enum Target {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathItem {
/// A path that may be missing (`?path`); missing files are skipped.
Optional(String),
Optional(std::path::PathBuf),
/// A path that must exist.
Required(String),
Required(std::path::PathBuf),
}
#[derive(Debug, thiserror::Error)]
@@ -124,14 +124,14 @@ pub fn load_from_files(paths: &[PathItem], target: Target) -> Result<Vec<String>
PathItem::Required(p) => (p, false),
};
let shader = match load_from_file(&ghostty_os::osbytes::config_str_to_path(path), target) {
let shader = match load_from_file(path, target) {
Ok(s) => s,
Err(Error::Io(e)) if optional && e.kind() == std::io::ErrorKind::NotFound => {
continue;
}
Err(e) => return Err(e),
};
log::info!("loaded custom shader path={path}");
log::info!("loaded custom shader path={}", path.display());
list.push(shader);
}
@@ -709,13 +709,13 @@ mod tests {
let missing = dir.join("missing.glsl");
let items = [
PathItem::Optional(missing.to_string_lossy().into_owned()),
PathItem::Required(good.to_string_lossy().into_owned()),
PathItem::Optional(missing.clone()),
PathItem::Required(good.clone()),
];
let shaders = load_from_files(&items, Target::Glsl).unwrap();
assert_eq!(shaders.len(), 1);
let items = [PathItem::Required(missing.to_string_lossy().into_owned())];
let items = [PathItem::Required(missing.clone())];
assert!(matches!(
load_from_files(&items, Target::Glsl),
Err(Error::Io(_))
@@ -560,14 +560,20 @@ fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
/// Returns true if path appears to be in a temporary directory.
/// Copies logic from Kitty.
fn is_path_in_temp_dir(path: &[u8]) -> bool {
is_path_in_temp_dir_with(path, alloc_tmp_dir())
}
/// [`is_path_in_temp_dir`] with an explicit temporary directory (an OS
/// string: it may be non-UTF-8, so it is compared byte for byte).
fn is_path_in_temp_dir_with(path: &[u8], tmp_dir: Option<std::ffi::OsString>) -> bool {
if path.starts_with(b"/tmp") {
return true;
}
if path.starts_with(b"/dev/shm") {
return true;
}
if let Some(dir) = alloc_tmp_dir() {
if path.starts_with(dir.as_bytes()) {
if let Some(dir) = tmp_dir {
if path.starts_with(dir.as_os_str().as_bytes()) {
return true;
}
@@ -582,22 +588,23 @@ fn is_path_in_temp_dir(path: &[u8]) -> bool {
false
}
/// `os/file.zig allocTmpDir` on POSIX (TMPDIR, TMP, then /tmp).
/// `os/file.zig allocTmpDir` on POSIX (TMPDIR, TMP, then /tmp), as the raw
/// OS string.
#[cfg(not(windows))]
#[allow(clippy::unnecessary_wraps)]
fn alloc_tmp_dir() -> Option<String> {
fn alloc_tmp_dir() -> Option<std::ffi::OsString> {
for key in ["TMPDIR", "TMP"] {
if let Some(v) = std::env::var_os(key) {
return Some(v.to_string_lossy().into_owned());
return Some(v);
}
}
Some("/tmp".to_owned())
Some("/tmp".into())
}
/// `os/file.zig allocTmpDir` on Windows (`TMP` only).
#[cfg(windows)]
fn alloc_tmp_dir() -> Option<String> {
std::env::var_os("TMP").map(|v| v.to_string_lossy().into_owned())
fn alloc_tmp_dir() -> Option<std::ffi::OsString> {
std::env::var_os("TMP")
}
/// Image represents a single fully loaded image.
@@ -655,6 +662,23 @@ pub struct Rect {
#[cfg(test)]
mod tests {
#[cfg(unix)]
#[test]
fn temp_dir_check_keeps_non_utf8_tmpdir_bytes() {
use std::os::unix::ffi::OsStringExt;
let dir = std::ffi::OsString::from_vec(b"/data/t\xffmp".to_vec());
assert!(is_path_in_temp_dir_with(
b"/data/t\xffmp/tty-graphics-protocol-x",
Some(dir.clone())
));
// A lossy conversion would have turned the byte into U+FFFD.
assert!(!is_path_in_temp_dir_with(
b"/data/t\xef\xbf\xbdmp/tty-graphics-protocol-x",
Some(dir)
));
assert!(!is_path_in_temp_dir_with(b"/data/x", None));
}
use ghostty_vt::kitty::graphics_command::{Control, Transmission};
use super::*;
@@ -185,9 +185,9 @@ impl<'a> Handler<'a> {
Ok(())
}
fn color_operation(&mut self, requests: &osc_color::List) {
for req in requests {
match *req {
fn color_operation(&mut self, requests: &osc_color::List<'_>) {
for req in requests.iter() {
match req {
Request::Set(set) => match set.target {
Target::Palette(i) => {
self.terminal.flags.dirty.palette = true;
@@ -231,10 +231,10 @@ impl<'a> Handler<'a> {
}
}
fn kitty_color_operation(&mut self, request: &kitty::color::OSC) {
fn kitty_color_operation(&mut self, request: &kitty::color::OSC<'_>) {
use kitty::color::{Kind, Request as KReq, Special};
for item in &request.list {
match item {
for item in request.list.iter() {
match &item {
KReq::Set { key, color } => match key {
Kind::Palette(palette) => {
self.terminal.flags.dirty.palette = true;
+9 -7
View File
@@ -177,7 +177,7 @@ pub struct Command {
/// Working directory to change to in the child process. If not set, the
/// working directory of the calling process is preserved. Failure to
/// change directory is ignored (as in Ghostty).
pub cwd: Option<String>,
pub cwd: Option<ghostty_os::osbytes::EncodedOs>,
/// The file handle to set for stdin/out/err. If this isn't set, we do
/// nothing explicitly so it is up to the behavior of the operating system.
@@ -317,8 +317,8 @@ impl Command {
let prep = self.prepare()?;
let cwd = self
.cwd
.as_deref()
.and_then(|c| CString::new(config_str_to_bytes(c)).ok());
.as_ref()
.and_then(|c| CString::new(c.to_bytes()).ok());
let mut argv_ptrs: Vec<*const c_char> = prep.argv.iter().map(|c| c.as_ptr()).collect();
argv_ptrs.push(std::ptr::null());
@@ -872,7 +872,7 @@ mod tests {
let mut cmd = sh("pwd");
cmd.stdout = Some(stdout.try_clone().unwrap().into());
cmd.cwd = Some("/tmp".to_owned());
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/tmp"));
cmd.start().unwrap();
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0));
@@ -894,7 +894,7 @@ mod tests {
let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-c", "pwd -P; printf %s \"$0\""]);
cmd.args.push(bytes_to_config_string(b"a\xffb"));
cmd.stdout = Some(stdout.try_clone().unwrap().into());
cmd.cwd = Some(bytes_to_config_string(&raw));
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_bytes(&raw));
cmd.start().unwrap();
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0));
@@ -915,7 +915,9 @@ mod tests {
let mut cmd = sh("echo ok");
cmd.stdout = Some(stdout.try_clone().unwrap().into());
cmd.cwd = Some("/definitely/not/a/dir".to_owned());
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded(
"/definitely/not/a/dir",
));
cmd.start().unwrap();
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0));
assert_eq!("ok\n", read_all(stdout));
@@ -933,7 +935,7 @@ mod tests {
let mut cmd = Command::new("/not/a/binary", &["/not/a/binary", ""]);
cmd.stdout = Some(stdout.try_clone().unwrap().into());
cmd.stderr = Some(stderr.try_clone().unwrap().into());
cmd.cwd = Some("/bin".to_owned());
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/bin"));
cmd.start().unwrap();
assert!(cmd.pid.is_some());
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(1));
+21 -10
View File
@@ -16,11 +16,13 @@ use std::{
ptr,
};
use ghostty_os::windows::{
CREATE_UNICODE_ENVIRONMENT,
EXTENDED_STARTUPINFO_PRESENT,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
to_wide_nul,
use ghostty_os::{
osbytes::EncodedOs,
windows::{
CREATE_UNICODE_ENVIRONMENT,
EXTENDED_STARTUPINFO_PRESENT,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
},
};
use windows_sys::Win32::{
Foundation::{HANDLE, TRUE, WAIT_FAILED},
@@ -43,7 +45,7 @@ use windows_sys::Win32::{
},
};
use crate::windows_args::{self, create_windows_env_block, windows_create_command_line};
use crate::windows_args::{self, create_windows_env_block, windows_create_command_line_wide};
/// Errors from [`Command::start`].
#[derive(Debug, thiserror::Error)]
@@ -77,7 +79,7 @@ pub struct Command {
/// Working directory for the child. If not set, the working directory of
/// the calling process is preserved.
pub cwd: Option<String>,
pub cwd: Option<ghostty_os::osbytes::EncodedOs>,
/// The file handles to set for stdin/out/err. If not set the NUL device
/// is used. Ignored if `pseudo_console` is set.
@@ -127,12 +129,21 @@ impl Command {
/// available.
#[allow(unsafe_code)]
pub fn start(&mut self) -> Result<(), StartError> {
let application_w = to_wide_nul(&self.path);
let cwd_w = self.cwd.as_deref().map(to_wide_nul);
// `path` and `args` are the config command's argv strings (encoded
// OS strings); decode them to WTF-16 for the Windows API.
let application_w = EncodedOs::from_encoded(&self.path).to_wide_nul();
let cwd_w = self.cwd.as_ref().map(EncodedOs::to_wide_nul);
let mut command_line_w = if self.args.is_empty() {
None
} else {
Some(to_wide_nul(&windows_create_command_line(&self.args)))
let wide: Vec<Vec<u16>> = self
.args
.iter()
.map(|a| EncodedOs::from_encoded(a).to_wide())
.collect();
let mut line = windows_create_command_line_wide(&wide);
line.push(0);
Some(line)
};
let mut env_w = match &self.env {
Some(map) => Some(create_windows_env_block(map.iter())?),
+89 -33
View File
@@ -32,7 +32,7 @@ use ghostty_config::{
types_b::{ShellIntegration, ShellIntegrationFeatures},
};
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_os::file::Rlimit;
use ghostty_os::{file::Rlimit, osbytes::EncodedOs};
use ghostty_renderer::size::{GridSize, ScreenSize};
use ghostty_terminal::terminal::Terminal;
use rustix::{
@@ -107,8 +107,8 @@ pub struct Config {
pub shell_integration: ShellIntegration,
pub shell_integration_features: ShellIntegrationFeatures,
pub cursor_blink: Option<bool>,
pub working_directory: Option<String>,
pub resources_dir: Option<String>,
pub working_directory: Option<EncodedOs>,
pub resources_dir: Option<EncodedOs>,
pub term: String,
pub rt_pre_exec_info: RtPreExecInfo,
@@ -158,8 +158,8 @@ impl Exec {
}
/// The working directory the subprocess will be started in, if any.
pub fn cwd(&self) -> Option<&str> {
self.subprocess.cwd.as_deref()
pub fn cwd(&self) -> Option<&EncodedOs> {
self.subprocess.cwd.as_ref()
}
/// The argv that will be executed.
@@ -181,7 +181,7 @@ impl Exec {
// set that on the terminal now. This allows rapidly initializing
// new surfaces to use the proper pwd.
if let Some(cwd) = &self.subprocess.cwd
&& let Err(err) = term.set_pwd(&ghostty_os::osbytes::config_str_to_bytes(cwd))
&& let Err(err) = term.set_pwd(&cwd.to_bytes())
{
log::warn!("error setting initial pwd err={err:?}");
}
@@ -766,7 +766,7 @@ struct FlatpakProcess {
}
struct Subprocess {
cwd: Option<String>,
cwd: Option<EncodedOs>,
env: Option<EnvMap>,
args: Vec<String>,
grid_size: GridSize,
@@ -790,11 +790,8 @@ impl Subprocess {
// If we have a resources dir then set our env var
if let Some(dir) = &cfg.resources_dir {
log::info!("found Ghostty resources dir: {dir}");
env.insert(
"GHOSTTY_RESOURCES_DIR",
ghostty_os::osbytes::config_str_to_os(dir),
);
log::info!("found Ghostty resources dir: {}", dir.as_encoded());
env.insert("GHOSTTY_RESOURCES_DIR", dir.to_os());
}
// Set our TERM var. This is a bit complicated because we want to use
@@ -923,7 +920,7 @@ impl Subprocess {
// https://github.com/kovidgoyal/kitty/issues/1595
// https://github.com/ghostty-org/ghostty/discussions/7769
if let Some(pwd) = &cwd {
env.insert("PWD", ghostty_os::osbytes::config_str_to_os(pwd));
env.insert("PWD", pwd.to_os());
}
Ok(Subprocess {
@@ -1002,7 +999,7 @@ impl Subprocess {
// If we can't access the cwd, then don't set any cwd and inherit.
// This is important because our cwd can be set by the shell (OSC 7)
// and we don't want to break new windows.
let cwd: Option<String> = match &self.cwd {
let cwd: Option<EncodedOs> = match &self.cwd {
None => None,
Some(proposed) if flatpak => {
// Flatpak sandboxing prevents access to certain reserved paths
@@ -1035,10 +1032,10 @@ impl Subprocess {
}
}
Some(proposed) => {
if std::path::Path::new(&ghostty_os::osbytes::config_str_to_os(proposed)).exists() {
if proposed.to_path().exists() {
Some(proposed.clone())
} else {
log::warn!("cannot access cwd, ignoring: {proposed}");
log::warn!("cannot access cwd, ignoring: {}", proposed.as_encoded());
None
}
}
@@ -1188,10 +1185,17 @@ fn add_ghostty_path(env: &mut EnvMap) {
return;
}
};
let Some(exe_dir) = exe.parent().map(|p| p.to_string_lossy().into_owned()) else {
add_ghostty_path_for(env, &exe);
}
/// [`add_ghostty_path`] for a given executable (testable with non-UTF-8
/// directories).
fn add_ghostty_path_for(env: &mut EnvMap, exe: &std::path::Path) {
// Keep the exact bytes of the directory (it may be non-UTF-8).
let Some(exe_dir) = exe.parent().map(|p| p.as_os_str().to_owned()) else {
return;
};
log::debug!("appending ghostty bin to path dir={exe_dir}");
log::debug!("appending ghostty bin to path dir={}", exe_dir.display());
// We always set this so that if the shell overwrites the path
// scripts still have a way to find the Ghostty binary when
@@ -1211,10 +1215,7 @@ fn add_ghostty_path(env: &mut EnvMap) {
{
return;
}
env.insert(
"PATH",
ghostty_os::env::append_env_os(&path, std::ffi::OsStr::new(&exe_dir)),
);
env.insert("PATH", ghostty_os::env::append_env_os(&path, &exe_dir));
} else {
env.insert("PATH", exe_dir);
}
@@ -1222,8 +1223,8 @@ fn add_ghostty_path(env: &mut EnvMap) {
/// The `TERMINFO` directory for a resources dir (reversible config-string
/// encoding in, raw OS string out).
fn terminfo_dir(base: &str) -> std::ffi::OsString {
let base = ghostty_os::osbytes::config_str_to_os(base);
fn terminfo_dir(base: &EncodedOs) -> std::ffi::OsString {
let base = base.to_os();
let own = std::path::Path::new(&base).join("terminfo");
if own.join("x/xterm-ghostty").exists() {
own.into_os_string()
@@ -1238,9 +1239,9 @@ fn terminfo_dir(base: &str) -> std::ffi::OsString {
/// macOS: export additional data directories from our application bundle
/// (`XDG_DATA_DIRS` and `MANPATH`).
pub fn darwin_bundle_env(env: &mut EnvMap, resources_dir: &str) {
pub fn darwin_bundle_env(env: &mut EnvMap, resources_dir: &EncodedOs) {
let xdg_data_dir_key = "XDG_DATA_DIRS";
let mut data_dir = ghostty_os::osbytes::config_str_to_os(resources_dir);
let mut data_dir = resources_dir.to_os();
data_dir.push("/..");
let current = env.get(xdg_data_dir_key).map_or_else(
|| std::ffi::OsString::from("/usr/local/share:/usr/share"),
@@ -1252,7 +1253,7 @@ pub fn darwin_bundle_env(env: &mut EnvMap, resources_dir: &str) {
);
let manpath_key = "MANPATH";
let mut man_dir = ghostty_os::osbytes::config_str_to_os(resources_dir);
let mut man_dir = resources_dir.to_os();
man_dir.push("/../man");
// Always append with colon in front, as it mean that if
// `MANPATH` is empty, then it should be treated as an extra
@@ -1439,11 +1440,13 @@ fn darwin_login_command(
let hush = match &passwd.home {
Some(home) => {
if std::fs::read_dir(home).is_err() {
// The passwd home is an encoded OS string: decode it first.
let home = home.to_path();
if std::fs::read_dir(&home).is_err() {
log::warn!("failed to open home dir, not checking for hushlogin");
false
} else {
std::path::Path::new(home).join(".hushlogin").exists()
home.join(".hushlogin").exists()
}
}
None => false,
@@ -1507,7 +1510,7 @@ fn darwin_login_command(
args.push("-q".into());
}
args.push("-flp".into());
args.push(username);
args.push(username.into_encoded());
match command {
// Direct args can be passed directly to login, since
@@ -1552,11 +1555,64 @@ impl Drop for ThreadData {
mod tests {
use super::*;
#[test]
fn ghostty_path_keeps_non_utf8_exe_dir_bytes() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let exe = std::path::PathBuf::from(std::ffi::OsString::from_vec(
b"/opt/gh\xffostty/bin/ghostty".to_vec(),
));
// No PATH: PATH becomes the directory itself.
let mut env = EnvMap::new();
add_ghostty_path_for(&mut env, &exe);
assert_eq!(
env.get("GHOSTTY_BIN_DIR").unwrap().as_bytes(),
b"/opt/gh\xffostty/bin"
);
assert_eq!(env.get("PATH").unwrap().as_bytes(), b"/opt/gh\xffostty/bin");
// Existing PATH: appended byte for byte (no U+FFFD), and not twice.
let mut env = EnvMap::new();
env.insert("PATH", "/usr/bin");
add_ghostty_path_for(&mut env, &exe);
assert_eq!(
env.get("PATH").unwrap().as_bytes(),
b"/usr/bin:/opt/gh\xffostty/bin"
);
add_ghostty_path_for(&mut env, &exe);
assert_eq!(
env.get("PATH").unwrap().as_bytes(),
b"/usr/bin:/opt/gh\xffostty/bin"
);
}
#[test]
fn exec_command_darwin_hushlogin_with_non_utf8_home() {
use std::os::unix::ffi::OsStrExt;
let tmp = ghostty_os::TempDir::init().unwrap();
let home = tmp.path().join(std::ffi::OsStr::from_bytes(b"h\xffome"));
std::fs::create_dir_all(&home).unwrap();
std::fs::write(home.join(".hushlogin"), "").unwrap();
// The passwd entry carries the *encoded* home; it must be decoded
// before the lookup.
let enc = EncodedOs::from_path(&home);
assert_ne!(enc.as_encoded().as_bytes(), home.as_os_str().as_bytes());
let passwd = move || {
Ok(ghostty_os::passwd::Entry {
name: Some(EncodedOs::from_encoded("u")),
home: Some(enc.clone()),
..Default::default()
})
};
let result = exec_command_with(&ConfigCommand::Direct(vec!["x".into()]), true, &passwd);
assert_eq!(result, ["/usr/bin/login", "-q", "-flp", "u", "x"]);
}
fn pw(name: Option<&str>) -> impl Fn() -> std::io::Result<ghostty_os::passwd::Entry> {
let name = name.map(str::to_owned);
move || {
Ok(ghostty_os::passwd::Entry {
name: name.clone(),
name: name.clone().map(EncodedOs::from_encoded),
..Default::default()
})
}
@@ -1602,10 +1658,10 @@ mod tests {
let dir = std::env::temp_dir().join(format!("ghostty-hush-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(".hushlogin"), "").unwrap();
let home = dir.to_str().unwrap().to_owned();
let home = EncodedOs::from_path(&dir);
let passwd = move || {
Ok(ghostty_os::passwd::Entry {
name: Some("u".into()),
name: Some(EncodedOs::from_encoded("u")),
home: Some(home.clone()),
..Default::default()
})
+58 -23
View File
@@ -35,6 +35,7 @@ use ghostty_config::{
types_b::{ShellIntegration, ShellIntegrationFeatures},
};
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_os::osbytes::EncodedOs;
use ghostty_renderer::size::{GridSize, ScreenSize};
use ghostty_terminal::terminal::Terminal;
use windows_sys::Win32::{
@@ -99,8 +100,8 @@ pub struct Config {
pub shell_integration: ShellIntegration,
pub shell_integration_features: ShellIntegrationFeatures,
pub cursor_blink: Option<bool>,
pub working_directory: Option<String>,
pub resources_dir: Option<String>,
pub working_directory: Option<EncodedOs>,
pub resources_dir: Option<EncodedOs>,
pub term: String,
}
@@ -136,8 +137,8 @@ impl Exec {
}
/// The working directory the subprocess will be started in, if any.
pub fn cwd(&self) -> Option<&str> {
self.subprocess.cwd.as_deref()
pub fn cwd(&self) -> Option<&EncodedOs> {
self.subprocess.cwd.as_ref()
}
/// The argv that will be executed.
@@ -157,7 +158,7 @@ impl Exec {
// set that on the terminal now. This allows rapidly initializing
// new surfaces to use the proper pwd.
if let Some(cwd) = &self.subprocess.cwd
&& let Err(err) = term.set_pwd(cwd.as_bytes())
&& let Err(err) = term.set_pwd(&cwd.to_bytes())
{
log::warn!("error setting initial pwd err={err:?}");
}
@@ -583,7 +584,7 @@ fn write_thread_main(handle: &OwnedHandle, rx: &Receiver<Vec<u8>>) {
// Subprocess
struct Subprocess {
cwd: Option<String>,
cwd: Option<EncodedOs>,
env: Option<EnvMap>,
args: Vec<String>,
grid_size: GridSize,
@@ -602,8 +603,10 @@ impl Subprocess {
// If we have a resources dir then set our env var
if let Some(dir) = &cfg.resources_dir {
log::info!("found Ghostty resources dir: {dir}");
env.insert("GHOSTTY_RESOURCES_DIR", dir.clone());
log::info!("found Ghostty resources dir: {}", dir.as_encoded());
// The resources dir is an encoded OS string: decode it for the
// child's environment.
env.insert("GHOSTTY_RESOURCES_DIR", dir.to_os());
}
// Set our TERM var. This is a bit complicated because we want to use
@@ -615,10 +618,7 @@ impl Subprocess {
// Assume that the resources directory is adjacent to the terminfo
// database
let parent = std::path::Path::new(base)
.parent()
.map_or_else(String::new, |p| p.to_string_lossy().into_owned());
env.insert("TERMINFO", format!("{parent}\\terminfo"));
env.insert("TERMINFO", terminfo_dir(base));
} else {
env.insert("TERM", "xterm-256color");
env.insert("COLORTERM", "truecolor");
@@ -693,7 +693,10 @@ impl Subprocess {
// Build our args list. If we fail to find the shell, we'd still like
// to try to run something so Ghostty can launch.
let args = match exec_command(&shell_command, std::env::var("WINDIR").ok().as_deref()) {
// %WINDIR% may hold lone surrogates: carry it in the encoded form the
// argv strings use (decoded to WTF-16 when the process is created).
let windir = std::env::var_os("WINDIR").map(|v| EncodedOs::from_os(&v).into_encoded());
let args = match exec_command(&shell_command, windir.as_deref()) {
Ok(args) => args,
Err(Error::SystemError) => {
log::warn!("failed to get WINDIR, cannot run shell command");
@@ -708,7 +711,7 @@ impl Subprocess {
// Propagate the current working directory (CWD) to the shell.
if let Some(pwd) = &cwd {
env.insert("PWD", pwd.clone());
env.insert("PWD", pwd.to_os());
}
Ok(Subprocess {
@@ -777,9 +780,9 @@ impl Subprocess {
// If we can't access the cwd, then don't set any cwd and inherit.
// This is important because our cwd can be set by the shell (OSC 7)
// and we don't want to break new windows.
let cwd: Option<String> = match &self.cwd {
let cwd: Option<EncodedOs> = match &self.cwd {
None => None,
Some(proposed) => match std::fs::metadata(proposed) {
Some(proposed) => match std::fs::metadata(proposed.to_path()) {
Ok(_) => Some(proposed.clone()),
Err(err) => {
log::warn!("cannot access cwd, ignoring: {err}");
@@ -852,10 +855,17 @@ fn add_ghostty_path(env: &mut EnvMap) {
return;
}
};
let Some(exe_dir) = exe.parent().map(|p| p.to_string_lossy().into_owned()) else {
add_ghostty_path_for(env, &exe);
}
/// [`add_ghostty_path`] for a given executable (testable with non-UTF-8
/// directories).
fn add_ghostty_path_for(env: &mut EnvMap, exe: &std::path::Path) {
// Keep the exact OS string (it may hold lone UTF-16 surrogates).
let Some(exe_dir) = exe.parent().map(|p| p.as_os_str().to_owned()) else {
return;
};
log::debug!("appending ghostty bin to path dir={exe_dir}");
log::debug!("appending ghostty bin to path dir={}", exe_dir.display());
// We always set this so that if the shell overwrites the path
// scripts still have a way to find the Ghostty binary when
@@ -868,19 +878,27 @@ fn add_ghostty_path(env: &mut EnvMap) {
if let Some(path) = env.get("PATH").map(std::ffi::OsStr::to_owned) {
// Verify that our path doesn't already contain this entry
if std::env::split_paths(&path)
.any(|entry| !entry.as_os_str().is_empty() && entry.as_os_str() == exe_dir.as_str())
.any(|entry| !entry.as_os_str().is_empty() && entry.as_os_str() == exe_dir.as_os_str())
{
return;
}
env.insert(
"PATH",
ghostty_os::env::append_env_os(&path, std::ffi::OsStr::new(&exe_dir)),
);
env.insert("PATH", ghostty_os::env::append_env_os(&path, &exe_dir));
} else {
env.insert("PATH", exe_dir);
}
}
/// The `TERMINFO` value for a resources dir: `<parent of base>\\terminfo` as
/// an OS string (the resources dir is an encoded OS string).
fn terminfo_dir(base: &EncodedOs) -> std::ffi::OsString {
let base = base.to_path();
let mut out = base
.parent()
.map_or_else(std::ffi::OsString::new, |p| p.as_os_str().to_owned());
out.push("\\terminfo");
out
}
/// Terminate the underlying subprocess and wait for it to exit
/// (`killCommand`: `TerminateProcess(pid, 0)` then wait).
fn kill_command(command: &Command) -> Result<(), Error> {
@@ -908,6 +926,23 @@ pub fn exec_command(command: &ConfigCommand, windir: Option<&str>) -> Result<Vec
#[cfg(test)]
mod tests {
#[test]
fn terminfo_dir_is_sibling_of_resources_and_lossless() {
use std::os::windows::ffi::{OsStrExt, OsStringExt};
// C:\Users\<lone surrogate>\share\ghostty -> ...\share\terminfo
let mut wide: Vec<u16> = "C:\\Users\\".encode_utf16().collect();
wide.push(0xD800);
wide.extend("\\share\\ghostty".encode_utf16());
let base = EncodedOs::from_wide(&wide);
let got = super::terminfo_dir(&base);
let mut want: Vec<u16> = "C:\\Users\\".encode_utf16().collect();
want.push(0xD800);
want.extend("\\share\\terminfo".encode_utf16());
assert_eq!(got.encode_wide().collect::<Vec<_>>(), want);
assert_eq!(std::ffi::OsString::from_wide(&want), got);
}
use super::*;
#[test]
+50 -36
View File
@@ -5,7 +5,7 @@
use std::path::Path;
use ghostty_os::{env::prepend_env_os, homedir, shell::ShellCommandBuilder};
use ghostty_os::{env::prepend_env_os, homedir, osbytes::EncodedOs, shell::ShellCommandBuilder};
/// The environment passed to the child (Zig `std.process.EnvMap`).
pub type EnvMap = ghostty_os::EnvMap;
@@ -65,8 +65,8 @@ pub struct ShellIntegration {
/// `<resource_dir>/<rel>` as an OS string. `resource_dir` carries the
/// reversible config-string encoding (it may hold non-UTF-8 bytes), so it is
/// decoded before reaching the filesystem or the child environment.
fn resource_path(resource_dir: &str, rel: &str) -> std::ffi::OsString {
let mut p = ghostty_os::osbytes::config_str_to_os(resource_dir);
fn resource_path(resource_dir: &EncodedOs, rel: &str) -> std::ffi::OsString {
let mut p = resource_dir.to_os();
p.push("/");
p.push(rel);
p
@@ -77,7 +77,7 @@ fn resource_path(resource_dir: &str, rel: &str) -> std::ffi::OsString {
/// struct describing the integration. If integration fails
/// (shell type couldn't be detected, etc.), this will return None.
pub fn setup(
resource_dir: &str,
resource_dir: &EncodedOs,
command: &Command,
env: &mut EnvMap,
force_shell: Option<Shell>,
@@ -185,7 +185,7 @@ pub fn setup_features(env: &mut EnvMap, features: ShellIntegrationFeatures, curs
/// or None if integration failed.
fn setup_bash(
command: &Command,
resource_dir: &str,
resource_dir: &EncodedOs,
env: &mut EnvMap,
) -> Result<Option<Command>, Error> {
let mut cmd = ShellCommandBuilder::new();
@@ -271,7 +271,10 @@ fn setup_bash(
if !env.contains_key("HISTFILE")
&& let Some(home) = homedir::home()?
{
env.insert("HISTFILE", format!("{home}/.bash_history"));
// `home` is an encoded OS string: build the real OS value.
let mut histfile = home.to_os();
histfile.push("/.bash_history");
env.insert("HISTFILE", histfile);
env.insert("GHOSTTY_BASH_UNEXPORT_HISTFILE", "1");
}
@@ -286,7 +289,7 @@ fn setup_bash(
/// It is also saved in the `GHOSTTY_SHELL_INTEGRATION_XDG_DIR` variable
/// so that the shell can refer to it and safely remove this directory
/// from `XDG_DATA_DIRS` when integration is complete.
fn setup_xdg_data_dirs(resource_dir: &str, env: &mut EnvMap) -> bool {
fn setup_xdg_data_dirs(resource_dir: &EncodedOs, env: &mut EnvMap) -> bool {
// Get our path to the shell integration directory.
let integ_path = resource_path(resource_dir, "shell-integration");
if let Err(err) = std::fs::read_dir(&integ_path) {
@@ -319,7 +322,7 @@ fn setup_xdg_data_dirs(resource_dir: &str, env: &mut EnvMap) -> bool {
///
/// We then add `--execute 'use ghostty ...'` to the nu command line to
/// automatically enable our shell features.
fn setup_nushell(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Option<Command> {
fn setup_nushell(command: &Command, resource_dir: &EncodedOs, env: &mut EnvMap) -> Option<Command> {
// Add our XDG_DATA_DIRS entry (for nushell/vendor/autoload/). This
// makes our 'ghostty' module automatically available, even if any
// of the later checks abort the rest of our automatic integration.
@@ -377,7 +380,7 @@ fn setup_nushell(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Opt
/// Setup the zsh automatic shell integration. This works by setting
/// ZDOTDIR to our resources dir so that zsh will load our config. This
/// config then loads the true user config.
fn setup_zsh(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Option<Command> {
fn setup_zsh(command: &Command, resource_dir: &EncodedOs, env: &mut EnvMap) -> Option<Command> {
// Preserve an existing ZDOTDIR value. We're about to overwrite it.
if let Some(old) = env.get("ZDOTDIR").map(std::ffi::OsStr::to_owned) {
env.insert("GHOSTTY_ZSH_ZDOTDIR", old);
@@ -419,6 +422,10 @@ mod tests {
}
impl TmpResourcesDir {
fn enc(&self) -> EncodedOs {
EncodedOs::from_plain_text(&self.path)
}
fn new(shell: Shell) -> Self {
let tmp = TempDir::init().unwrap();
let path = std::fs::canonicalize(tmp.path())
@@ -455,7 +462,7 @@ mod tests {
let mut env = EnvMap::new();
for shell in Shell::ALL {
let res = TmpResourcesDir::new(shell);
let result = setup(&res.path, &sh("sh"), &mut env, Some(shell)).unwrap();
let result = setup(&res.enc(), &sh("sh"), &mut env, Some(shell)).unwrap();
assert_eq!(shell, result.unwrap().shell);
}
}
@@ -463,7 +470,13 @@ mod tests {
#[test]
fn shell_integration_failure() {
let mut env = EnvMap::new();
let result = setup("/nonexistent", &sh("sh"), &mut env, None).unwrap();
let result = setup(
&EncodedOs::from_encoded("/nonexistent"),
&sh("sh"),
&mut env,
None,
)
.unwrap();
assert!(result.is_none());
assert_eq!(0, env.len());
}
@@ -585,7 +598,7 @@ mod tests {
let res = TmpResourcesDir::new(Shell::Bash);
let mut env = EnvMap::new();
let command = setup_bash(&sh("bash"), &res.path, &mut env).unwrap();
let command = setup_bash(&sh("bash"), &res.enc(), &mut env).unwrap();
assert_eq!("bash --posix", shell_str(command.as_ref()));
assert_eq!(
"1",
@@ -609,7 +622,7 @@ mod tests {
] {
let mut env = EnvMap::new();
assert!(
setup_bash(&sh(cmdline), &res.path, &mut env)
setup_bash(&sh(cmdline), &res.enc(), &mut env)
.unwrap()
.is_none()
);
@@ -622,7 +635,7 @@ mod tests {
let res = TmpResourcesDir::new(Shell::Bash);
let mut env = EnvMap::new();
let command = setup_bash(&sh("bash --norc"), &res.path, &mut env).unwrap();
let command = setup_bash(&sh("bash --norc"), &res.enc(), &mut env).unwrap();
assert_eq!("bash --posix", shell_str(command.as_ref()));
assert_eq!(
"1 --norc",
@@ -630,7 +643,7 @@ mod tests {
);
let mut env = EnvMap::new();
let command = setup_bash(&sh("bash --noprofile"), &res.path, &mut env).unwrap();
let command = setup_bash(&sh("bash --noprofile"), &res.enc(), &mut env).unwrap();
assert_eq!("bash --posix", shell_str(command.as_ref()));
assert_eq!(
"1 --noprofile",
@@ -643,14 +656,14 @@ mod tests {
let res = TmpResourcesDir::new(Shell::Bash);
let mut env = EnvMap::new();
let command = setup_bash(&sh("bash --rcfile profile.sh"), &res.path, &mut env).unwrap();
let command = setup_bash(&sh("bash --rcfile profile.sh"), &res.enc(), &mut env).unwrap();
assert_eq!("bash --posix", shell_str(command.as_ref()));
assert_eq!(
"profile.sh",
env.get("GHOSTTY_BASH_RCFILE").unwrap().to_str().unwrap()
);
let command = setup_bash(&sh("bash --init-file profile.sh"), &res.path, &mut env).unwrap();
let command = setup_bash(&sh("bash --init-file profile.sh"), &res.enc(), &mut env).unwrap();
assert_eq!("bash --posix", shell_str(command.as_ref()));
assert_eq!(
"profile.sh",
@@ -665,7 +678,7 @@ mod tests {
// HISTFILE unset (needs a home dir; set HOME so it is deterministic)
temp_env::with_var("HOME", Some("/home/test"), || {
let mut env = EnvMap::new();
setup_bash(&sh("bash"), &res.path, &mut env).unwrap();
setup_bash(&sh("bash"), &res.enc(), &mut env).unwrap();
assert!(
env.get("HISTFILE")
.unwrap()
@@ -685,7 +698,7 @@ mod tests {
// HISTFILE set
let mut env = EnvMap::new();
env.insert("HISTFILE", "my_history");
setup_bash(&sh("bash"), &res.path, &mut env).unwrap();
setup_bash(&sh("bash"), &res.enc(), &mut env).unwrap();
assert_eq!("my_history", env.get("HISTFILE").unwrap().to_str().unwrap());
assert!(!env.contains_key("GHOSTTY_BASH_UNEXPORT_HISTFILE"));
}
@@ -696,7 +709,7 @@ mod tests {
let mut env = EnvMap::new();
env.insert("ENV", "env.sh");
setup_bash(&sh("bash"), &res.path, &mut env).unwrap();
setup_bash(&sh("bash"), &res.enc(), &mut env).unwrap();
assert_eq!(
"env.sh",
env.get("GHOSTTY_BASH_ENV").unwrap().to_str().unwrap()
@@ -712,13 +725,13 @@ mod tests {
let res = TmpResourcesDir::new(Shell::Bash);
let mut env = EnvMap::new();
let command = setup_bash(&sh("bash - --arg file1 file2"), &res.path, &mut env).unwrap();
let command = setup_bash(&sh("bash - --arg file1 file2"), &res.enc(), &mut env).unwrap();
assert_eq!(
"bash --posix - --arg file1 file2",
shell_str(command.as_ref())
);
let command = setup_bash(&sh("bash -- --arg file1 file2"), &res.path, &mut env).unwrap();
let command = setup_bash(&sh("bash -- --arg file1 file2"), &res.enc(), &mut env).unwrap();
assert_eq!(
"bash --posix -- --arg file1 file2",
shell_str(command.as_ref())
@@ -731,7 +744,7 @@ mod tests {
let dir = std::fs::canonicalize(tmp.path()).unwrap();
let mut env = EnvMap::new();
assert!(
setup_bash(&sh("bash"), dir.to_str().unwrap(), &mut env)
setup_bash(&sh("bash"), &EncodedOs::from_path(&dir), &mut env)
.unwrap()
.is_none()
);
@@ -743,7 +756,7 @@ mod tests {
let res = TmpResourcesDir::new(Shell::Fish);
let mut env = EnvMap::new();
assert!(setup_xdg_data_dirs(&res.path, &mut env));
assert!(setup_xdg_data_dirs(&res.enc(), &mut env));
assert_eq!(
format!("{}/shell-integration", res.path),
env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR")
@@ -763,7 +776,7 @@ mod tests {
let mut env = EnvMap::new();
env.insert("XDG_DATA_DIRS", "/opt/share");
assert!(setup_xdg_data_dirs(&res.path, &mut env));
assert!(setup_xdg_data_dirs(&res.enc(), &mut env));
assert_eq!(
format!("{}/shell-integration", res.path),
env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR")
@@ -782,7 +795,7 @@ mod tests {
let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap();
let mut env = EnvMap::new();
assert!(!setup_xdg_data_dirs(dir.to_str().unwrap(), &mut env));
assert!(!setup_xdg_data_dirs(&EncodedOs::from_path(&dir), &mut env));
assert_eq!(0, env.len());
}
@@ -791,7 +804,7 @@ mod tests {
let res = TmpResourcesDir::new(Shell::Nushell);
let mut env = EnvMap::new();
let command = setup_nushell(&sh("nu"), &res.path, &mut env);
let command = setup_nushell(&sh("nu"), &res.enc(), &mut env);
assert_eq!("nu --execute 'use ghostty *'", shell_str(command.as_ref()));
assert_eq!(
format!("{}/shell-integration", res.path),
@@ -819,7 +832,7 @@ mod tests {
"nu -ic script.sh",
] {
let mut env = EnvMap::new();
assert!(setup_nushell(&sh(cmdline), &res.path, &mut env).is_none());
assert!(setup_nushell(&sh(cmdline), &res.enc(), &mut env).is_none());
assert!(env.contains_key("XDG_DATA_DIRS"));
assert!(env.contains_key("GHOSTTY_SHELL_INTEGRATION_XDG_DIR"));
}
@@ -830,7 +843,7 @@ mod tests {
let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap();
let mut env = EnvMap::new();
assert!(setup_nushell(&sh("nu"), dir.to_str().unwrap(), &mut env).is_none());
assert!(setup_nushell(&sh("nu"), &EncodedOs::from_path(&dir), &mut env).is_none());
assert_eq!(0, env.len());
}
@@ -839,7 +852,7 @@ mod tests {
let res = TmpResourcesDir::new(Shell::Zsh);
let mut env = EnvMap::new();
let command = setup_zsh(&sh("zsh"), &res.path, &mut env);
let command = setup_zsh(&sh("zsh"), &res.enc(), &mut env);
assert_eq!("zsh", shell_str(command.as_ref()));
assert_eq!(
res.shell_path,
@@ -854,7 +867,7 @@ mod tests {
let mut env = EnvMap::new();
env.insert("ZDOTDIR", "$HOME/.config/zsh");
let command = setup_zsh(&sh("zsh"), &res.path, &mut env);
let command = setup_zsh(&sh("zsh"), &res.enc(), &mut env);
assert_eq!("zsh", shell_str(command.as_ref()));
assert_eq!(
res.shell_path,
@@ -871,7 +884,7 @@ mod tests {
let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap();
let mut env = EnvMap::new();
assert!(setup_zsh(&sh("zsh"), dir.to_str().unwrap(), &mut env).is_none());
assert!(setup_zsh(&sh("zsh"), &EncodedOs::from_path(&dir), &mut env).is_none());
assert_eq!(0, env.len());
}
@@ -879,9 +892,11 @@ mod tests {
fn bundled_resources_exist() {
// The shipped scripts that setup() points shells at.
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources");
let root = root.to_str().unwrap();
let root_enc = EncodedOs::from_path(&root);
let mut env = EnvMap::new();
let r = setup(root, &sh("bash"), &mut env, None).unwrap().unwrap();
let r = setup(&root_enc, &sh("bash"), &mut env, None)
.unwrap()
.unwrap();
assert_eq!(r.shell, Shell::Bash);
assert!(
env.get("ENV")
@@ -891,8 +906,7 @@ mod tests {
.ends_with("shell-integration/bash/ghostty.bash")
);
assert!(
Path::new(root)
.join("shell-integration/nushell/vendor/autoload/ghostty.nu")
root.join("shell-integration/nushell/vendor/autoload/ghostty.nu")
.is_file()
);
}
+6 -6
View File
@@ -1022,7 +1022,7 @@ impl StreamHandler<'_> {
Ok(())
}
fn color_operation(&mut self, requests: &osc_color::List, terminator: Terminator) {
fn color_operation(&mut self, requests: &osc_color::List<'_>, terminator: Terminator) {
// return early if there is nothing to do
if requests.is_empty() {
return;
@@ -1030,8 +1030,8 @@ impl StreamHandler<'_> {
let mut response: Vec<u8> = Vec::new();
for req in requests {
match *req {
for req in requests.iter() {
match req {
Request::Set(set) => {
match set.target {
Target::Palette(i) => {
@@ -1216,13 +1216,13 @@ impl StreamHandler<'_> {
}
}
fn kitty_color_report(&mut self, request: &kitty::color::OSC) {
fn kitty_color_report(&mut self, request: &kitty::color::OSC<'_>) {
use kitty::color::{Kind, Request as KReq, Special};
let mut out: Vec<u8> = Vec::new();
for item in &request.list {
match item {
for item in request.list.iter() {
match &item {
KReq::Query(key) => {
// If the writer buffer is empty, we need to write our prefix
if out.is_empty() {
+61
View File
@@ -90,6 +90,48 @@ pub fn windows_create_command_line<S: AsRef<str>>(argv: &[S]) -> String {
buf
}
/// [`windows_create_command_line`] over UTF-16 arguments (which may hold
/// lone surrogates, so they cannot be a `str`). The quoting rules are the
/// same: only ASCII characters are significant, so the logic is identical on
/// code units.
pub fn windows_create_command_line_wide(argv: &[Vec<u16>]) -> Vec<u16> {
let (sp, tab, nl, quote, bs) = (
u16::from(b' '),
u16::from(b'\t'),
u16::from(b'\n'),
u16::from(b'"'),
u16::from(b'\\'),
);
let mut buf: Vec<u16> = Vec::new();
for (arg_i, arg) in argv.iter().enumerate() {
if arg_i != 0 {
buf.push(sp);
}
if !arg.iter().any(|c| [sp, tab, nl, quote].contains(c)) {
buf.extend_from_slice(arg);
continue;
}
buf.push(quote);
let mut backslash_count = 0usize;
for &c in arg {
if c == bs {
backslash_count += 1;
} else if c == quote {
buf.extend(std::iter::repeat_n(bs, backslash_count * 2 + 1));
buf.push(quote);
backslash_count = 0;
} else {
buf.extend(std::iter::repeat_n(bs, backslash_count));
buf.push(c);
backslash_count = 0;
}
}
buf.extend(std::iter::repeat_n(bs, backslash_count * 2));
buf.push(quote);
}
buf
}
/// The argv for a `shell` command on Windows (`execCommand`): the command is
/// run as `%WINDIR%\System32\cmd.exe /C <command>`.
pub fn windows_shell_args(windir: &str, command: &str) -> Vec<String> {
@@ -115,6 +157,25 @@ mod tests {
windows_create_command_line(args)
}
#[test]
fn wide_command_line_matches_str_version_and_keeps_lone_surrogates() {
let w = |s: &str| s.encode_utf16().collect::<Vec<u16>>();
for args in [
vec!["a", "b c", "d\"e", "f\\", "g h\\"],
vec!["cmd.exe", "/C", "echo \"hi\" there"],
] {
let wide: Vec<Vec<u16>> = args.iter().map(|a| w(a)).collect();
assert_eq!(
windows_create_command_line_wide(&wide),
w(&windows_create_command_line(&args))
);
}
// A lone surrogate inside a quoted argument is carried unchanged.
let lone = vec![u16::from(b'a'), u16::from(b' '), 0xD800];
let out = windows_create_command_line_wide(&[lone]);
assert_eq!(out, [34, 97, 32, 0xD800, 34]);
}
#[test]
fn shell_args_use_windir_cmd() {
assert_eq!(
+57 -2
View File
@@ -6,7 +6,11 @@ use std::{
ptr::NonNull,
};
use ghostty_vt::osc::{Command, Parser};
use ghostty_vt::osc::{Command, Parser, color::RECORD_LEN};
/// Records the color request list holds without growing (Zig:
/// `SegmentedList(Request, 4)`).
const COLOR_LIST_INLINE_RECORDS: usize = 4;
use crate::{
alloc::{Alloc, Allocator, CBlock, CByteStorage},
@@ -70,9 +74,16 @@ pub unsafe extern "C" fn ghostty_osc_new(
let Some(storage) = CByteStorage::new(alloc, ghostty_vt::osc::MAX_BUF) else {
return GhosttyResult::OutOfMemory;
};
// Request lists of the color OSCs live in a second block from the same
// allocator (Zig: a `SegmentedList(Request, 4)` on the parser's
// allocator). Four inline records, like Zig, so short color commands
// never allocate after creation; longer lists grow via the allocator.
let Some(aux) = CByteStorage::new(alloc, COLOR_LIST_INLINE_RECORDS * RECORD_LEN) else {
return GhosttyResult::OutOfMemory;
};
let Some(p) = alloc.create(OscParser {
alloc,
parser: Parser::with_storage(storage),
parser: Parser::with_storage(storage, aux),
command: OscCommand {
key: 0,
title_z: None,
@@ -320,6 +331,16 @@ mod tests {
("66;s=2;hi", false),
("3008;start=abc;type=shell", false),
("5522;type=read;data", false),
// Color commands: the request lists live in the parser's
// storage, which comes from the caller's allocator.
("4;1;rgb:ff/00/00;2;?", false),
("5;0;rgb:00/00/00", false),
("10;rgb:ff/ff/ff;?", false),
("11;?", false),
("104;1;2;3;4", false),
("104", false),
("112", false),
("21;foreground=?;background=rgb:f0/f8/ff;cursor", false),
("0;a title", true),
] {
ghostty_osc_reset(p);
@@ -346,6 +367,40 @@ mod tests {
}
}
#[test]
fn long_color_lists_grow_via_the_caller_allocator() {
let caller_allocs = AtomicUsize::new(0);
let a = Allocator {
ctx: (&raw const caller_allocs).cast_mut().cast(),
vtable: &raw const VT,
};
let mut p = std::ptr::null_mut();
// SAFETY: valid allocator and out pointer.
assert_eq!(
unsafe { ghostty_osc_new(&raw const a, &mut p) },
GhosttyResult::Success
);
// SAFETY: `p` is live for the whole block.
unsafe {
for osc in ["4;0;?;1;?;2;?;3;?;4;?;5;?", "21;1=?;2=?;3=?;4=?;5=?;6=?"] {
ghostty_osc_reset(p);
for b in osc.bytes() {
ghostty_osc_next(p, b);
}
let global_before = global_allocs();
let caller_before = caller_allocs.load(Ordering::SeqCst);
let cmd = ghostty_osc_end(p, 0x07);
assert_ne!(ghostty_osc_command_type(cmd), 0, "{osc}");
assert_eq!(global_allocs(), global_before, "global allocation: {osc}");
assert!(
caller_allocs.load(Ordering::SeqCst) > caller_before,
"list did not grow through the caller allocator: {osc}"
);
}
ghostty_osc_free(p);
}
}
fn new() -> *mut OscParser {
let mut p = std::ptr::null_mut();
// SAFETY: valid out pointer, default allocator.
+41 -2
View File
@@ -71,7 +71,7 @@ int main(void) {
GhosttyAllocator a = {.ctx = &c, .vtable = &VT};
GhosttyOscParser p;
assert(ghostty_osc_new(&a, &p) == GHOSTTY_SUCCESS);
assert(c.allocs >= 2); // the parser object and its buffer
assert(c.allocs >= 3); // the parser object, its buffer and request storage
assert(c.live >= 2048); // the fixed payload buffer is ours
feed(p, "0;hello", 7);
GhosttyOscCommand cmd = ghostty_osc_end(p, 0x07);
@@ -88,7 +88,7 @@ int main(void) {
// 2. Creation failure at each allocation point is OUT_OF_MEMORY and leaks
// nothing.
for (size_t fail_after = 0; fail_after < 2; fail_after++) {
for (size_t fail_after = 0; fail_after < 3; fail_after++) {
Counter c = counter(0, fail_after);
GhosttyAllocator a = {.ctx = &c, .vtable = &VT};
GhosttyOscParser p = NULL;
@@ -175,6 +175,16 @@ int main(void) {
{"9;4;1;50", GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT},
{"22;pointer", GHOSTTY_OSC_COMMAND_MOUSE_SHAPE},
{"66;s=2;hi", GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING},
// Color commands keep their request lists in the parser's storage
// (the caller's allocator), up to 4 requests without growing.
{"4;1;rgb:ff/00/00;2;?", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
{"5;0;rgb:00/00/00", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
{"10;rgb:ff/ff/ff;?", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
{"11;?", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
{"104;1;2;3;4", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
{"104", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
{"112", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
{"21;foreground=?;background=rgb:f0/f8/ff;cursor", GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL},
};
Counter c = counter(0, SIZE_MAX);
GhosttyAllocator a = {.ctx = &c, .vtable = &VT};
@@ -202,6 +212,35 @@ int main(void) {
assert(c.allocs == c.frees);
}
// 7. Longer color request lists grow through the caller's allocator (one
// more block here), and fail like Zig's allocation failure (invalid
// command) when the allocator refuses: the global allocator is never
// used as a fallback.
{
static const char *const LONG4 = "4;0;?;1;?;2;?;3;?;4;?;5;?";
static const char *const LONG21 = "21;1=?;2=?;3=?;4=?;5=?;6=?";
for (int k = 0; k < 2; k++) {
const char *osc = k ? LONG21 : LONG4;
int type = k ? GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL : GHOSTTY_OSC_COMMAND_COLOR_OPERATION;
Counter c = counter(0, SIZE_MAX);
GhosttyAllocator a = {.ctx = &c, .vtable = &VT};
GhosttyOscParser p;
assert(ghostty_osc_new(&a, &p) == GHOSTTY_SUCCESS);
size_t base = c.allocs;
feed(p, osc, strlen(osc));
assert(ghostty_osc_command_type(ghostty_osc_end(p, 0x07)) == type);
assert(c.allocs > base); // the list grew via the caller's allocator
ghostty_osc_reset(p);
c.fail_after = c.allocs; // refuse any further allocation
feed(p, osc, strlen(osc));
assert(ghostty_osc_command_type(ghostty_osc_end(p, 0x07)) == GHOSTTY_OSC_COMMAND_INVALID);
ghostty_osc_free(p);
assert(c.live == 0);
assert(c.allocs == c.frees);
}
}
puts("ok");
return 0;
}
+37
View File
@@ -0,0 +1,37 @@
//! Generates the sorted, lowercase X11 color table (`x11_table.rs`) from
//! `src/res/rgb.txt`, so name lookups need neither a lazily built map nor a
//! lowercased copy of the query (Zig builds the same table at comptime, so
//! looking up a color never allocates).
use std::{collections::HashSet, env, fmt::Write as _, fs, path::PathBuf};
fn main() {
println!("cargo:rerun-if-changed=src/res/rgb.txt");
println!("cargo:rerun-if-changed=build.rs");
let data = fs::read_to_string("src/res/rgb.txt").expect("read rgb.txt");
let mut seen = HashSet::new();
let mut entries: Vec<(String, u8, u8, u8)> = Vec::new();
for line in data.split('\n').filter(|l| !l.is_empty()) {
let comp = |r: std::ops::Range<usize>| -> u8 {
line[r].trim_matches(' ').parse().expect("bad rgb.txt")
};
let (r, g, b) = (comp(0..3), comp(4..7), comp(8..11));
let name = line[12..]
.trim_matches(|c| c == ' ' || c == '\t' || c == '\n')
.to_ascii_lowercase();
// The first spelling of a (case-insensitive) name wins.
if seen.insert(name.clone()) {
entries.push((name, r, g, b));
}
}
entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
let mut out = String::from("&[\n");
for (name, r, g, b) in &entries {
writeln!(out, " ({name:?}, {r}, {g}, {b}),").expect("write");
}
out.push_str("]\n");
let dest = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")).join("x11_table.rs");
fs::write(dest, out).expect("write x11_table.rs");
}
+158 -4
View File
@@ -1,21 +1,88 @@
//! Kitty color protocol (ZIG/src/terminal/kitty/color.zig).
use std::fmt;
use std::{borrow::Cow, fmt};
use crate::{color::Rgb, osc::Terminator};
/// OSC 21 payload.
///
/// The request list is stored encoded (fixed size records) in the OSC
/// parser's own storage, so parsing it never touches the global allocator
/// when the parser was given a caller-provided allocator (C API).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct OSC {
pub struct OSC<'a> {
/// list of requests
pub list: Vec<Request>,
pub list: List<'a>,
/// We must reply with the same string terminator (ST) as used in the
/// request.
pub terminator: Terminator,
}
#[derive(Debug, Clone, PartialEq, Eq)]
impl OSC<'_> {
/// Detach from the parser storage.
#[must_use]
pub fn into_owned(self) -> OSC<'static> {
OSC {
list: self.list.into_owned(),
terminator: self.terminator,
}
}
}
/// Size of one encoded [`Request`] record.
pub const RECORD_LEN: usize = 8;
/// A list of kitty color [`Request`]s: borrowed (or owned) encoded records.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct List<'a>(Cow<'a, [u8]>);
impl<'a> List<'a> {
/// View records encoded with [`Request::encode`] (`bytes.len()` must be a
/// multiple of [`RECORD_LEN`]).
#[must_use]
pub fn from_encoded(bytes: &'a [u8]) -> List<'a> {
debug_assert_eq!(bytes.len() % RECORD_LEN, 0);
List(Cow::Borrowed(bytes))
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len() / RECORD_LEN
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn get(&self, i: usize) -> Option<Request> {
Request::decode(self.0.get(i * RECORD_LEN..(i + 1) * RECORD_LEN)?)
}
/// Iterate the requests (by value).
pub fn iter(&self) -> impl Iterator<Item = Request> + '_ {
self.0
.as_chunks::<RECORD_LEN>()
.0
.iter()
.filter_map(|rec| Request::decode(rec))
}
/// Copy into a `Vec` (allocates; for tests and diagnostics).
#[must_use]
pub fn to_vec(&self) -> Vec<Request> {
self.iter().collect()
}
#[must_use]
pub fn into_owned(self) -> List<'static> {
List(Cow::Owned(self.0.into_owned()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Request {
Query(Kind),
Set { key: Kind, color: Rgb },
@@ -35,6 +102,26 @@ pub enum Special {
}
impl Special {
/// All specials in discriminant order.
const ALL: [Special; 8] = [
Special::Foreground,
Special::Background,
Special::SelectionForeground,
Special::SelectionBackground,
Special::Cursor,
Special::CursorText,
Special::VisualBell,
Special::SecondTransparentBackground,
];
fn to_u8(self) -> u8 {
Special::ALL.iter().position(|s| *s == self).unwrap_or(0) as u8
}
fn from_u8(v: u8) -> Option<Special> {
Special::ALL.get(usize::from(v)).copied()
}
/// The tag name (Zig `@tagName`).
pub fn name(self) -> &'static str {
match self {
@@ -92,6 +179,46 @@ impl Kind {
}
}
impl Request {
/// Encode as a fixed size record: `[tag, kind class, kind value, r, g, b,
/// 0, 0]`.
#[must_use]
pub fn encode(&self) -> [u8; RECORD_LEN] {
let (tag, key, color) = match *self {
Request::Query(k) => (0, k, Rgb::default()),
Request::Set { key, color } => (1, key, color),
Request::Reset(k) => (2, k, Rgb::default()),
};
let (class, value) = match key {
Kind::Palette(p) => (0, p),
Kind::Special(s) => (1, s.to_u8()),
};
[tag, class, value, color.r, color.g, color.b, 0, 0]
}
/// Inverse of [`Request::encode`].
#[must_use]
pub fn decode(rec: &[u8]) -> Option<Request> {
let [tag, class, value, r, g, b, ..] = *rec else {
return None;
};
let key = match class {
0 => Kind::Palette(value),
1 => Kind::Special(Special::from_u8(value)?),
_ => return None,
};
Some(match tag {
0 => Request::Query(key),
1 => Request::Set {
key,
color: Rgb { r, g, b },
},
2 => Request::Reset(key),
_ => return None,
})
}
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -106,6 +233,33 @@ mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn request_records_round_trip() {
let mut reqs = vec![];
for s in Special::ALL {
let k = Kind::Special(s);
reqs.push(Request::Query(k));
reqs.push(Request::Reset(k));
reqs.push(Request::Set {
key: k,
color: Rgb { r: 1, g: 2, b: 3 },
});
}
reqs.push(Request::Set {
key: Kind::Palette(255),
color: Rgb { r: 255, g: 0, b: 9 },
});
reqs.push(Request::Query(Kind::Palette(0)));
let bytes: Vec<u8> = reqs.iter().flat_map(Request::encode).collect();
let list = List::from_encoded(&bytes);
assert_eq!(list.len(), reqs.len());
assert_eq!(list.to_vec(), reqs);
assert_eq!(list.get(reqs.len()), None);
assert_eq!(list.into_owned().to_vec(), reqs);
assert_eq!(Request::decode(&[9, 0, 0, 0, 0, 0, 0, 0]), None);
assert_eq!(Request::decode(&[0, 1, 200, 0, 0, 0, 0, 0]), None);
}
#[test]
fn osc_kitty_color_protocol_kind_string() {
assert_eq!("foreground", Kind::Special(Special::Foreground).to_string());
+38 -9
View File
@@ -82,13 +82,13 @@ pub enum Command<'a> {
/// 4, 5, 10-19, 104, 105, 110-119
ColorOperation {
op: color::Operation,
requests: color::List,
requests: color::List<'a>,
terminator: Terminator,
},
/// Kitty color protocol, OSC 21
/// https://sw.kovidgoyal.net/kitty/color-stack/#id1
KittyColorProtocol(kitty_color::OSC),
KittyColorProtocol(kitty_color::OSC<'a>),
/// Show a desktop notification (OSC 9 or OSC 777)
ShowDesktopNotification {
@@ -228,10 +228,10 @@ impl Command<'_> {
terminator,
} => Command::ColorOperation {
op,
requests,
requests: requests.into_owned(),
terminator,
},
Command::KittyColorProtocol(v) => Command::KittyColorProtocol(v),
Command::KittyColorProtocol(v) => Command::KittyColorProtocol(v.into_owned()),
Command::ShowDesktopNotification { title, body } => Command::ShowDesktopNotification {
title: own(title),
body: own(body),
@@ -486,6 +486,10 @@ pub struct Parser<S: ByteStorage = Vec<u8>> {
/// Buffer for temporary storage of OSC data
buffer: S,
/// Auxiliary storage for the encoded request lists of the color OSCs
/// (Zig: a `SegmentedList` on the parser's allocator). Same allocator as
/// `buffer`.
aux: S,
writer: Writer,
}
@@ -501,6 +505,7 @@ impl Parser {
alloc,
state: State::Start,
buffer: Vec::new(),
aux: Vec::new(),
writer: Writer::None,
}
}
@@ -512,11 +517,16 @@ impl<S: ByteStorage> Parser<S> {
/// Like [`Parser::init`] with an allocator-backed payload buffer
/// (`alloc` is true: an allocator is available).
pub fn with_storage(storage: S) -> Parser<S> {
///
/// `aux` holds the request lists of the color OSCs; give it the same
/// allocator as `storage` (a capacity of a few [`color::RECORD_LEN`]
/// records avoids allocating for common color commands).
pub fn with_storage(storage: S, aux: S) -> Parser<S> {
Parser {
alloc: true,
state: State::Start,
buffer: storage,
aux,
writer: Writer::None,
}
}
@@ -525,6 +535,7 @@ impl<S: ByteStorage> Parser<S> {
pub fn reset(&mut self) {
self.state = State::Start;
self.buffer.reset();
self.aux.reset();
self.writer = Writer::None;
}
@@ -608,6 +619,18 @@ impl<S: ByteStorage> Parser<S> {
self.buffer.bytes()
}
/// The payload and the auxiliary request storage at once (disjoint
/// borrows, so a color parser can read the former while filling the
/// latter).
pub(crate) fn payload_and_aux(&mut self) -> (&[u8], &mut S) {
(self.buffer.bytes(), &mut self.aux)
}
/// The encoded records written to the auxiliary storage.
pub(crate) fn aux_bytes(&self) -> &[u8] {
self.aux.bytes()
}
/// Whether any payload writer has been set up (Zig `parser.writer != null`).
pub fn has_writer(&self) -> bool {
self.writer != Writer::None
@@ -977,10 +1000,16 @@ mod next_slice_tests {
#[test]
fn storage_failure_invalidates_osc() {
let mut p = Parser::with_storage(Limited {
data: Vec::new(),
cap: 8,
});
let mut p = Parser::with_storage(
Limited {
data: Vec::new(),
cap: 8,
},
Limited {
data: Vec::new(),
cap: 64,
},
);
for b in b"0;abcdefghijkl" {
p.next(*b);
}
+285 -54
View File
@@ -1,6 +1,8 @@
//! OSC color operations: 4, 5, 10-19, 104, 105, 110-119
//! (ZIG/src/terminal/osc/parsers/color.zig).
use std::borrow::Cow;
use crate::{
color::{Dynamic as DynamicColor, Rgb, Special as SpecialColor},
osc::{ByteStorage, Command, Parser, State, Terminator},
@@ -49,11 +51,7 @@ pub fn parse<S: ByteStorage>(
if parser.has_writer() {
parser.buffered()?;
}
let data: &[u8] = if parser.has_writer() {
parser.payload()
} else {
&[]
};
let has_writer = parser.has_writer();
// Check and make sure that we're parsing the correct OSCs
let op = match parser.state {
State::S4 => Operation::Osc4,
@@ -84,13 +82,60 @@ pub fn parse<S: ByteStorage>(
return None;
}
};
// The request list is built in the parser's own (allocator-backed)
// auxiliary storage, not on the global allocator.
let (payload, aux) = parser.payload_and_aux();
let data: &[u8] = if has_writer { payload } else { &[] };
aux.reset();
let mut sink = StorageSink { aux, failed: false };
parse_color_into(op, data, &mut sink);
if sink.failed {
// Allocation failure: the OSC cannot be represented.
log::warn!("unable to allocate the color request list");
parser.state = State::Invalid;
return None;
}
Some(Command::ColorOperation {
op,
requests: parse_color(op, data),
requests: List::from_encoded(parser.aux_bytes()),
terminator: Terminator::init(terminator_ch),
})
}
/// Where parsed requests go: a `Vec` (tests, [`parse_color`]) or the
/// parser's storage ([`StorageSink`]).
pub trait Sink {
fn push(&mut self, request: Request);
fn is_empty(&self) -> bool;
}
impl Sink for Vec<Request> {
fn push(&mut self, request: Request) {
Vec::push(self, request);
}
fn is_empty(&self) -> bool {
Vec::is_empty(self)
}
}
/// Appends encoded request records to a [`ByteStorage`]. An allocation
/// failure is remembered in `failed` (later pushes are dropped).
pub struct StorageSink<'s, S: ByteStorage> {
aux: &'s mut S,
failed: bool,
}
impl<S: ByteStorage> Sink for StorageSink<'_, S> {
fn push(&mut self, request: Request) {
if !self.failed && self.aux.try_extend(&request.encode()).is_err() {
self.failed = true;
}
}
fn is_empty(&self) -> bool {
self.aux.is_empty()
}
}
/// Zig `std.mem.tokenizeScalar(u8, buf, ';')`: skips empty tokens.
fn tokenize(buf: &[u8]) -> impl Iterator<Item = &[u8]> {
buf.split(|&c| c == b';').filter(|t| !t.is_empty())
@@ -132,33 +177,88 @@ fn parse_u9(s: &[u8]) -> Option<u16> {
/// request) but grants us an easier to understand and testable implementation.
///
/// If color changing ends up being a bottleneck we can optimize this later.
pub fn parse_color(op: Operation, buf: &[u8]) -> List {
pub fn parse_color(op: Operation, buf: &[u8]) -> Vec<Request> {
let mut out = Vec::new();
parse_color_into(op, buf, &mut out);
out
}
/// [`parse_color`] into any [`Sink`].
pub fn parse_color_into(op: Operation, buf: &[u8], out: &mut impl Sink) {
let mut it = tokenize(buf);
match op {
Operation::Osc4 => parse_get_set_ansi_color(Operation::Osc4, &mut it),
Operation::Osc5 => parse_get_set_ansi_color(Operation::Osc5, &mut it),
Operation::Osc104 => parse_reset_ansi_color(Operation::Osc104, &mut it),
Operation::Osc105 => parse_reset_ansi_color(Operation::Osc105, &mut it),
Operation::Osc10 => parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it),
Operation::Osc11 => parse_get_set_dynamic_color(DynamicColor::Background, &mut it),
Operation::Osc12 => parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it),
Operation::Osc13 => parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it),
Operation::Osc14 => parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it),
Operation::Osc15 => parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it),
Operation::Osc16 => parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it),
Operation::Osc17 => parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it),
Operation::Osc18 => parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it),
Operation::Osc19 => parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it),
Operation::Osc110 => parse_reset_dynamic_color(DynamicColor::Foreground, &mut it),
Operation::Osc111 => parse_reset_dynamic_color(DynamicColor::Background, &mut it),
Operation::Osc112 => parse_reset_dynamic_color(DynamicColor::Cursor, &mut it),
Operation::Osc113 => parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it),
Operation::Osc114 => parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it),
Operation::Osc115 => parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it),
Operation::Osc116 => parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it),
Operation::Osc117 => parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it),
Operation::Osc118 => parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it),
Operation::Osc119 => parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it),
Operation::Osc4 => {
parse_get_set_ansi_color(Operation::Osc4, &mut it, out);
}
Operation::Osc5 => {
parse_get_set_ansi_color(Operation::Osc5, &mut it, out);
}
Operation::Osc104 => {
parse_reset_ansi_color(Operation::Osc104, &mut it, out);
}
Operation::Osc105 => {
parse_reset_ansi_color(Operation::Osc105, &mut it, out);
}
Operation::Osc10 => {
parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it, out);
}
Operation::Osc11 => {
parse_get_set_dynamic_color(DynamicColor::Background, &mut it, out);
}
Operation::Osc12 => {
parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it, out);
}
Operation::Osc13 => {
parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it, out);
}
Operation::Osc14 => {
parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it, out);
}
Operation::Osc15 => {
parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it, out);
}
Operation::Osc16 => {
parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it, out);
}
Operation::Osc17 => {
parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it, out);
}
Operation::Osc18 => {
parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it, out);
}
Operation::Osc19 => {
parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it, out);
}
Operation::Osc110 => {
parse_reset_dynamic_color(DynamicColor::Foreground, &mut it, out);
}
Operation::Osc111 => {
parse_reset_dynamic_color(DynamicColor::Background, &mut it, out);
}
Operation::Osc112 => {
parse_reset_dynamic_color(DynamicColor::Cursor, &mut it, out);
}
Operation::Osc113 => {
parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it, out);
}
Operation::Osc114 => {
parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it, out);
}
Operation::Osc115 => {
parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it, out);
}
Operation::Osc116 => {
parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it, out);
}
Operation::Osc117 => {
parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it, out);
}
Operation::Osc118 => {
parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it, out);
}
Operation::Osc119 => {
parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it, out);
}
}
}
@@ -179,29 +279,32 @@ fn ansi_target(color: u16, special_only: bool) -> Option<Target> {
}
/// OSC 4/5
fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
fn parse_get_set_ansi_color<'a>(
op: Operation,
it: &mut impl Iterator<Item = &'a [u8]>,
result: &mut impl Sink,
) {
// Note: in ANY error scenario below we return the accumulated results.
// This matches the xterm behavior (see misc.c ChangeAnsiColorRequest)
let mut result = List::new();
loop {
// We expect a `c; spec` pair. If either doesn't exist then
// we return the results up to this point.
let Some(color_str) = it.next() else {
return result;
return;
};
let Some(spec_str) = it.next() else {
return result;
return;
};
// Color must be numeric. u9 because that'll fit our palette + special
let Some(color) = parse_u9(color_str) else {
return result;
return;
};
// Parse the color.
let Some(target) = ansi_target(color, op == Operation::Osc5) else {
return result;
return;
};
// "?" always results in a query.
@@ -211,20 +314,23 @@ fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a
}
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
return result;
return;
};
result.push(Request::Set(ColoredTarget { target, color: rgb }));
}
}
/// OSC 104/105: Reset ANSI Colors
fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
fn parse_reset_ansi_color<'a>(
op: Operation,
it: &mut impl Iterator<Item = &'a [u8]>,
result: &mut impl Sink,
) {
// Note: xterm stops parsing the reset list on any error, but we're
// more flexible and try the next value. This matches the behavior of
// Kitty and I don't see a downside to being more flexible here. Hopefully
// no one depends on the exact behavior of xterm.
let mut result = List::new();
loop {
let Some(color_str) = it.next() else {
// If no parameters are given, we reset the full table.
@@ -235,7 +341,7 @@ fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [
_ => unreachable!(),
});
}
return result;
return;
};
// Empty color strings are ignored, not treated as an error.
@@ -261,22 +367,22 @@ fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [
fn parse_get_set_dynamic_color<'a>(
start: DynamicColor,
it: &mut impl Iterator<Item = &'a [u8]>,
) -> List {
result: &mut impl Sink,
) {
// Note: in ANY error scenario below we return the accumulated results.
// This matches the xterm behavior (see misc.c ChangeColorsRequest)
let mut result = List::new();
let mut color = start;
loop {
let Some(spec_str) = it.next() else {
return result;
return;
};
if spec_str == b"?" {
result.push(Request::Query(Target::Dynamic(color)));
} else {
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
return result;
return;
};
result.push(Request::Set(ColoredTarget {
target: Target::Dynamic(color),
@@ -287,7 +393,7 @@ fn parse_get_set_dynamic_color<'a>(
// Each successive value uses the next color so long as it exists.
match color.next() {
Some(n) => color = n,
None => return result,
None => return,
}
}
}
@@ -296,18 +402,117 @@ fn parse_get_set_dynamic_color<'a>(
fn parse_reset_dynamic_color<'a>(
color: DynamicColor,
it: &mut impl Iterator<Item = &'a [u8]>,
) -> List {
let mut result = List::new();
result: &mut impl Sink,
) {
if it.next().is_some() {
return result;
return;
}
result.push(Request::Reset(Target::Dynamic(color)));
result
}
/// The list of requests in a color OSC. (Zig uses a SegmentedList to
/// avoid copying; a `Vec` is equivalent.)
pub type List = Vec<Request>;
/// Size of one encoded [`Request`] record.
pub const RECORD_LEN: usize = 8;
/// The list of requests in a color OSC. (Zig uses a SegmentedList on the
/// parser's allocator.) The requests are stored as fixed size encoded
/// records ([`Request::encode`]) in the parser's own storage, so with a
/// caller-provided allocator (C API) the list is allocated by that allocator
/// and borrowed by the command, like Zig's.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct List<'a>(Cow<'a, [u8]>);
impl<'a> List<'a> {
/// View records encoded with [`Request::encode`].
#[must_use]
pub fn from_encoded(bytes: &'a [u8]) -> List<'a> {
debug_assert_eq!(bytes.len() % RECORD_LEN, 0);
List(Cow::Borrowed(bytes))
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len() / RECORD_LEN
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn get(&self, i: usize) -> Option<Request> {
Request::decode(self.0.get(i * RECORD_LEN..(i + 1) * RECORD_LEN)?)
}
/// Iterate the requests (by value).
pub fn iter(&self) -> impl Iterator<Item = Request> + '_ {
self.0
.as_chunks::<RECORD_LEN>()
.0
.iter()
.filter_map(|rec| Request::decode(rec))
}
/// Copy into a `Vec` (allocates; for tests and diagnostics).
#[must_use]
pub fn to_vec(&self) -> Vec<Request> {
self.iter().collect()
}
/// Detach from the parser storage.
#[must_use]
pub fn into_owned(self) -> List<'static> {
List(Cow::Owned(self.0.into_owned()))
}
}
impl Request {
/// Encode as a fixed size record: `[tag, target class, target value, r,
/// g, b, 0, 0]`.
#[must_use]
pub fn encode(&self) -> [u8; RECORD_LEN] {
fn target(t: Target) -> (u8, u8) {
match t {
Target::Palette(p) => (0, p),
Target::Special(s) => (1, s as u8),
Target::Dynamic(d) => (2, d as u8),
}
}
let (tag, (class, value), c) = match *self {
Request::Set(ColoredTarget { target: t, color }) => (0, target(t), color),
Request::Query(t) => (1, target(t), Rgb::default()),
Request::Reset(t) => (2, target(t), Rgb::default()),
Request::ResetPalette => (3, (0, 0), Rgb::default()),
Request::ResetSpecial => (4, (0, 0), Rgb::default()),
};
[tag, class, value, c.r, c.g, c.b, 0, 0]
}
/// Inverse of [`Request::encode`].
#[must_use]
pub fn decode(rec: &[u8]) -> Option<Request> {
let [tag, class, value, r, g, b, ..] = *rec else {
return None;
};
let target = match class {
0 => Target::Palette(value),
1 => Target::Special(SpecialColor::from_u8(value)?),
2 => Target::Dynamic(DynamicColor::from_u8(value)?),
_ => return None,
};
Some(match tag {
0 => Request::Set(ColoredTarget {
target,
color: Rgb { r, g, b },
}),
1 => Request::Query(target),
2 => Request::Reset(target),
3 => Request::ResetPalette,
4 => Request::ResetSpecial,
_ => return None,
})
}
}
/// A single operation related to the terminal color palette.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -447,6 +652,32 @@ mod tests {
}
}
#[test]
fn request_records_round_trip() {
let mut reqs = vec![Request::ResetPalette, Request::ResetSpecial];
for t in [
Target::Palette(0),
Target::Palette(255),
Target::Special(SpecialColor::Italic),
Target::Dynamic(DynamicColor::HighlightForeground),
] {
reqs.push(Request::Query(t));
reqs.push(Request::Reset(t));
reqs.push(Request::Set(ColoredTarget {
target: t,
color: Rgb { r: 9, g: 8, b: 7 },
}));
}
let bytes: Vec<u8> = reqs.iter().flat_map(Request::encode).collect();
let list = List::from_encoded(&bytes);
assert_eq!(list.len(), reqs.len());
assert_eq!(list.to_vec(), reqs);
assert_eq!(list.iter().count(), reqs.len());
assert_eq!(list.into_owned().to_vec(), reqs);
assert_eq!(Request::decode(&[7, 0, 0, 0, 0, 0, 0, 0]), None);
assert_eq!(Request::decode(&[1, 2, 99, 0, 0, 0, 0, 0]), None);
}
#[test]
fn osc_4_multiple_requests() {
let list = parse_color(Operation::Osc4, b"0;red;1;blue");
@@ -566,7 +797,7 @@ mod tests {
assert_eq!(Operation::Osc112, op);
assert_eq!(
vec![Request::Reset(Target::Dynamic(DynamicColor::Cursor))],
requests
requests.to_vec()
);
}
}
@@ -2,7 +2,7 @@
use crate::{
color::Rgb,
kitty::color::{Kind, OSC, Request},
kitty::color::{Kind, List, OSC, Request},
osc::{ByteStorage, Command, Parser, State, Terminator},
};
@@ -23,16 +23,18 @@ pub fn parse<S: ByteStorage>(
}
parser.buffered()?;
// The parse only reads the payload; failure marks the parser invalid
// after the borrow ends.
let data = parser.payload();
// The parse reads the payload and appends encoded records to the
// parser's auxiliary storage (the caller's allocator in the C API); the
// command then borrows them.
let (data, aux) = parser.payload_and_aux();
aux.reset();
let mut exceeded = false;
let mut osc = OSC {
list: Vec::new(),
terminator: Terminator::init(terminator_ch),
let mut out = Pending {
failed: false,
count: 0,
};
for kv in data.split(|&c| c == b';') {
if osc.list.len() >= Kind::MAX * 2 {
if out.count >= Kind::MAX * 2 {
log::warn!("exceeded limit for number of keys in kitty color protocol, ignoring");
exceeded = true;
break;
@@ -54,11 +56,11 @@ pub fn parse<S: ByteStorage>(
};
let value = rest.trim_ascii_matching(b' ');
if value.is_empty() {
osc.list.push(Request::Reset(key));
out.push(aux, Request::Reset(key));
} else if value == b"?" {
osc.list.push(Request::Query(key));
out.push(aux, Request::Query(key));
} else if let Ok(color) = Rgb::parse_bytes(value) {
osc.list.push(Request::Set { key, color });
out.push(aux, Request::Set { key, color });
} else {
log::warn!(
"invalid color format in kitty color protocol: {}",
@@ -66,11 +68,31 @@ pub fn parse<S: ByteStorage>(
);
}
}
if exceeded {
if exceeded || out.failed {
parser.state = State::Invalid;
return None;
}
Some(Command::KittyColorProtocol(osc))
Some(Command::KittyColorProtocol(OSC {
list: List::from_encoded(parser.aux_bytes()),
terminator: Terminator::init(terminator_ch),
}))
}
/// Bookkeeping while appending records to the parser's storage.
struct Pending {
/// An allocation failed (the OSC cannot be represented).
failed: bool,
/// Number of requests so far (for the limit).
count: usize,
}
impl Pending {
fn push<S: ByteStorage>(&mut self, aux: &mut S, req: Request) {
if !self.failed && aux.try_extend(&req.encode()).is_err() {
self.failed = true;
}
self.count += 1;
}
}
trait TrimMatching {
@@ -123,7 +145,7 @@ mod tests {
let Command::KittyColorProtocol(osc) = cmd else {
panic!("wrong command");
};
let list = osc.list;
let list = osc.list.to_vec();
assert_eq!(9, list.len());
assert_eq!(Request::Query(Kind::Special(Special::Foreground)), list[0]);
assert_eq!(
+5 -3
View File
@@ -892,8 +892,10 @@ mod tests {
assert_eq!(op, osc::color::Operation::Osc112);
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0],
osc::color::Request::Reset(osc::color::Target::Dynamic(crate::color::Dynamic::Cursor))
requests.get(0),
Some(osc::color::Request::Reset(osc::color::Target::Dynamic(
crate::color::Dynamic::Cursor
)))
);
}
@@ -921,7 +923,7 @@ mod tests {
assert_eq!(terminator, osc::Terminator::Bel);
assert_eq!(op, osc::color::Operation::Osc104);
assert_eq!(requests.len(), 1);
assert_eq!(requests[0], osc::color::Request::ResetPalette);
assert_eq!(requests.get(0), Some(osc::color::Request::ResetPalette));
}
#[test]
+2 -2
View File
@@ -151,7 +151,7 @@ define_actions! {
MouseShape(MouseShape),
ConfigureCharset(ConfigureCharset),
SetAttribute(sgr::Attribute<'a>),
KittyColorReport(&'a kitty::color::OSC),
KittyColorReport(&'a kitty::color::OSC<'a>),
ColorOperation(ColorOperation<'a>),
SemanticPrompt(&'a osc::SemanticPrompt<'a>),
}
@@ -248,7 +248,7 @@ pub struct ConfigureCharset {
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ColorOperation<'a> {
pub op: osc::color::Operation,
pub requests: &'a osc::color::List,
pub requests: &'a osc::color::List<'a>,
pub terminator: osc::Terminator,
}
+17 -15
View File
@@ -1,6 +1,6 @@
//! X11 color names (ZIG/src/terminal/x11_color.zig).
use std::{collections::HashMap, sync::OnceLock};
use std::sync::OnceLock;
use crate::color::Rgb;
@@ -23,17 +23,15 @@ fn parse_entries() -> impl Iterator<Item = (&'static str, Rgb)> {
})
}
/// The map of all available X11 colors. Keys are lowercase ASCII; lookups
/// are ASCII case-insensitive (see [`get`]).
fn map() -> &'static HashMap<String, Rgb> {
static MAP: OnceLock<HashMap<String, Rgb>> = OnceLock::new();
MAP.get_or_init(|| {
let mut m = HashMap::new();
for (name, rgb) in parse_entries() {
m.entry(name.to_ascii_lowercase()).or_insert(rgb);
}
m
})
/// `(lowercase name, r, g, b)` sorted by name (generated by `build.rs` from
/// `res/rgb.txt`; the first spelling of a name wins).
static TABLE: &[(&str, u8, u8, u8)] = include!(concat!(env!("OUT_DIR"), "/x11_table.rs"));
/// ASCII case-insensitive comparison of `query` against an already lowercase
/// `key`, without allocating.
fn cmp_lowercase(query: &[u8], key: &[u8]) -> std::cmp::Ordering {
let q = query.iter().map(u8::to_ascii_lowercase);
q.cmp(key.iter().copied())
}
/// All X11 color names with their original spelling (e.g. `AliceBlue`,
@@ -49,14 +47,18 @@ pub fn names() -> &'static [(&'static str, Rgb)] {
})
}
/// Look up an X11 color by name (ASCII case-insensitive).
/// Look up an X11 color by name (ASCII case-insensitive). Never allocates.
pub fn get(name: &str) -> Option<Rgb> {
map().get(&name.to_ascii_lowercase()).copied()
get_bytes(name.as_bytes())
}
/// Byte-slice variant of [`get`].
pub fn get_bytes(name: &[u8]) -> Option<Rgb> {
get(std::str::from_utf8(name).ok()?)
let i = TABLE
.binary_search_by(|(key, ..)| cmp_lowercase(name, key.as_bytes()).reverse())
.ok()?;
let (_, r, g, b) = TABLE[i];
Some(Rgb { r, g, b })
}
#[cfg(test)]