fix: C action buffer lengths, byte-faithful callbacks, runtime finalize

Resolves the 8 gaps in docs/rewrite/notes/review-rv10-codex.md
(final-Z13.md):
- P1 memory safety: the C key-table action (and hovered-link/open-URL
  payloads) could advertise a length larger than the NUL-truncated
  allocation. A counted_bytes helper now passes the full bytes plus a
  trailing NUL with len == byte count, like Zig's backing slices; all
  C pointer+len structs were audited and tested to read exactly len.
- ghostty-bench args follow cli/args.zig (ordered duplicates, empty
  value resets, first bad value errors, parseBool spellings); the C
  benchmark tokenizer uses the ArgIteratorGeneral port.
- OSC 7 derived titles, desktop notifications and OSC 8 hover links
  forward raw bytes to the runtime; GTK converts only for display.
- C diagnostic getter exports original bytes; escaped surrogates report
  Utf8CannotEncodeSurrogateHalf; config float formatters print `nan`.
- Config::finalize_for(AppRuntime): GTK-only finalization (gtk-single-
  instance=detect) and termio's cgroup hooks apply only to the GTK
  runtime, so libghostty keeps `detect` like Zig.

Test Plan:
- cargo test --workspace --no-fail-fast (exit 0)
- cargo test -p ghostty-lib --features test-platform
- just check-wasm (macOS/Windows clippy re-run after the concurrent
  terminal changes land)
This commit is contained in:
ddidderr committed 2026-10-12 13:09:04 +02:00
1 parent 7b3fffded3
commit da9599140b
21 files changed
+479 -133

No files matched your search

+93 -32
View File
@@ -38,11 +38,13 @@ impl ZigArg for f32 {
}
}
/// Parsed `--key[=value]` arguments.
/// Parsed `--key[=value]` arguments, in command line order.
#[derive(Debug, Default)]
pub struct Args {
// Values are OS strings: file names (`--data=...`) need not be UTF-8.
map: HashMap<String, Option<OsString>>,
// Every occurrence is kept: like `cli/args.zig` each one is applied in
// order when the field is taken.
map: HashMap<String, Vec<Option<OsString>>>,
}
/// The action name of a `+action` argument, if `arg` is one.
@@ -60,7 +62,7 @@ impl Args {
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let mut map = HashMap::new();
let mut map: HashMap<String, Vec<Option<OsString>>> = HashMap::new();
for arg in iter {
let bytes = os_bytes(&arg.into());
if bytes.starts_with(b"+") {
@@ -78,57 +80,69 @@ impl Args {
};
let key = String::from_utf8(key.to_vec())
.map_err(|_| "option names must be valid UTF-8".to_owned())?;
map.insert(key, value);
map.entry(key).or_default().push(value);
}
Ok(Args { map })
}
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")),
Some(Some(v)) => Ok(Some(v)),
/// Apply every occurrence of `key` in order (Zig `parseIntoField`): an
/// empty value resets to the default, otherwise `set` parses the value
/// (`None` = no `=value`). The first error wins.
fn apply<T: Clone>(
&mut self,
key: &str,
default: T,
mut set: impl FnMut(Option<&OsStr>) -> Result<T, String>,
) -> Result<T, String> {
let mut cur = default.clone();
for v in self.map.remove(key).unwrap_or_default() {
cur = match v.as_deref() {
Some(v) if v.is_empty() => default.clone(),
v => set(v)?,
};
}
Ok(cur)
}
/// Take a typed value, falling back to `default`.
pub fn take<T: ZigArg>(&mut self, key: &str, default: T) -> Result<T, String> {
match self.take_os(key)? {
None => Ok(default),
Some(v) => T::parse_zig(&os_bytes(&v))
.ok_or_else(|| format!("invalid value for --{key}: {}", v.to_string_lossy())),
}
pub fn take<T: ZigArg + Clone>(&mut self, key: &str, default: T) -> Result<T, String> {
self.apply(key, default, |v| {
let v = v.ok_or_else(|| format!("option --{key} requires a value"))?;
T::parse_zig(&os_bytes(v))
.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())),
}
self.apply(key, None, |v| {
let v = v.ok_or_else(|| format!("option --{key} requires a value"))?;
v.to_str()
.map(|s| Some(s.to_owned()))
.ok_or_else(|| 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))
self.apply(key, None, |v| {
let v = v.ok_or_else(|| format!("option --{key} requires a value"))?;
Ok(Some(PathBuf::from(v)))
})
}
/// Take a boolean flag (`--key`, `--key=true`, `--key=false`).
/// Take a boolean flag (`--key`, `--key=true`, `--key=false`; `parseBool`).
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.to_str().unwrap_or_default() {
"true" | "t" | "1" | "yes" => Ok(true),
"false" | "f" | "0" | "no" => Ok(false),
self.apply(key, false, |v| match v.map(os_bytes) {
None => Ok(true),
Some(v) => match v.as_slice() {
b"1" | b"t" | b"T" | b"true" => Ok(true),
b"0" | b"f" | b"F" | b"false" => Ok(false),
_ => Err(format!(
"invalid value for --{key}: {}",
v.to_string_lossy()
String::from_utf8_lossy(&v)
)),
},
}
})
}
/// Error on any unconsumed option.
@@ -183,6 +197,53 @@ mod tests {
assert!(a.take("n", 0u16).is_err());
}
#[test]
fn duplicates_apply_in_order_and_first_error_wins() {
let a = |v: &[&str]| Args::parse(v.iter().map(OsString::from)).unwrap();
// A bad value earlier is an error even if a later one is fine.
assert!(
a(&["--terminal-rows=bad", "--terminal-rows=10"])
.take("terminal-rows", 80u16)
.is_err()
);
// The last good value wins.
assert_eq!(
a(&["--terminal-rows=5", "--terminal-rows=10"])
.take("terminal-rows", 80u16)
.unwrap(),
10
);
// An empty value resets to the default.
assert_eq!(
a(&["--terminal-rows=5", "--terminal-rows="])
.take("terminal-rows", 80u16)
.unwrap(),
80
);
assert_eq!(
a(&["--terminal-rows="])
.take("terminal-rows", 80u16)
.unwrap(),
80
);
// Missing value is still an error.
assert!(
a(&["--terminal-rows"])
.take("terminal-rows", 80u16)
.is_err()
);
// Optional path: empty resets to unset.
assert_eq!(
a(&["--data=/x", "--data="]).take_opt_path("data").unwrap(),
None
);
assert_eq!(a(&["--data="]).take_opt_path("data").unwrap(), None);
// Flags: empty resets, parseBool spellings only.
assert!(!a(&["--time", "--time="]).take_flag("time").unwrap());
assert!(a(&["--time=T"]).take_flag("time").unwrap());
assert!(a(&["--time=yes"]).take_flag("time").is_err());
}
#[test]
fn data_path_keeps_non_utf8_bytes() {
let arg = OsString::from_vec(b"--data=/tmp/a\xffb".to_vec());