fix: close remaining gaps found by the completeness audit

Addresses the partial ports listed in
docs/rewrite/notes/review-completeness-codex.md (details in
docs/rewrite/notes/gaps-X2.md):
- Flatpak: the exit watcher no longer consumes the host command; a
  shared FlatpakSignalHandle lets closing a surface still SIGHUP the
  host process group.
- Child environment is byte-preserving end to end (ghostty_os::EnvMap
  of OsStrings) and Snap paths are filtered like filterSnapPaths.
- argv handling no longer panics on non-UTF-8 arguments.
- URI percent-decoding keeps raw bytes; OSC 7 pwd is passed through
  without lossy conversion.
- Keybinding comparison/hashing uses full Unicode case folding from a
  table generated from CaseFolding.txt (Unicode 17.0.0).
- Link regexes set Oniguruma's whole-search retry limit through the new
  minimal ghostty-onig crate (one SAFETY-commented FFI call).
- OSC 9 NUL-capacity check ordering matches Zig.
- Wayland registry binds/clears globals advertised after startup.
- Custom shaders may pass sampler2D to helper functions (naga adapter).
- +list-themes preview renders OSC 8 links, styled underlines and
  follows color-scheme reports.

Also includes the inspector's integration edits in shared files
(renderer state hook, termio VT recording, core/apprt-gtk wiring), X1's
config/input/cli additions for completion generation, and a fix for a
race in the termio backpressure test (it wrote before `stty raw`
applied, so the cooked line discipline dropped bytes past MAX_CANON).

Test Plan:
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
This commit is contained in:
ddidderr committed 2026-10-12 04:39:24 +02:00
1 parent 90ad602969
commit f4c020101c
61 files changed
+4210 -298

No files matched your search

+22
View File
@@ -9,6 +9,17 @@ pub fn iterator() -> std::env::ArgsOs {
std::env::args_os()
}
/// Collect the process arguments as `String`s without ever panicking on
/// non-UTF-8 bytes (invalid sequences become U+FFFD). The CLI/config layers
/// are string based; `+action` dispatch only depends on the ASCII prefix.
pub fn collect_lossy() -> Vec<String> {
lossy(iterator())
}
fn lossy(it: impl Iterator<Item = std::ffi::OsString>) -> Vec<String> {
it.map(|a| a.to_string_lossy().into_owned()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -18,4 +29,15 @@ mod tests {
let mut iter = iterator();
assert!(!iter.next().unwrap().is_empty());
}
#[test]
fn lossy_does_not_panic_on_non_utf8() {
use std::os::unix::ffi::OsStringExt;
let args = vec![
std::ffi::OsString::from("ghostty"),
std::ffi::OsString::from_vec(vec![b'+', b'x', 0xff]),
];
let out = lossy(args.into_iter());
assert_eq!(out, vec!["ghostty".to_owned(), "+x\u{fffd}".to_owned()]);
}
}
+201 -11
View File
@@ -1,24 +1,153 @@
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use crate::flatpak::is_flatpak;
/// PATH-style delimiter (`std.fs.path.delimiter` on POSIX).
const DELIMITER: char = ':';
/// A byte-preserving environment map (Zig `std.process.EnvMap`): keys and
/// values are arbitrary OS strings, never required to be UTF-8.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EnvMap(HashMap<OsString, OsString>);
impl EnvMap {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Option<OsString> {
self.0
.insert(key.as_ref().to_owned(), value.as_ref().to_owned())
}
pub fn get(&self, key: impl AsRef<OsStr>) -> Option<&OsStr> {
self.0.get(key.as_ref()).map(OsString::as_os_str)
}
pub fn remove(&mut self, key: impl AsRef<OsStr>) -> Option<OsString> {
self.0.remove(key.as_ref())
}
pub fn contains_key(&self, key: impl AsRef<OsStr>) -> bool {
self.0.contains_key(key.as_ref())
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&OsStr, &OsStr)> {
self.0.iter().map(|(k, v)| (k.as_os_str(), v.as_os_str()))
}
}
impl<K: AsRef<OsStr>> std::ops::Index<K> for EnvMap {
type Output = OsStr;
fn index(&self, key: K) -> &OsStr {
self.get(key).expect("no entry found for key")
}
}
impl<K: Into<OsString>, V: Into<OsString>> FromIterator<(K, V)> for EnvMap {
fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
Self(
iter.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
)
}
}
impl IntoIterator for EnvMap {
type Item = (OsString, OsString);
type IntoIter = std::collections::hash_map::IntoIter<OsString, OsString>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
/// Get the environment map. Inside Flatpak this is empty (the sandbox
/// environment is not meaningful for host commands).
pub fn get_env_map() -> HashMap<String, String> {
/// environment is not meaningful for host commands). Bytes are preserved.
pub fn get_env_map() -> EnvMap {
if is_flatpak() {
return HashMap::new();
return EnvMap::new();
}
std::env::vars_os().collect()
}
/// Filter out Snap-specific environment variables and path components
/// (port of `Surface.filterSnapPaths`): remove any `SNAP_*` variable and
/// drop `:`-separated components that start with one of the snap
/// directories. Variables we set ourselves (`TERMINFO`, `GHOSTTY*`) are
/// left alone.
pub fn filter_snap_paths(env: &mut EnvMap) {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
const SNAP_VARS: [&str; 5] = [
"SNAP",
"SNAP_USER_COMMON",
"SNAP_USER_DATA",
"SNAP_DATA",
"SNAP_COMMON",
];
let mut to_remove: Vec<OsString> = Vec::new();
let mut to_update: Vec<(OsString, OsString)> = Vec::new();
for (key, value) in env.iter() {
let kb = key.as_bytes();
if kb == b"TERMINFO" || kb.starts_with(b"GHOSTTY") {
continue;
}
if kb.starts_with(b"SNAP_") {
to_remove.push(key.to_owned());
continue;
}
let mut filtered: Vec<&[u8]> = Vec::new();
let mut modified = false;
for path in value.as_bytes().split(|&b| b == b':') {
let mut include = true;
for k in SNAP_VARS {
if let Some(snap_path) = env.get(k) {
let snap_path = snap_path.as_bytes();
if snap_path.is_empty() {
continue;
}
if path.starts_with(snap_path) {
include = false;
modified = true;
break;
}
}
}
if include {
filtered.push(path);
}
}
if modified {
if filtered.is_empty() {
to_remove.push(key.to_owned());
} else {
to_update.push((key.to_owned(), OsString::from_vec(filtered.join(&b':'))));
}
}
}
for (k, v) in to_update {
env.insert(k, v);
}
for k in to_remove {
env.remove(k);
}
std::env::vars_os()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.to_string_lossy().into_owned(),
)
})
.collect()
}
/// Append a value to an environment variable such as PATH.
@@ -39,6 +168,28 @@ pub fn append_env_always(current: &str, value: &str) -> String {
format!("{current}{DELIMITER}{value}")
}
/// Byte-preserving [`prepend_env`].
pub fn prepend_env_os(current: &OsStr, value: &OsStr) -> OsString {
if current.is_empty() {
return value.to_owned();
}
let mut out = value.to_owned();
out.push(DELIMITER.to_string());
out.push(current);
out
}
/// Byte-preserving [`append_env`].
pub fn append_env_os(current: &OsStr, value: &OsStr) -> OsString {
if current.is_empty() {
return value.to_owned();
}
let mut out = current.to_owned();
out.push(DELIMITER.to_string());
out.push(value);
out
}
/// Prepend a value to an environment variable such as PATH.
pub fn prepend_env(current: &str, value: &str) -> String {
// If there is no prior value, we return it as-is
@@ -64,6 +215,35 @@ pub fn getenv_not_empty(key: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::ffi::OsStringExt;
#[test]
fn env_map_preserves_non_utf8() {
let mut env = EnvMap::new();
let v = OsString::from_vec(vec![b'a', 0xff, b'b']);
env.insert("K", &v);
assert_eq!(env.get("K"), Some(v.as_os_str()));
}
#[test]
fn filter_snap_paths_filters() {
let mut env = EnvMap::new();
env.insert("SNAP", "/snap/ghostty/1");
env.insert("SNAP_NAME", "ghostty");
env.insert("GHOSTTY_X", "/snap/ghostty/1/a");
env.insert("TERMINFO", "/snap/ghostty/1/ti");
env.insert("PATH", "/snap/ghostty/1/bin:/usr/bin:/snap/ghostty/1/x");
env.insert("ONLY", "/snap/ghostty/1/lib");
env.insert("KEEP", "/usr/lib");
filter_snap_paths(&mut env);
assert!(!env.contains_key("SNAP"));
assert!(!env.contains_key("SNAP_NAME"));
assert_eq!(env["GHOSTTY_X"], *"/snap/ghostty/1/a");
assert_eq!(env["TERMINFO"], *"/snap/ghostty/1/ti");
assert_eq!(env["PATH"], *"/usr/bin");
assert!(!env.contains_key("ONLY"));
assert_eq!(env["KEEP"], *"/usr/lib");
}
#[test]
fn append_env_empty() {
@@ -75,6 +255,16 @@ mod tests {
assert_eq!(append_env("a:b", "foo"), "a:b:foo");
}
#[test]
fn os_variants_preserve_bytes() {
let cur = OsString::from_vec(vec![0xff, b'a']);
let out = prepend_env_os(&cur, OsStr::new("x"));
assert_eq!(out.into_vec(), vec![b'x', b':', 0xff, b'a']);
let out = append_env_os(&cur, OsStr::new("x"));
assert_eq!(out.into_vec(), vec![0xff, b'a', b':', b'x']);
assert_eq!(append_env_os(OsStr::new(""), OsStr::new("x")), "x");
}
#[test]
fn prepend_env_empty() {
assert_eq!(prepend_env("", "foo"), "foo");
+63 -10
View File
@@ -84,7 +84,7 @@ pub struct FlatpakHostCommand {
/// Environment variables for the child process. If this is None, this
/// does not send any environment variables.
pub env: Option<HashMap<String, String>>,
pub env: Option<crate::env::EnvMap>,
/// File descriptors to send to the child process. It is up to the
/// caller to create the file descriptors and set them up. They are
@@ -113,14 +113,6 @@ impl FlatpakHostCommand {
}
}
fn state(&self) -> State {
*self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Spawn the command. This will start the host command. On return,
/// the pid will be available. This must only be called with the
/// state in "init".
@@ -128,7 +120,19 @@ impl FlatpakHostCommand {
let request = Request {
argv: self.argv.clone(),
cwd: self.cwd.clone(),
env: self.env.clone().unwrap_or_default(),
// The Development.HostCommand DBus method takes `a{ss}`, which
// can only carry UTF-8, so non-UTF-8 bytes are replaced here.
env: self
.env
.iter()
.flat_map(crate::env::EnvMap::iter)
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.to_string_lossy().into_owned(),
)
})
.collect(),
fds: [
self.stdin
.try_clone()
@@ -213,6 +217,38 @@ impl FlatpakHostCommand {
Ok(())
}
/// Send a signal to the started command. This does nothing if the
/// command is not in the started state.
pub fn signal(&self, sig: u8, pg: bool) -> Result<(), Error> {
self.signal_handle().signal(sig, pg)
}
/// A cloneable handle that can signal this command even after the
/// command itself has been moved into an exit watcher
/// (`wait_callback`). Zig's `FlatpakHostCommand` is a pointer shared
/// between the exit watcher and `Exec.stop`; this is the equivalent.
pub fn signal_handle(&self) -> FlatpakSignalHandle {
FlatpakSignalHandle {
shared: Arc::clone(&self.shared),
}
}
}
/// Handle used to signal a host command independent of its owner.
#[derive(Clone)]
pub struct FlatpakSignalHandle {
shared: Arc<Shared>,
}
impl FlatpakSignalHandle {
fn state(&self) -> State {
*self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Send a signal to the started command. This does nothing if the
/// command is not in the started state.
pub fn signal(&self, sig: u8, pg: bool) -> Result<(), Error> {
@@ -392,6 +428,23 @@ mod tests {
assert_eq!(cmd.signal(15, false), Ok(()));
}
#[test]
fn signal_handle_survives_wait_callback() {
let (r, w) = crate::pipe::pipe().unwrap();
let w2 = w.try_clone().unwrap();
let cmd = FlatpakHostCommand::new(vec!["true".into()], r, w, w2);
let handle = cmd.signal_handle();
let (tx, rx) = std::sync::mpsc::channel();
cmd.wait_callback(move |r| {
let _ = tx.send(r);
})
.unwrap();
// The watcher owns the command; the handle still works (no-op
// since never started) and the watcher reports the state error.
assert_eq!(handle.signal(1, true), Ok(()));
assert_eq!(rx.recv().unwrap(), Err(Error::FlatpakMustBeStarted));
}
#[test]
fn exit_status_extraction() {
// Mirrors posix.W.EXITSTATUS on a raw wait status.
+4 -1
View File
@@ -47,7 +47,10 @@ pub(crate) mod testutil;
// Functions and types (mirrors the flat exports of `os/main.zig`).
pub use dbus::launched_by_dbus_activation;
pub use desktop::{DesktopEnvironment, desktop_environment, launched_from_desktop};
pub use env::{append_env, append_env_always, get_env_map, getenv, getenv_not_empty, prepend_env};
pub use env::{
EnvMap, append_env, append_env_always, append_env_os, filter_snap_paths, get_env_map, getenv,
getenv_not_empty, prepend_env, prepend_env_os,
};
pub use file::{Rlimit, alloc_tmp_dir, fix_max_files, restore_max_files};
pub use flatpak::{FlatpakHostCommand, is_flatpak};
pub use homedir::{expand_home, home};
+102 -7
View File
@@ -2,6 +2,7 @@
//! additional [`ParseOptions`] from `os/uri.zig`.
use std::borrow::Cow;
use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, Default)]
pub struct ParseOptions {
@@ -47,15 +48,26 @@ impl Component<'_> {
}
}
/// The raw (percent-decoded) value (`toRaw`). Invalid escapes are left
/// as-is, matching `std.Uri.percentDecodeInPlace`.
pub fn to_raw(&self) -> Cow<'_, str> {
/// The raw (percent-decoded) value (`toRaw`) as bytes. Invalid escapes
/// are left as-is, matching `std.Uri.percentDecodeInPlace`. The decoded
/// bytes are NOT required to be UTF-8 (e.g. non-UTF-8 file names).
pub fn to_raw_bytes(&self) -> Cow<'_, [u8]> {
match self {
Component::Raw(s) => Cow::Borrowed(s.as_ref()),
Component::Raw(s) => Cow::Borrowed(s.as_bytes()),
Component::PercentEncoded(s) => percent_decode(s),
}
}
/// Lossy (U+FFFD) UTF-8 view of [`Self::to_raw_bytes`], for values such
/// as host names that are expected to be text. Use `to_raw_bytes` for
/// paths.
pub fn to_raw(&self) -> Cow<'_, str> {
match self.to_raw_bytes() {
Cow::Borrowed(b) => String::from_utf8_lossy(b),
Cow::Owned(b) => Cow::Owned(String::from_utf8_lossy(&b).into_owned()),
}
}
/// The percent-encoded value as it appears in the source, or the raw
/// text for `Raw` components.
pub fn as_str(&self) -> &str {
@@ -68,9 +80,9 @@ impl Component<'_> {
/// Lenient percent decoding like `std.Uri.percentDecodeBackwards`/InPlace:
/// valid `%XX` sequences are decoded, everything else passes through.
fn percent_decode(s: &str) -> Cow<'_, str> {
fn percent_decode(s: &str) -> Cow<'_, [u8]> {
if !s.contains('%') {
return Cow::Borrowed(s);
return Cow::Borrowed(s.as_bytes());
}
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
@@ -87,7 +99,7 @@ fn percent_decode(s: &str) -> Cow<'_, str> {
out.push(b[i]);
i += 1;
}
Cow::Owned(String::from_utf8_lossy(&out).into_owned())
Cow::Owned(out)
}
fn hexval(c: u8) -> Option<u8> {
@@ -318,6 +330,64 @@ pub fn parse(text: &str, options: ParseOptions) -> Result<Uri<'_>, ParseError> {
Ok(uri)
}
/// The pieces of a parsed URI that are needed by byte-oriented callers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedBytes {
pub scheme: String,
/// Percent-decoded host (lossy; host names are text).
pub host: Option<String>,
/// Percent-decoded path bytes (or the full raw tail with
/// [`ParseOptions::raw_path`]). Not required to be UTF-8.
pub path: Vec<u8>,
}
/// Parse a URI given as arbitrary bytes without losing any path bytes.
///
/// Bytes that are not valid UTF-8 are percent-escaped before parsing (so
/// they decode back to exactly the same bytes), mirroring Zig's byte-string
/// `std.Uri.parse`. With `raw_path` the original bytes are sliced directly.
pub fn parse_bytes(text: &[u8], options: ParseOptions) -> Result<ParsedBytes, ParseError> {
// Escape invalid bytes, remembering the offset mapping.
let mut escaped = String::with_capacity(text.len());
for chunk in text.utf8_chunks() {
escaped.push_str(chunk.valid());
for b in chunk.invalid() {
let _ = write!(escaped, "%{b:02X}");
}
}
let uri = parse(&escaped, options)?;
let path = if options.raw_path {
let esc_start = offset_in(&escaped, uri.path.as_str());
// Map the escaped offset back to the original byte offset. The path
// never begins inside an escape (it starts at a delimiter).
let mut orig_start = esc_start;
let mut esc_pos = 0usize;
let mut orig_pos = 0usize;
for chunk in text.utf8_chunks() {
let v = chunk.valid().len();
if esc_start <= esc_pos + v {
orig_start = orig_pos + (esc_start - esc_pos);
break;
}
esc_pos += v;
orig_pos += v;
let inv = chunk.invalid().len();
esc_pos += inv * 3;
orig_pos += inv;
orig_start = orig_pos;
}
text[orig_start.min(text.len())..].to_vec()
} else {
uri.path.to_raw_bytes().into_owned()
};
Ok(ParsedBytes {
scheme: uri.scheme.to_owned(),
host: uri.host.as_ref().map(|h| h.to_raw().into_owned()),
path,
})
}
/// Checks if a string represents a valid MAC address, e.g. 12:34:56:ab:cd:ef.
fn is_valid_mac_address(s: &str) -> bool {
let b = s.as_bytes();
@@ -407,6 +477,31 @@ mod tests {
);
}
#[test]
fn percent_decode_preserves_non_utf8_bytes() {
let u = parse("file://localhost/a%FFb%e2%82", ParseOptions::default()).unwrap();
assert_eq!(&*u.path.to_raw_bytes(), b"/a\xffb\xe2\x82");
// The lossy text view still works for text values.
assert_eq!(u.path.to_raw(), "/a\u{fffd}b\u{fffd}");
}
#[test]
fn parse_bytes_non_utf8() {
let p = parse_bytes(b"file://localhost/a\xffb%FEc", ParseOptions::default()).unwrap();
assert_eq!(p.scheme, "file");
assert_eq!(p.host.as_deref(), Some("localhost"));
assert_eq!(p.path, b"/a\xffb\xfec");
let raw = ParseOptions {
raw_path: true,
mac_address: true,
};
let p = parse_bytes(b"kitty-shell-cwd://localhost/a\xff%41?x", raw).unwrap();
assert_eq!(p.path, b"/a\xff%41?x");
let p = parse_bytes(b"kitty-shell-cwd://\xfflo/a", raw).ok();
assert!(p.is_none() || p.is_some());
}
#[test]
fn parse_raw_path() {
let text = "file://localhost/path??#fragment";