fix: preserve non-UTF-8 bytes in OS, config and CLI layers

Zig treats paths, arguments and environment values as byte slices; the
port converted them lossily in many places. Now (final-Z1.md):
- argv, env (incl. present non-UTF-8 values), HOME, PATH lookup,
  passwd, resources dir, TMPDIR, cgroup, config path canonicalization,
  +validate-config, +list-themes and Zig-string escapes (\xFF) keep raw
  bytes. String-based config values carry bytes via ghostty_os::osbytes,
  a bijective escape (noncharacter U+FDD0 + byte) that leaves all valid
  UTF-8 - including Nerd Font private-use icons - unchanged.
- os/file rlimit fallbacks exist on all targets.
- i18n locale canonicalization is an exact port of libintl's tables
  (e.g. pa-Guru -> pa_IN).
- New ghostty-build-info crate is the single source of version, channel,
  commit and build mode for all consumers.

Test Plan:
- cargo test -p ghostty-os -p ghostty-config -p ghostty-cli
  (incl. randomized osbytes round-trip property tests)
This commit is contained in:
ddidderr committed 2026-10-12 08:14:02 +02:00
1 parent 3f3f6680a5
commit 8519f1ba1b
32 files changed
+1464 -333

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
[package]
name = "ghostty-build-info"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Single source of truth for the derived build version, channel, commit and mode (Zig: build_config.zig)"
[lints]
workspace = true
+144
View File
@@ -0,0 +1,144 @@
//! Derives the build version metadata for [`ghostty_build_info`](crate):
//!
//! * the app version, release channel and commit (Zig: `build/Config.zig`
//! version detection + `build/GitVersion.zig`)
//! * the build mode (Zig: `builtin.mode`)
//!
//! The version can be overridden with `GHOSTTY_VERSION_STRING` (Zig:
//! `-Dversion-string`), which must be a semantic version.
use std::{
path::{Path, PathBuf},
process::Command,
};
fn run(cmd: &str, args: &[&str], dir: &Path) -> Option<String> {
let out = Command::new(cmd)
.args(args)
.current_dir(dir)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim_end().to_string())
}
/// The `app-version` from `[workspace.metadata.ghostty]` of the workspace
/// manifest (two levels above this crate).
fn app_version(manifest_dir: &Path) -> (u64, u64, u64) {
let root = manifest_dir.join("../../Cargo.toml");
println!("cargo:rerun-if-changed={}", root.display());
let text = std::fs::read_to_string(root).unwrap_or_default();
let v = text
.lines()
.find_map(|l| l.trim().strip_prefix("app-version"))
.and_then(|l| l.split('"').nth(1))
.unwrap_or("1.3.1");
let mut it = v.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
(
it.next().unwrap_or(1),
it.next().unwrap_or(3),
it.next().unwrap_or(1),
)
}
/// Returns `(version_string, commit)` mirroring `Config.zig`.
fn detect_version(manifest_dir: &Path) -> (String, Option<String>) {
println!("cargo:rerun-if-env-changed=GHOSTTY_VERSION_STRING");
if let Ok(v) = std::env::var("GHOSTTY_VERSION_STRING") {
let commit = v.split_once('+').map(|(_, b)| b.to_string());
return (v, commit);
}
let (maj, min, pat) = app_version(manifest_dir);
let base = format!("{maj}.{min}.{pat}");
// Git unavailable or not a repository: unknown dev version.
let Some(branch) = run("git", &["rev-parse", "--abbrev-ref", "HEAD"], manifest_dir) else {
return (format!("{base}-dev+0000000"), Some("0000000".into()));
};
if let Some(git_dir) = run("git", &["rev-parse", "--absolute-git-dir"], manifest_dir) {
let git_dir = PathBuf::from(git_dir);
println!("cargo:rerun-if-changed={}", git_dir.join("HEAD").display());
println!("cargo:rerun-if-changed={}", git_dir.join("refs").display());
println!(
"cargo:rerun-if-changed={}",
git_dir.join("packed-refs").display()
);
}
let hash = run(
"git",
&[
"-c",
"log.showSignature=false",
"log",
"--pretty=format:%h",
"-n",
"1",
],
manifest_dir,
)
.unwrap_or_default();
let tag = run(
"git",
&["describe", "--exact-match", "--tags"],
manifest_dir,
)
.filter(|t| !t.is_empty());
if let Some(tag) = tag {
// Tip releases behave just like any other pre-release so we skip.
if tag != "tip" {
assert!(
tag == format!("v{base}"),
"tagged releases must be in vX.Y.Z format matching the workspace app-version"
);
return (base, None);
}
}
// Slashes in branch names are replaced like in `GitVersion.zig`.
let branch = branch.replace('/', "-");
(format!("{base}-{branch}+{hash}"), Some(hash))
}
/// The Zig optimize-mode name for the Cargo profile being built.
fn build_mode() -> &'static str {
// The profile name is the directory below `target/` that holds OUT_DIR.
let out = std::env::var("OUT_DIR").unwrap_or_default();
let profile = Path::new(&out)
.ancestors()
.skip_while(|p| p.file_name().is_none_or(|n| n != "build"))
.nth(1)
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let debug_assertions = std::env::var_os("CARGO_CFG_DEBUG_ASSERTIONS").is_some();
let opt = std::env::var("OPT_LEVEL").unwrap_or_default();
if profile == "production" || !debug_assertions {
".ReleaseFast"
} else if opt == "0" {
".Debug"
} else {
".ReleaseSafe"
}
}
fn main() {
println!("cargo:rerun-if-changed=build.rs");
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default());
let (version_string, commit) = detect_version(&manifest_dir);
// Release channel: stable unless the version has a pre-release part.
let channel = if version_string.contains('-') {
"tip"
} else {
"stable"
};
println!("cargo:rustc-env=GHOSTTY_VERSION_STRING={version_string}");
println!(
"cargo:rustc-env=GHOSTTY_COMMIT={}",
commit.unwrap_or_default()
);
println!("cargo:rustc-env=GHOSTTY_RELEASE_CHANNEL={channel}");
println!("cargo:rustc-env=GHOSTTY_BUILD_MODE={}", build_mode());
}
+106
View File
@@ -0,0 +1,106 @@
//! Single source of truth for the build version metadata (Zig:
//! `build_config.zig` `version`, `version_string`, `release_channel`, plus
//! `builtin.mode`).
//!
//! Everything is derived at compile time by this crate's `build.rs` (git
//! describe / workspace `app-version`, overridable with the
//! `GHOSTTY_VERSION_STRING` environment variable). Consumers (XTVERSION,
//! `TERM_PROGRAM_VERSION`, the About dialog, crash metadata, `+version`) must
//! use these constants instead of hardcoding a version.
/// `build_config.version_string`, e.g. `1.3.1` for tagged releases or
/// `1.3.1-main+abc1234` for development builds.
pub const VERSION_STRING: &str = env!("GHOSTTY_VERSION_STRING");
/// The git commit hash (`build_config.version.build`), if the version has
/// one (tagged releases do not).
pub const COMMIT: Option<&str> = {
let c = env!("GHOSTTY_COMMIT");
if c.is_empty() { None } else { Some(c) }
};
/// `build_config.release_channel` tag name: `stable` or `tip`.
pub const RELEASE_CHANNEL: &str = env!("GHOSTTY_RELEASE_CHANNEL");
/// `{}` of `builtin.mode` (`.Debug`, `.ReleaseSafe`, `.ReleaseFast`).
pub const BUILD_MODE: &str = env!("GHOSTTY_BUILD_MODE");
/// A semantic version (Zig: `std.SemanticVersion`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SemanticVersion<'a> {
pub major: u64,
pub minor: u64,
pub patch: u64,
/// Pre-release part (after `-`), e.g. `main`.
pub pre: Option<&'a str>,
/// Build metadata (after `+`), e.g. the commit hash.
pub build: Option<&'a str>,
}
/// Parse `X.Y.Z[-pre][+build]`.
pub fn parse(v: &str) -> Option<SemanticVersion<'_>> {
let (rest, build) = match v.split_once('+') {
Some((a, b)) => (a, Some(b)),
None => (v, None),
};
let (core, pre) = match rest.split_once('-') {
Some((a, b)) => (a, Some(b)),
None => (rest, None),
};
let mut it = core.split('.');
let major = it.next()?.parse().ok()?;
let minor = it.next()?.parse().ok()?;
let patch = it.next()?.parse().ok()?;
if it.next().is_some() {
return None;
}
Some(SemanticVersion {
major,
minor,
patch,
pre,
build,
})
}
/// `build_config.version` parsed from [`VERSION_STRING`].
pub fn version() -> Option<SemanticVersion<'static>> {
parse(VERSION_STRING)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn version_string_is_semver() {
let v = version().expect("derived version must be semantic");
assert!(v.major >= 1);
assert_eq!(v.build, COMMIT.filter(|_| v.build.is_some()));
}
#[test]
fn channel_matches_prerelease() {
let v = version().unwrap();
assert_eq!(RELEASE_CHANNEL == "tip", v.pre.is_some());
}
#[test]
fn parse_cases() {
assert_eq!(
parse("1.3.1"),
Some(SemanticVersion {
major: 1,
minor: 3,
patch: 1,
pre: None,
build: None
})
);
let v = parse("1.4.0-main+abc1234").unwrap();
assert_eq!((v.pre, v.build), (Some("main"), Some("abc1234")));
assert_eq!(parse("1.3"), None);
assert_eq!(parse("1.3.1.2"), None);
}
}
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
[dependencies] [dependencies]
flate2 = "1.1.10" flate2 = "1.1.10"
ghostty-build-info.workspace = true
ghostty-apprt.workspace = true ghostty-apprt.workspace = true
ghostty-config.workspace = true ghostty-config.workspace = true
ghostty-crash.workspace = true ghostty-crash.workspace = true
+2 -133
View File
@@ -1,130 +1,15 @@
//! Build metadata for `+version` and the generated docs. //! Build metadata for `+version` and the generated docs.
//! //!
//! * the compiler version (Zig: `builtin.zig_version_string`) //! * the compiler version (Zig: `builtin.zig_version_string`)
//! * the app version, release channel and commit (Zig: `build/Config.zig`
//! version detection + `build/GitVersion.zig`)
//! * the build mode (Zig: `builtin.mode`)
//! //!
//! The version can be overridden with `GHOSTTY_VERSION_STRING` (Zig: //! The app version, channel, commit and build mode live in
//! `-Dversion-string`), which must be a semantic version. //! `ghostty-build-info` (single source of truth).
use std::{ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::Command,
}; };
fn run(cmd: &str, args: &[&str], dir: &Path) -> Option<String> {
let out = Command::new(cmd)
.args(args)
.current_dir(dir)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim_end().to_string())
}
/// The `app-version` from `[workspace.metadata.ghostty]` of the workspace
/// manifest (two levels above this crate).
fn app_version(manifest_dir: &Path) -> (u64, u64, u64) {
let root = manifest_dir.join("../../Cargo.toml");
println!("cargo:rerun-if-changed={}", root.display());
let text = std::fs::read_to_string(root).unwrap_or_default();
let v = text
.lines()
.find_map(|l| l.trim().strip_prefix("app-version"))
.and_then(|l| l.split('"').nth(1))
.unwrap_or("1.3.1");
let mut it = v.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
(
it.next().unwrap_or(1),
it.next().unwrap_or(3),
it.next().unwrap_or(1),
)
}
/// Returns `(version_string, commit)` mirroring `Config.zig`.
fn detect_version(manifest_dir: &Path) -> (String, Option<String>) {
println!("cargo:rerun-if-env-changed=GHOSTTY_VERSION_STRING");
if let Ok(v) = std::env::var("GHOSTTY_VERSION_STRING") {
let commit = v.split_once('+').map(|(_, b)| b.to_string());
return (v, commit);
}
let (maj, min, pat) = app_version(manifest_dir);
let base = format!("{maj}.{min}.{pat}");
// Git unavailable or not a repository: unknown dev version.
let Some(branch) = run("git", &["rev-parse", "--abbrev-ref", "HEAD"], manifest_dir) else {
return (format!("{base}-dev+0000000"), Some("0000000".into()));
};
if let Some(git_dir) = run("git", &["rev-parse", "--absolute-git-dir"], manifest_dir) {
let git_dir = PathBuf::from(git_dir);
println!("cargo:rerun-if-changed={}", git_dir.join("HEAD").display());
println!("cargo:rerun-if-changed={}", git_dir.join("refs").display());
println!(
"cargo:rerun-if-changed={}",
git_dir.join("packed-refs").display()
);
}
let hash = run(
"git",
&[
"-c",
"log.showSignature=false",
"log",
"--pretty=format:%h",
"-n",
"1",
],
manifest_dir,
)
.unwrap_or_default();
let tag = run(
"git",
&["describe", "--exact-match", "--tags"],
manifest_dir,
)
.filter(|t| !t.is_empty());
if let Some(tag) = tag {
// Tip releases behave just like any other pre-release so we skip.
if tag != "tip" {
assert!(
tag == format!("v{base}"),
"tagged releases must be in vX.Y.Z format matching the workspace app-version"
);
return (base, None);
}
}
// Slashes in branch names are replaced like in `GitVersion.zig`.
let branch = branch.replace('/', "-");
(format!("{base}-{branch}+{hash}"), Some(hash))
}
/// The Zig optimize-mode name for the Cargo profile being built.
fn build_mode() -> &'static str {
// The profile name is the directory below `target/` that holds OUT_DIR.
let out = std::env::var("OUT_DIR").unwrap_or_default();
let profile = Path::new(&out)
.ancestors()
.skip_while(|p| p.file_name().is_none_or(|n| n != "build"))
.nth(1)
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let debug_assertions = std::env::var_os("CARGO_CFG_DEBUG_ASSERTIONS").is_some();
let opt = std::env::var("OPT_LEVEL").unwrap_or_default();
if profile == "production" || !debug_assertions {
".ReleaseFast"
} else if opt == "0" {
".Debug"
} else {
".ReleaseSafe"
}
}
/// Generates the per-action help table (Zig: `helpgen.zig` `genActions`): /// Generates the per-action help table (Zig: `helpgen.zig` `genActions`):
/// the doc comment of each action's `run` function becomes /// the doc comment of each action's `run` function becomes
/// `Action::help_text`. /// `Action::help_text`.
@@ -175,20 +60,4 @@ fn main() {
.and_then(|s| s.split_whitespace().nth(1).map(str::to_owned)) .and_then(|s| s.split_whitespace().nth(1).map(str::to_owned))
.unwrap_or_else(|| "unknown".into()); .unwrap_or_else(|| "unknown".into());
println!("cargo:rustc-env=GHOSTTY_RUSTC_VERSION={version}"); println!("cargo:rustc-env=GHOSTTY_RUSTC_VERSION={version}");
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default());
let (version_string, commit) = detect_version(&manifest_dir);
// Release channel: stable unless the version has a pre-release part.
let channel = if version_string.contains('-') {
"tip"
} else {
"stable"
};
println!("cargo:rustc-env=GHOSTTY_VERSION_STRING={version_string}");
println!(
"cargo:rustc-env=GHOSTTY_COMMIT={}",
commit.unwrap_or_default()
);
println!("cargo:rustc-env=GHOSTTY_RELEASE_CHANNEL={channel}");
println!("cargo:rustc-env=GHOSTTY_BUILD_MODE={}", build_mode());
} }
+1 -1
View File
@@ -53,7 +53,7 @@ pub trait ActionEnum: Sized + Copy {
/// Detect the action from the process CLI args (Zig: `detectArgs`). /// Detect the action from the process CLI args (Zig: `detectArgs`).
pub fn detect_args<E: ActionEnum>() -> Result<Option<E>, DetectError> { pub fn detect_args<E: ActionEnum>() -> Result<Option<E>, DetectError> {
detect_iter(ghostty_os::args::collect_lossy()) detect_iter(ghostty_os::args::collect_config_strings())
} }
/// Detect the action from any iterator. Each iterator value should yield /// Detect the action from any iterator. Each iterator value should yield
+9 -5
View File
@@ -6,6 +6,10 @@
use std::sync::OnceLock; use std::sync::OnceLock;
/// Re-export of the derived version constants (`VERSION_STRING`, `COMMIT`,
/// `RELEASE_CHANNEL`, `BUILD_MODE`) from `ghostty-build-info`.
pub use ghostty_build_info as derived;
/// A `major.minor.micro` version (Zig: `gtk_version.zig` / `adw_version.zig`). /// A `major.minor.micro` version (Zig: `gtk_version.zig` / `adw_version.zig`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Version { pub struct Version {
@@ -49,11 +53,11 @@ pub struct BuildInfo {
impl Default for BuildInfo { impl Default for BuildInfo {
fn default() -> Self { fn default() -> Self {
BuildInfo { BuildInfo {
// Derived by `build.rs` (git describe / workspace app-version). // Derived by `ghostty-build-info` (single source of truth).
version_string: env!("GHOSTTY_VERSION_STRING").to_string(), version_string: ghostty_build_info::VERSION_STRING.to_string(),
commit: Some(env!("GHOSTTY_COMMIT").to_string()).filter(|c| !c.is_empty()), commit: ghostty_build_info::COMMIT.map(str::to_string),
release_channel: env!("GHOSTTY_RELEASE_CHANNEL").to_string(), release_channel: ghostty_build_info::RELEASE_CHANNEL.to_string(),
build_mode: env!("GHOSTTY_BUILD_MODE").to_string(), build_mode: ghostty_build_info::BUILD_MODE.to_string(),
app_runtime: ".gtk".to_string(), app_runtime: ".gtk".to_string(),
font_backend: ".fontconfig_freetype".to_string(), font_backend: ".fontconfig_freetype".to_string(),
renderer: "renderer.generic.Renderer(renderer.OpenGL)".to_string(), renderer: "renderer.generic.Renderer(renderer.OpenGL)".to_string(),
+12 -10
View File
@@ -93,8 +93,7 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
let Some(editor) = find_editor() else { let Some(editor) = find_editor() else {
// If we don't have `$EDITOR` set then we can't do anything // If we don't have `$EDITOR` set then we can't do anything
// but we can still print a helpful message. // but we can still print a helpful message.
write!( let msg = String::from(
stderr,
"The $EDITOR or $VISUAL environment variable is not set or is empty.\n\ "The $EDITOR or $VISUAL environment variable is not set or is empty.\n\
This environment variable is required to edit the Ghostty configuration\n\ This environment variable is required to edit the Ghostty configuration\n\
via this CLI command.\n\ via this CLI command.\n\
@@ -104,17 +103,20 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
\n\ \n\
If you prefer to edit the configuration file another way,\n\ If you prefer to edit the configuration file another way,\n\
you can find the configuration file at the following path:\n\ you can find the configuration file at the following path:\n\
\n" \n",
)?; );
// Output the path using the OSC8 sequence so that it is linked. // Output the path using the OSC8 sequence so that it is linked.
writeln!(stderr, "\x1b]8;;file://{path}\x1b\\{path}\x1b]8;;\x1b\\")?; // (Paths are raw bytes: decode the reversible escape when writing.)
let msg = format!("{msg}\x1b]8;;file://{path}\x1b\\{path}\x1b]8;;\x1b\\\n");
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&msg))?;
return Ok(1); return Ok(1);
}; };
// Flush before exec so no output is lost. // Flush before exec so no output is lost.
let _ = stderr.flush(); let _ = stderr.flush();
let err = std::process::Command::new(&editor).arg(path).exec(); let err = std::process::Command::new(ghostty_os::osbytes::config_str_to_os(&editor))
.arg(ghostty_os::osbytes::config_str_to_os(path))
.exec();
// If we reached this point then exec failed. // If we reached this point then exec failed.
let err = match err.kind() { let err = match err.kind() {
@@ -122,8 +124,7 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
std::io::ErrorKind::PermissionDenied => "error.AccessDenied".to_string(), std::io::ErrorKind::PermissionDenied => "error.AccessDenied".to_string(),
_ => err.to_string(), _ => err.to_string(),
}; };
write!( let msg = format!(
stderr,
"Failed to execute the editor. Error code={err}.\n\ "Failed to execute the editor. Error code={err}.\n\
\n\ \n\
This is usually due to the executable path not existing, invalid\n\ This is usually due to the executable path not existing, invalid\n\
@@ -132,6 +133,7 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
\n\ \n\
Editor: {editor}\n\ Editor: {editor}\n\
Path: {path}\n" Path: {path}\n"
)?; );
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&msg))?;
Ok(1) Ok(1)
} }
+39
View File
@@ -243,6 +243,28 @@ pub fn detect(args: &[String]) -> Result<Option<Action>, DetectError> {
detect_iter::<Action, _, _>(args.iter()) detect_iter::<Action, _, _>(args.iter())
} }
/// Convert raw OS arguments (Zig `[]const u8` argv) into the argument strings
/// used by the CLI layers. No bytes are lost: invalid UTF-8 bytes use the
/// reversible escape encoding of `ghostty_os::osbytes`, which the config
/// layer (`cli_args`) decodes back to the original bytes.
#[must_use]
pub fn os_args_to_strings(args: &[std::ffi::OsString]) -> Vec<String> {
args.iter()
.map(|a| ghostty_os::osbytes::os_to_config_string(a))
.collect()
}
/// [`detect`] over raw OS arguments.
pub fn detect_os(args: &[std::ffi::OsString]) -> Result<Option<Action>, DetectError> {
detect(&os_args_to_strings(args))
}
/// [`run_from_args`] over raw OS arguments (byte-preserving).
#[must_use]
pub fn run_from_os_args(args: &[std::ffi::OsString]) -> Option<u8> {
run_from_args(&os_args_to_strings(args))
}
/// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the /// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the
/// action from `args` (the full argv), print the errors Ghostty prints for /// action from `args` (the full argv), print the errors Ghostty prints for
/// invalid invocations and run the action. /// invalid invocations and run the action.
@@ -338,6 +360,23 @@ mod tests {
); );
} }
#[cfg(unix)]
#[test]
fn os_args_keep_non_utf8_bytes_and_still_dispatch() {
use std::os::unix::ffi::OsStringExt;
let args = vec![
std::ffi::OsString::from("ghostty"),
std::ffi::OsString::from_vec(b"--title=\xff".to_vec()),
std::ffi::OsString::from("+version"),
];
assert_eq!(Some(Action::Version), detect_os(&args).unwrap());
let strings = os_args_to_strings(&args);
assert_eq!(
ghostty_os::osbytes::config_str_to_bytes(&strings[1]),
b"--title=\xff"
);
}
#[test] #[test]
fn names_round_trip() { fn names_round_trip() {
for a in Action::ALL { for a in Action::ALL {
+43 -12
View File
@@ -15,6 +15,12 @@ use ghostty_config::{
config_struct, config_struct,
theme::{Location, LocationIterator}, theme::{Location, LocationIterator},
}; };
use ghostty_os::osbytes::{
config_str_to_bytes,
config_str_to_path,
os_to_config_string,
path_to_config_string,
};
use crate::{error::Error, tui}; use crate::{error::Error, tui};
@@ -121,7 +127,7 @@ pub fn collect_themes(resources_dir: Option<&str>) -> Vec<ThemeListElement> {
let mut themes = vec![]; let mut themes = vec![];
let mut it = LocationIterator::new(resources_dir); let mut it = LocationIterator::new(resources_dir);
while let Some((location, dir)) = it.next_location() { while let Some((location, dir)) = it.next_location() {
let rd = match std::fs::read_dir(&dir) { let rd = match std::fs::read_dir(config_str_to_path(&dir)) {
Ok(rd) => rd, Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => { Err(e) => {
@@ -137,15 +143,14 @@ pub fn collect_themes(resources_dir: Option<&str>) -> Vec<ThemeListElement> {
if !(ft.is_file() || ft.is_symlink()) { if !(ft.is_file() || ft.is_symlink()) {
continue; continue;
} }
let name = entry.file_name().to_string_lossy().into_owned(); // Entry names are raw bytes: keep them via the reversible escape.
let raw_name = entry.file_name();
let name = os_to_config_string(&raw_name);
if name == ".DS_Store" { if name == ".DS_Store" {
continue; continue;
} }
themes.push(ThemeListElement { themes.push(ThemeListElement {
path: std::path::Path::new(&dir) path: path_to_config_string(&config_str_to_path(&dir).join(&raw_name)),
.join(&name)
.to_string_lossy()
.into_owned(),
location, location,
theme: name, theme: name,
rank: None, rank: None,
@@ -170,8 +175,8 @@ pub fn should_include_theme(theme_filter: ColorScheme, theme_config: &Config) ->
/// Make `path` absolute (`Config::load_file` requires absolute paths). /// Make `path` absolute (`Config::load_file` requires absolute paths).
pub(crate) fn absolute_path(path: &str) -> String { pub(crate) fn absolute_path(path: &str) -> String {
std::path::absolute(path) std::path::absolute(config_str_to_path(path))
.map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()) .map_or_else(|_| path.to_string(), |p| path_to_config_string(&p))
} }
fn resources_dir() -> Option<String> { fn resources_dir() -> Option<String> {
@@ -273,11 +278,14 @@ pub fn write_plain(
continue; continue;
} }
let loc = location_name(theme.location); let loc = location_name(theme.location);
if opts.path { // Names/paths are raw bytes in Zig: decode the reversible escape so
writeln!(out, "{} ({loc}) {}", theme.theme, theme.path)?; // non-UTF-8 bytes are written back unchanged.
let line = if opts.path {
format!("{} ({loc}) {}\n", theme.theme, theme.path)
} else { } else {
writeln!(out, "{} ({loc})", theme.theme)?; format!("{} ({loc})\n", theme.theme)
} };
out.write_all(&config_str_to_bytes(&line))?;
} }
Ok(()) Ok(())
} }
@@ -297,6 +305,29 @@ mod tests {
tmp tmp
} }
#[cfg(unix)]
#[test]
fn collect_themes_keeps_non_utf8_names() {
use std::os::unix::ffi::OsStrExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("themes");
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 themes: Vec<_> = collect_themes(Some(&res))
.into_iter()
.filter(|t| t.location == Location::Resources)
.collect();
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));
let mut out = Vec::new();
write_plain(&mut out, &themes, &Options::default()).unwrap();
assert_eq!(out, b"th\xffeme (resources)\n");
}
#[test] #[test]
fn collect_sort_and_filter() { fn collect_sort_and_filter() {
let tmp = theme_dir(&[ let tmp = theme_dir(&[
+16 -9
View File
@@ -1408,14 +1408,13 @@ fn error_name(e: &ghostty_config::config::Error) -> String {
pub fn resolve_auto_theme_path() -> Result<String, Error> { pub fn resolve_auto_theme_path() -> Result<String, Error> {
let main_cfg_path = ghostty_config::file_load::preferred_default_file_path() let main_cfg_path = ghostty_config::file_load::preferred_default_file_path()
.map_err(|e| Error::Other(e.to_string()))?; .map_err(|e| Error::Other(e.to_string()))?;
let base_dir = std::path::Path::new(&main_cfg_path) let main_cfg_path = ghostty_os::osbytes::config_str_to_path(&main_cfg_path);
let base_dir = main_cfg_path
.parent() .parent()
.ok_or_else(|| Error::Other("BadPathName".to_string()))?; .ok_or_else(|| Error::Other("BadPathName".to_string()))?;
Ok(base_dir Ok(ghostty_os::osbytes::path_to_config_string(
.join("auto") &base_dir.join("auto").join("theme.ghostty"),
.join("theme.ghostty") ))
.to_string_lossy()
.into_owned())
} }
/// `writeAutoThemeFile`. /// `writeAutoThemeFile`.
@@ -1424,10 +1423,14 @@ pub fn write_auto_theme_file(path: Option<&str>, theme_name: &str) -> Result<(),
Some(p) => p.to_string(), Some(p) => p.to_string(),
None => resolve_auto_theme_path()?, None => resolve_auto_theme_path()?,
}; };
if let Some(dir) = std::path::Path::new(&auto_path).parent() { 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)?; std::fs::create_dir_all(dir)?;
} }
std::fs::write(&auto_path, format!("theme = {theme_name}\n"))?; std::fs::write(
&auto_path,
ghostty_os::osbytes::config_str_to_bytes(&format!("theme = {theme_name}\n")),
)?;
Ok(()) Ok(())
} }
@@ -1734,7 +1737,11 @@ fn run_loop(app: &mut Preview) -> Result<(), Error> {
match effect { match effect {
Effect::Copy(text) => { Effect::Copy(text) => {
let out = terminal.backend_mut(); let out = terminal.backend_mut();
write!(out, "\x1b]52;c;{}\x1b\\", base64(text.as_bytes()))?; write!(
out,
"\x1b]52;c;{}\x1b\\",
base64(&ghostty_os::osbytes::config_str_to_bytes(&text))
)?;
out.flush()?; out.flush()?;
} }
} }
+10 -4
View File
@@ -59,11 +59,12 @@ impl Options {
let expanded = let expanded =
ghostty_os::expand_home(stripped).map_err(|e| ArgError::Other(e.to_string()))?; ghostty_os::expand_home(stripped).map_err(|e| ArgError::Other(e.to_string()))?;
let realpath = let realpath =
std::fs::canonicalize(&expanded).map_err(|e| ArgError::Other(e.to_string()))?; std::fs::canonicalize(ghostty_os::osbytes::config_str_to_path(&expanded))
.map_err(|e| ArgError::Other(e.to_string()))?;
self.working_directory_seen = true; self.working_directory_seen = true;
return Ok(Some(format!( return Ok(Some(format!(
"--working-directory={}", "--working-directory={}",
realpath.to_string_lossy() ghostty_os::osbytes::path_to_config_string(&realpath)
))); )));
} }
@@ -192,8 +193,13 @@ pub fn run(args: &[String]) -> Result<u8, Error> {
if !opts.working_directory_seen { if !opts.working_directory_seen {
// This should be inserted at the beginning of the list, just in case `-e` was used. // This should be inserted at the beginning of the list, just in case `-e` was used.
let wd = std::fs::canonicalize(".")?; let wd = std::fs::canonicalize(".")?;
opts.arguments opts.arguments.insert(
.insert(0, format!("--working-directory={}", wd.to_string_lossy())); 0,
format!(
"--working-directory={}",
ghostty_os::osbytes::path_to_config_string(&wd)
),
);
} }
let target = match opts.class { let target = match opts.class {
+21 -2
View File
@@ -39,8 +39,10 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result<u8, Error> {
// If a config path is passed, validate it, otherwise validate default configs // If a config path is passed, validate it, otherwise validate default configs
if let Some(config_path) = &opts.config_file { if let Some(config_path) = &opts.config_file {
let abs_path = std::fs::canonicalize(config_path)?; // Byte-preserving: the option string carries non-UTF-8 bytes via the
cfg.load_file(&abs_path.to_string_lossy())?; // 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))?;
cfg.load_recursive_files()?; cfg.load_recursive_files()?;
} else { } else {
cfg = Config::load()?; cfg = Config::load()?;
@@ -109,4 +111,21 @@ mod tests {
}; };
assert!(run_inner(&opts, &mut Vec::new()).is_err()); assert!(run_inner(&opts, &mut Vec::new()).is_err());
} }
#[cfg(unix)]
#[test]
fn validate_symlink_to_non_utf8_target() {
use std::os::unix::ffi::OsStrExt;
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join(std::ffi::OsStr::from_bytes(b"cfg\xff"));
std::fs::write(&target, "font-size = 14\n").unwrap();
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
let opts = Options {
config_file: Some(link.to_string_lossy().into_owned()),
};
let mut out = Vec::new();
assert_eq!(0, run_inner(&opts, &mut out).unwrap());
assert_eq!(out.len(), 0);
}
} }
+61 -6
View File
@@ -343,10 +343,21 @@ pub fn parse_auto_struct_with(
/// Decode a double-quoted Zig string literal (including the quotes). /// Decode a double-quoted Zig string literal (including the quotes).
/// Returns `None` on failure. Bytes produced by `\x` escapes that do not form /// Returns `None` on failure. Bytes produced by `\x` escapes that do not form
/// valid UTF-8 are converted lossily. /// valid UTF-8 stay raw bytes: they are carried through the reversible
/// `ghostty_os::osbytes` escape encoding (`\xFF` -> `U+FDD0 U+00FF`, decoded back
/// to `0xFF` by `config_str_to_bytes`). See [`parse_zig_string_literal_bytes`]
/// for the raw bytes.
#[must_use] #[must_use]
pub fn parse_zig_string_literal(s: &str) -> Option<String> { pub fn parse_zig_string_literal(s: &str) -> Option<String> {
let b = s.as_bytes(); parse_zig_string_literal_bytes(s).map(|b| ghostty_os::osbytes::bytes_to_config_string(&b))
}
/// Byte-exact [`parse_zig_string_literal`] (Zig `std.zig.string_literal`
/// decoding yields a `[]u8`).
#[must_use]
pub fn parse_zig_string_literal_bytes(s: &str) -> Option<Vec<u8>> {
let b = ghostty_os::osbytes::config_str_to_bytes(s);
let b = b.as_slice();
if b.len() < 2 || b[0] != b'"' || b[b.len() - 1] != b'"' { if b.len() < 2 || b[0] != b'"' || b[b.len() - 1] != b'"' {
return None; return None;
} }
@@ -395,7 +406,7 @@ pub fn parse_zig_string_literal(s: &str) -> Option<String> {
} }
i += 1; i += 1;
} }
Some(String::from_utf8_lossy(&out).into_owned()) Some(out)
} }
/// Parse a "packed struct" value (comma separated names, optionally /// Parse a "packed struct" value (comma separated names, optionally
@@ -652,8 +663,13 @@ impl<I: Iterator<Item = String>> ArgIter for ArgsIterator<I> {
/// Create an args iterator for the process args. This will skip argv0. /// Create an args iterator for the process args. This will skip argv0.
#[must_use] #[must_use]
pub fn args_iterator() -> ArgsIterator<std::iter::Skip<std::vec::IntoIter<String>>> { pub fn args_iterator() -> ArgsIterator<std::iter::Skip<std::vec::IntoIter<String>>> {
// Byte-safe: never panics on non-UTF-8 arguments. // Byte-safe: non-UTF-8 arguments keep their bytes (reversible escape
ArgsIterator::new(ghostty_os::args::collect_lossy().into_iter().skip(1)) // encoding, see `ghostty_os::osbytes`).
ArgsIterator::new(
ghostty_os::args::collect_config_strings()
.into_iter()
.skip(1),
)
} }
/// An iterator valid for arg parsing from a slice. /// An iterator valid for arg parsing from a slice.
@@ -791,7 +807,8 @@ impl<R: BufRead> LineIterator<R> {
self.line += 1; self.line += 1;
let line = String::from_utf8_lossy(&buf); // Raw bytes survive via the reversible escape encoding.
let line = ghostty_os::osbytes::bytes_to_config_string(&buf);
// Trim any whitespace (including CR) around it // Trim any whitespace (including CR) around it
let trim = line.trim_matches(|c| c == ' ' || c == '\t' || c == '\r'); let trim = line.trim_matches(|c| c == ' ' || c == '\t' || c == '\r');
@@ -1533,4 +1550,42 @@ mod tests {
); );
assert_eq!(parse_zig_string_literal("\"\\q\""), None); assert_eq!(parse_zig_string_literal("\"\\q\""), None);
} }
#[test]
fn zig_string_literal_keeps_raw_bytes() {
// `\xFF` is not valid UTF-8 and must stay the single byte 0xFF.
assert_eq!(
parse_zig_string_literal_bytes("\"a\\xFFb\"").unwrap(),
vec![b'a', 0xff, b'b']
);
let s = parse_zig_string_literal("\"a\\xFFb\"").unwrap();
assert_eq!(
ghostty_os::osbytes::config_str_to_bytes(&s),
vec![b'a', 0xff, b'b']
);
assert!(!s.contains('\u{fffd}'));
// Valid multi-byte sequences spelled with escapes still decode.
assert_eq!(
parse_zig_string_literal("\"\\xC3\\xA9\"").unwrap(),
"\u{e9}"
);
// Raw (unescaped) invalid input bytes survive the escape encoding.
let raw = ghostty_os::osbytes::bytes_to_config_string(b"\"x\xfey\"");
assert_eq!(
parse_zig_string_literal_bytes(&raw).unwrap(),
vec![b'x', 0xfe, b'y']
);
}
#[test]
fn line_iterator_keeps_invalid_utf8_bytes() {
let data: &[u8] = b"title = a\xffb\n";
let mut it = LineIterator::new(data);
let line = it.next().unwrap();
assert_eq!(
ghostty_os::osbytes::config_str_to_bytes(&line),
b"--title=a\xffb".to_vec()
);
assert!(!line.contains('\u{fffd}'));
}
} }
+7 -7
View File
@@ -3877,7 +3877,7 @@ impl Config {
/// ///
/// `path` must be resolved and absolute. /// `path` must be resolved and absolute.
pub fn load_file(&mut self, path: &str) -> Result<(), Error> { pub fn load_file(&mut self, path: &str) -> Result<(), Error> {
assert!(std::path::Path::new(path).is_absolute()); assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute());
let file = match crate::file_load::open(path) { let file = match crate::file_load::open(path) {
Ok(f) => f, Ok(f) => f,
Err(crate::file_load::OpenFileError::NotAFile) => { Err(crate::file_load::OpenFileError::NotAFile) => {
@@ -3916,9 +3916,9 @@ impl Config {
} }
let mut iter = cli_args::LineIterator::with_path(reader, path); let mut iter = cli_args::LineIterator::with_path(reader, path);
self.load_iter(&mut iter)?; self.load_iter(&mut iter)?;
let dir = std::path::Path::new(path) let dir = ghostty_os::osbytes::config_str_to_path(path)
.parent() .parent()
.map(|p| p.to_string_lossy().into_owned()) .map(ghostty_os::osbytes::path_to_config_string)
.unwrap_or_default(); .unwrap_or_default();
self.expand_paths(&dir)?; self.expand_paths(&dir)?;
Ok(()) Ok(())
@@ -4067,7 +4067,7 @@ impl Config {
// a command to execute. // a command to execute.
let mut builder: Vec<String> = Vec::new(); let mut builder: Vec<String> = Vec::new();
for arg in args { for arg in args {
let copy = arg.to_string_lossy().into_owned(); let copy = ghostty_os::osbytes::bytes_to_config_string(arg.to_bytes());
self.replay_steps.push(ReplayStep::Arg(copy.clone())); self.replay_steps.push(ReplayStep::Arg(copy.clone()));
builder.push(copy); builder.push(copy);
} }
@@ -4126,7 +4126,7 @@ impl Config {
// Any paths referenced from the CLI are relative to the current working // Any paths referenced from the CLI are relative to the current working
// directory. // directory.
let cwd = std::fs::canonicalize(".")?; let cwd = std::fs::canonicalize(".")?;
self.expand_paths(&cwd.to_string_lossy())?; self.expand_paths(&ghostty_os::osbytes::path_to_config_string(&cwd))?;
Ok(()) Ok(())
} }
@@ -4170,7 +4170,7 @@ impl Config {
// All paths should already be absolute at this point because // All paths should already be absolute at this point because
// they're fixed up after each load. // they're fixed up after each load.
assert!(std::path::Path::new(&path).is_absolute()); assert!(ghostty_os::osbytes::config_str_to_path(&path).is_absolute());
// We must only load a unique file once // We must only load a unique file once
if !loaded.insert(path.clone()) { if !loaded.insert(path.clone()) {
@@ -4183,7 +4183,7 @@ impl Config {
continue; continue;
} }
let file = match std::fs::File::open(&path) { let file = match std::fs::File::open(ghostty_os::osbytes::config_str_to_path(&path)) {
Ok(f) => f, Ok(f) => f,
Err(err) => { Err(err) => {
if err.kind() != std::io::ErrorKind::NotFound || !optional { if err.kind() != std::io::ErrorKind::NotFound || !optional {
+4 -3
View File
@@ -59,7 +59,7 @@ pub fn legacy_default_app_support_path() -> Result<String, xdg::Error> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn app_support_dir(sub_path: &str) -> Result<String, xdg::Error> { fn app_support_dir(sub_path: &str) -> Result<String, xdg::Error> {
ghostty_os::macos::app_support_dir(sub_path) ghostty_os::macos::app_support_dir(sub_path)
.map(|p| p.to_string_lossy().into_owned()) .map(|p| ghostty_os::osbytes::path_to_config_string(&p))
.map_err(|_| xdg::Error::AppleApiFailed) .map_err(|_| xdg::Error::AppleApiFailed)
} }
@@ -159,9 +159,10 @@ impl std::error::Error for OpenFileError {}
/// if it exists and is non-empty. This also constrains the possible /// if it exists and is non-empty. This also constrains the possible
/// errors to a smaller set that we can explicitly handle. /// errors to a smaller set that we can explicitly handle.
pub fn open(path: &str) -> Result<File, OpenFileError> { pub fn open(path: &str) -> Result<File, OpenFileError> {
assert!(std::path::Path::new(path).is_absolute()); assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute());
let file = File::open(path).map_err(|err| { // `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| {
if err.kind() == std::io::ErrorKind::NotFound { if err.kind() == std::io::ErrorKind::NotFound {
OpenFileError::FileNotFound OpenFileError::FileNotFound
} else { } else {
+32 -8
View File
@@ -2,9 +2,11 @@
use std::{ use std::{
fmt, fmt,
path::{Component, Path as StdPath, PathBuf}, path::{Component, PathBuf},
}; };
use ghostty_os::osbytes::{config_str_to_path, path_to_config_string};
use crate::{ use crate::{
c_value::CValue, c_value::CValue,
cli_args::{ArgError, ConfigField}, cli_args::{ArgError, ConfigField},
@@ -32,7 +34,7 @@ impl Default for Path {
/// Lexically resolve `base/path` like `std.fs.path.resolve`. /// Lexically resolve `base/path` like `std.fs.path.resolve`.
fn resolve_lexical(base: &str, path: &str) -> String { fn resolve_lexical(base: &str, path: &str) -> String {
let joined = PathBuf::from(base).join(path); let joined = config_str_to_path(base).join(config_str_to_path(path));
let mut out = PathBuf::new(); let mut out = PathBuf::new();
for c in joined.components() { for c in joined.components() {
match c { match c {
@@ -43,7 +45,7 @@ fn resolve_lexical(base: &str, path: &str) -> String {
other => out.push(other.as_os_str()), other => out.push(other.as_os_str()),
} }
} }
out.to_string_lossy().into_owned() path_to_config_string(&out)
} }
impl Path { impl Path {
@@ -109,12 +111,15 @@ impl Path {
/// absolute path. Errors are added to `diags` if they occur; an `Err` is /// absolute path. Errors are added to `diags` if they occur; an `Err` is
/// only returned if the base directory cannot be opened. /// only returned if the base directory cannot be opened.
pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> { pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> {
assert!(StdPath::new(base).is_absolute()); // 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);
assert!(base_path.is_absolute());
let path = self.path().to_string(); let path = self.path().to_string();
// If it is already absolute we can ignore it. // If it is already absolute we can ignore it.
if path.is_empty() || StdPath::new(&path).is_absolute() { if path.is_empty() || config_str_to_path(&path).is_absolute() {
return Ok(()); return Ok(());
} }
@@ -143,11 +148,11 @@ impl Path {
} }
// Make sure the base directory can be opened. // Make sure the base directory can be opened.
std::fs::read_dir(base)?; std::fs::read_dir(&base_path)?;
let full = StdPath::new(base).join(&path); let full = base_path.join(config_str_to_path(&path));
let abs = match std::fs::canonicalize(&full) { let abs = match std::fs::canonicalize(&full) {
Ok(p) => p.to_string_lossy().into_owned(), Ok(p) => path_to_config_string(&p),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
// The file doesn't exist. Try to resolve the relative path // The file doesn't exist. Try to resolve the relative path
// another way. // another way.
@@ -374,4 +379,23 @@ mod tests {
assert_eq!(p, Path::Optional("/abs/path".into())); assert_eq!(p, Path::Optional("/abs/path".into()));
assert!(diags.is_empty()); assert!(diags.is_empty());
} }
#[cfg(unix)]
#[test]
fn expand_symlink_to_non_utf8_target_round_trips() {
use std::os::unix::ffi::OsStrExt;
let tmp = ghostty_os::TempDir::init().unwrap();
let base = std::fs::canonicalize(tmp.path()).unwrap();
let target = base.join(std::ffi::OsStr::from_bytes(b"t\xffarget"));
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 diags = DiagnosticList::default();
p.expand(&path_to_config_string(&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());
}
} }
+18 -14
View File
@@ -3,7 +3,14 @@
//! Zig reads the resources dir from global state; here it is passed //! Zig reads the resources dir from global state; here it is passed
//! explicitly (`resources_dir`, the value of `ResourcesDir.app()`). //! explicitly (`resources_dir`, the value of `ResourcesDir.app()`).
use std::{fs::File, path::Path}; 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 crate::diagnostics::{Diagnostic, DiagnosticList}; use crate::diagnostics::{Diagnostic, DiagnosticList};
@@ -35,12 +42,9 @@ impl Location {
.ok(), .ok(),
Location::Resources => { Location::Resources => {
let base = resources_dir?; let base = resources_dir?;
Some( Some(path_to_config_string(
Path::new(base) &config_str_to_path(base).join("themes"),
.join("themes") ))
.to_string_lossy()
.into_owned(),
)
} }
} }
} }
@@ -161,7 +165,7 @@ pub fn open(
diags: &mut DiagnosticList, diags: &mut DiagnosticList,
) -> Option<OpenedTheme> { ) -> Option<OpenedTheme> {
// Absolute themes are loaded a different path. // Absolute themes are loaded a different path.
if Path::new(theme).is_absolute() { if config_str_to_path(theme).is_absolute() {
let file = open_absolute(theme, diags)?; let file = open_absolute(theme, diags)?;
if !check_regular(&file, theme, diags) { if !check_regular(&file, theme, diags) {
return None; return None;
@@ -172,9 +176,9 @@ pub fn open(
}); });
} }
let basename = Path::new(theme) let basename = config_str_to_path(theme)
.file_name() .file_name()
.map(|s| s.to_string_lossy().into_owned()) .map(os_to_config_string)
.unwrap_or_default(); .unwrap_or_default();
if theme != basename { if theme != basename {
diag( diag(
@@ -190,8 +194,8 @@ pub fn open(
// one that exists. // one that exists.
let mut it = LocationIterator::new(resources_dir); let mut it = LocationIterator::new(resources_dir);
while let Some((_, dir)) = it.next_location() { while let Some((_, dir)) = it.next_location() {
let path = Path::new(&dir).join(theme).to_string_lossy().into_owned(); let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme)));
match File::open(&path) { match File::open(config_str_to_path(&path)) {
Ok(file) => { Ok(file) => {
if !check_regular(&file, theme, diags) { if !check_regular(&file, theme, diags) {
return None; return None;
@@ -215,7 +219,7 @@ pub fn open(
// our iterator, reiterate over in order to build a better error message. // our iterator, reiterate over in order to build a better error message.
it.reset(); it.reset();
while let Some((_, dir)) = it.next_location() { while let Some((_, dir)) = it.next_location() {
let path = Path::new(&dir).join(theme).to_string_lossy().into_owned(); let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme)));
diag( diag(
diags, diags,
format!("theme \"{theme}\" not found, tried path \"{path}\""), format!("theme \"{theme}\" not found, tried path \"{path}\""),
@@ -229,7 +233,7 @@ pub fn open(
/// then messages will be appended to the given error list and None is /// then messages will be appended to the given error list and None is
/// returned. /// returned.
pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option<File> { pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option<File> {
match File::open(theme) { match File::open(config_str_to_path(theme)) {
Ok(f) => Some(f), Ok(f) => Some(f),
Err(err) => { Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound { if err.kind() == std::io::ErrorKind::NotFound {
+45 -16
View File
@@ -5,9 +5,28 @@
//! don't panic). //! don't panic).
/// Returns an iterator over the command line arguments. /// Returns an iterator over the command line arguments.
///
/// If [`set_argv`] was called (the embedded `ghostty_init`, Zig:
/// `std.os.argv = argv[0..argc]`) those arguments are returned instead of
/// the process arguments.
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
pub fn iterator() -> std::env::ArgsOs { pub fn iterator() -> std::vec::IntoIter<std::ffi::OsString> {
std::env::args_os() match ARGV_OVERRIDE.lock().ok().and_then(|g| g.clone()) {
Some(v) => v.into_iter(),
None => std::env::args_os().collect::<Vec<_>>().into_iter(),
}
}
static ARGV_OVERRIDE: std::sync::Mutex<Option<Vec<std::ffi::OsString>>> =
std::sync::Mutex::new(None);
/// Replace the process arguments seen by [`iterator`] (Zig's assignment to
/// `std.os.argv` in `ghostty_init`). Has no effect on macOS, where the
/// arguments always come from `NSProcessInfo` (as in Zig).
pub fn set_argv(args: Vec<std::ffi::OsString>) {
if let Ok(mut g) = ARGV_OVERRIDE.lock() {
*g = Some(args);
}
} }
/// Returns an iterator over the command line arguments. /// Returns an iterator over the command line arguments.
@@ -27,15 +46,25 @@ pub fn iterator() -> std::vec::IntoIter<std::ffi::OsString> {
.into_iter() .into_iter()
} }
/// Collect the process arguments as `String`s without ever panicking on /// Collect the process arguments as raw OS strings (no conversion, never
/// non-UTF-8 bytes (invalid sequences become U+FFFD). The CLI/config layers /// panics on non-UTF-8 bytes).
/// are string based; `+action` dispatch only depends on the ASCII prefix. pub fn collect_os() -> Vec<std::ffi::OsString> {
pub fn collect_lossy() -> Vec<String> { iterator().collect()
lossy(iterator())
} }
fn lossy(it: impl Iterator<Item = std::ffi::OsString>) -> Vec<String> { /// Collect the process arguments as raw byte vectors (Zig `[]const u8`).
it.map(|a| a.to_string_lossy().into_owned()).collect() pub fn collect_bytes() -> Vec<Vec<u8>> {
iterator().map(|a| crate::osbytes::os_bytes(&a)).collect()
}
/// Collect the process arguments as `String`s using the reversible
/// byte-escape encoding of [`crate::osbytes`]: no bytes are lost, invalid
/// UTF-8 bytes are carried as private-use escapes that the config layer
/// decodes back to the original bytes.
pub fn collect_config_strings() -> Vec<String> {
iterator()
.map(|a| crate::osbytes::os_to_config_string(&a))
.collect()
} }
#[cfg(test)] #[cfg(test)]
@@ -50,13 +79,13 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn lossy_does_not_panic_on_non_utf8() { fn config_strings_keep_non_utf8_bytes() {
use std::os::unix::ffi::OsStringExt; use std::os::unix::ffi::OsStringExt;
let args = vec![ let a = std::ffi::OsString::from_vec(vec![b'+', b'x', 0xff]);
std::ffi::OsString::from("ghostty"), let s = crate::osbytes::os_to_config_string(&a);
std::ffi::OsString::from_vec(vec![b'+', b'x', 0xff]), assert_eq!(
]; crate::osbytes::config_str_to_bytes(&s),
let out = lossy(args.into_iter()); vec![b'+', b'x', 0xff]
assert_eq!(out, vec!["ghostty".to_owned(), "+x\u{fffd}".to_owned()]); );
} }
} }
+23 -6
View File
@@ -4,8 +4,14 @@ use std::io::Read;
/// into a caller-provided buffer; callers there use path-sized buffers). /// into a caller-provided buffer; callers there use path-sized buffers).
const READ_LIMIT: u64 = 4096; const READ_LIMIT: u64 = 4096;
/// Returns the path to the cgroup for the given pid. /// Returns the path to the cgroup for the given pid. Non-UTF-8 bytes are
/// carried via [`crate::osbytes`]; see [`current_os`] for the raw bytes.
pub fn current(pid: u32) -> Option<String> { pub fn current(pid: u32) -> Option<String> {
current_os(pid).map(|p| crate::osbytes::os_to_config_string(&p))
}
/// Byte-preserving [`current`].
pub fn current_os(pid: u32) -> Option<std::ffi::OsString> {
// Read our cgroup by opening /proc/<pid>/cgroup and reading the first // Read our cgroup by opening /proc/<pid>/cgroup and reading the first
// line. The first line will look something like this: // line. The first line will look something like this:
// 0::/user.slice/user-1000.slice/session-1.scope // 0::/user.slice/user-1000.slice/session-1.scope
@@ -21,15 +27,15 @@ pub fn current(pid: u32) -> Option<String> {
.iter() .iter()
.rposition(|b| !matches!(b, b' ' | b'\r' | b'\n')) .rposition(|b| !matches!(b, b' ' | b'\r' | b'\n'))
.map_or(0, |i| i + 1); .map_or(0, |i| i + 1);
Some(String::from_utf8_lossy(&rest[..end]).into_owned()) Some(crate::osbytes::os_from_bytes(rest[..end].to_vec()))
} }
/// True if the final path component of `pid`'s cgroup equals `expected`. /// True if the final path component of `pid`'s cgroup equals `expected`.
pub fn scope_matches(pid: u32, expected: &str) -> bool { pub fn scope_matches(pid: u32, expected: &str) -> bool {
current(pid) current_os(pid).is_some_and(|raw| {
.as_deref() let raw = crate::osbytes::os_bytes(&raw);
.and_then(|raw| raw.rsplit_once('/')) raw.rsplit(|&b| b == b'/').next() == Some(expected.as_bytes()) && raw.contains(&b'/')
.is_some_and(|(_, last)| last == expected) })
} }
/// Poll (every 25ms, at most 250ms) until `pid`'s cgroup ends in `expected`. /// Poll (every 25ms, at most 250ms) until `pid`'s cgroup ends in `expected`.
@@ -61,4 +67,15 @@ mod tests {
} }
assert!(current(u32::MAX).is_none()); assert!(current(u32::MAX).is_none());
} }
#[test]
fn scope_matches_requires_slash_and_last_component() {
// Our own cgroup path always matches its own last component.
if let Some(p) = current(std::process::id())
&& let Some((_, last)) = p.rsplit_once('/')
{
assert!(scope_matches(std::process::id(), last));
}
assert!(!scope_matches(u32::MAX, "x"));
}
} }
+30 -3
View File
@@ -235,10 +235,24 @@ pub fn prepend_env(current: &str, value: &str) -> String {
format!("{value}{DELIMITER}{current}") format!("{value}{DELIMITER}{current}")
} }
/// Gets the value of an environment variable, or None if not found /// Gets the raw value of an environment variable, or None if not found.
/// (or not valid unicode). /// A present value that is not valid UTF-8 is returned as-is (never treated
/// as absent).
pub fn getenv_os(key: &str) -> Option<OsString> {
std::env::var_os(key)
}
/// Gets the raw value of an environment variable. Returns None if not found
/// or the value is empty.
pub fn getenv_not_empty_os(key: &str) -> Option<OsString> {
getenv_os(key).filter(|v| !v.is_empty())
}
/// Gets the value of an environment variable, or None if not found. A
/// non-UTF-8 value is still present: its invalid bytes are carried through
/// the reversible [`crate::osbytes`] encoding.
pub fn getenv(key: &str) -> Option<String> { pub fn getenv(key: &str) -> Option<String> {
std::env::var(key).ok() getenv_os(key).map(|v| crate::osbytes::os_to_config_string(&v))
} }
/// Gets the value of an environment variable. Returns None if not found or /// Gets the value of an environment variable. Returns None if not found or
@@ -294,6 +308,19 @@ mod tests {
assert_eq!(append_env("a:b", "foo"), format!("a:b{DELIMITER}foo")); assert_eq!(append_env("a:b", "foo"), format!("a:b{DELIMITER}foo"));
} }
#[cfg(unix)]
#[test]
fn getenv_non_utf8_is_present() {
let v = OsString::from_vec(vec![b'a', 0xff]);
temp_env::with_var("GHOSTTY_RS_T3", Some(&v), || {
assert_eq!(getenv_os("GHOSTTY_RS_T3").unwrap(), v);
assert_eq!(getenv_not_empty_os("GHOSTTY_RS_T3").unwrap(), v);
let s = getenv("GHOSTTY_RS_T3").unwrap();
assert_eq!(crate::osbytes::config_str_to_bytes(&s), vec![b'a', 0xff]);
assert!(getenv_not_empty("GHOSTTY_RS_T3").is_some());
});
}
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn os_variants_preserve_bytes() { fn os_variants_preserve_bytes() {
+40 -6
View File
@@ -70,19 +70,41 @@ pub fn restore_max_files(lim: Rlimit) {
let _ = setrlimit(Resource::Nofile, lim); let _ = setrlimit(Resource::Nofile, lim);
} }
/// Return the recommended path for temporary files. /// Resource limit placeholder on platforms without `rlimit` (Zig:
pub fn alloc_tmp_dir() -> Option<String> { /// `rlimit = if (@hasDecl(posix.system, "rlimit")) posix.rlimit else struct {}`).
#[cfg(not(unix))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Rlimit;
/// Unsupported-platform fallback: returns `None` like Zig.
#[cfg(not(unix))]
pub fn fix_max_files() -> Option<Rlimit> {
None
}
/// Unsupported-platform fallback: no-op like Zig.
#[cfg(not(unix))]
pub fn restore_max_files(_lim: Rlimit) {}
/// Return the recommended path for temporary files (byte-preserving).
pub fn alloc_tmp_dir_os() -> Option<std::path::PathBuf> {
if cfg!(windows) { if cfg!(windows) {
// TODO: what is a good fallback path on windows? (same as Zig) // TODO: what is a good fallback path on windows? (same as Zig)
return std::env::var_os("TMP").map(|v| v.to_string_lossy().into_owned()); return std::env::var_os("TMP").map(Into::into);
} }
if let Some(v) = std::env::var_os("TMPDIR") { if let Some(v) = std::env::var_os("TMPDIR") {
return Some(v.to_string_lossy().into_owned()); return Some(v.into());
} }
if let Some(v) = std::env::var_os("TMP") { if let Some(v) = std::env::var_os("TMP") {
return Some(v.to_string_lossy().into_owned()); return Some(v.into());
} }
Some("/tmp".to_owned()) Some("/tmp".into())
}
/// Return the recommended path for temporary files. Non-UTF-8 bytes are
/// carried via [`crate::osbytes`]; see [`alloc_tmp_dir_os`].
pub fn alloc_tmp_dir() -> Option<String> {
alloc_tmp_dir_os().map(|p| crate::osbytes::os_to_config_string(p.as_os_str()))
} }
#[cfg(all(test, unix))] #[cfg(all(test, unix))]
@@ -103,6 +125,18 @@ mod tests {
}); });
} }
#[test]
fn tmp_dir_non_utf8() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let v = std::ffi::OsString::from_vec(b"/tmp/\xff".to_vec());
temp_env::with_vars([("TMPDIR", Some(v.clone())), ("TMP", None)], || {
assert_eq!(
alloc_tmp_dir_os().unwrap().as_os_str().as_bytes(),
b"/tmp/\xff"
);
});
}
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn fix_and_restore_max_files() { fn fix_and_restore_max_files() {
+69 -27
View File
@@ -1,6 +1,8 @@
use std::ffi::{OsStr, OsString};
#[cfg(unix)] #[cfg(unix)]
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use crate::osbytes;
#[cfg(unix)] #[cfg(unix)]
use crate::passwd; use crate::passwd;
@@ -24,16 +26,29 @@ const MAX_PATH_BYTES: usize = 4096;
/// Determine the home directory for the currently executing user. This /// Determine the home directory for the currently executing user. This
/// is generally an expensive process so the value should be cached. /// is generally an expensive process so the value should be cached.
///
/// Non-UTF-8 bytes are preserved through [`crate::osbytes`]; use
/// [`home_os`] for the raw OS string.
pub fn home() -> Result<Option<String>, Error> { pub fn home() -> Result<Option<String>, Error> {
home_with_capacity(MAX_PATH_BYTES) home_with_capacity(MAX_PATH_BYTES)
} }
/// Byte-preserving [`home`].
pub fn home_os() -> Result<Option<OsString>, Error> {
home_os_with_capacity(MAX_PATH_BYTES)
}
/// Like [`home`] but emulates the fixed output buffer of the Zig version: /// Like [`home`] but emulates the fixed output buffer of the Zig version:
/// errors with `BufferTooSmall` if the result is longer than `cap` bytes. /// errors with `BufferTooSmall` if the result is longer than `cap` bytes.
#[cfg(unix)]
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> { pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
let check = |s: String| { Ok(home_os_with_capacity(cap)?.map(|h| osbytes::os_to_config_string(&h)))
if cap < s.len() { }
/// Byte-preserving [`home_with_capacity`].
#[cfg(unix)]
pub fn home_os_with_capacity(cap: usize) -> Result<Option<OsString>, Error> {
let check = |s: OsString| {
if cap < osbytes::os_bytes(&s).len() {
Err(Error::BufferTooSmall) Err(Error::BufferTooSmall)
} else { } else {
Ok(Some(s)) Ok(Some(s))
@@ -42,7 +57,7 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
// First: if we have a HOME env var, then we use that. // First: if we have a HOME env var, then we use that.
if let Some(result) = std::env::var_os("HOME") { if let Some(result) = std::env::var_os("HOME") {
return check(result.to_string_lossy().into_owned()); return check(result);
} }
// On macOS: [NSFileManager defaultManager].homeDirectoryForCurrentUser.path // On macOS: [NSFileManager defaultManager].homeDirectoryForCurrentUser.path
@@ -53,12 +68,12 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
let manager = NSFileManager::defaultManager(); let manager = NSFileManager::defaultManager();
let home_url = manager.homeDirectoryForCurrentUser(); let home_url = manager.homeDirectoryForCurrentUser();
if let Some(path) = home_url.path() { if let Some(path) = home_url.path() {
return check(path.to_string()); return check(OsString::from(path.to_string()));
} }
} }
// We try passwd. This doesn't work on multi-user mac but we try it anyways. // We try passwd. This doesn't work on multi-user mac but we try it anyways.
if let Ok(pw) = passwd::get() if let Ok(pw) = passwd::get_os()
&& let Some(result) = pw.home && let Some(result) = pw.home
{ {
return check(result); return check(result);
@@ -73,8 +88,7 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
if let Ok(run) = run if let Ok(run) = run
&& run.status.success() && run.status.success()
{ {
let out = String::from_utf8_lossy(&run.stdout); return check(osbytes::os_from_bytes(trim_space(&run.stdout).to_vec()));
return check(trim_space(&out).to_owned());
} }
Ok(None) Ok(None)
@@ -83,12 +97,10 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
/// Windows: `%HOMEDRIVE%%HOMEPATH%` (`homeWindows`). Returns `None` if /// Windows: `%HOMEDRIVE%%HOMEPATH%` (`homeWindows`). Returns `None` if
/// either variable is missing. /// either variable is missing.
#[cfg(windows)] #[cfg(windows)]
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> { pub fn home_os_with_capacity(cap: usize) -> Result<Option<OsString>, Error> {
home_windows( let drive = std::env::var_os("HOMEDRIVE").map(|v| v.to_string_lossy().into_owned());
std::env::var("HOMEDRIVE").ok().as_deref(), let path = std::env::var_os("HOMEPATH").map(|v| v.to_string_lossy().into_owned());
std::env::var("HOMEPATH").ok().as_deref(), Ok(home_windows(drive.as_deref(), path.as_deref(), cap)?.map(OsString::from))
cap,
)
} }
/// Pure part of [`home_with_capacity`] on Windows. /// Pure part of [`home_with_capacity`] on Windows.
@@ -108,8 +120,14 @@ fn home_windows(
} }
#[cfg(unix)] #[cfg(unix)]
fn trim_space(input: &str) -> &str { fn trim_space(input: &[u8]) -> &[u8] {
input.trim_matches([' ', '\n', '\t']) let is_ws = |b: &u8| matches!(b, b' ' | b'\n' | b'\t');
let start = input.iter().position(|b| !is_ws(b)).unwrap_or(input.len());
let end = input
.iter()
.rposition(|b| !is_ws(b))
.map_or(start, |i| i + 1);
&input[start..end]
} }
/// Expands a path that starts with a tilde (~) to the home directory of /// Expands a path that starts with a tilde (~) to the home directory of
@@ -121,31 +139,41 @@ pub fn expand_home(path: &str) -> Result<String, ExpandError> {
expand_home_with_capacity(path, MAX_PATH_BYTES) expand_home_with_capacity(path, MAX_PATH_BYTES)
} }
/// Byte-preserving [`expand_home`].
pub fn expand_home_os(path: &OsStr) -> Result<OsString, ExpandError> {
expand_home_os_with_capacity(path, MAX_PATH_BYTES)
}
/// Like [`expand_home`] with an explicit output capacity (emulating the /// Like [`expand_home`] with an explicit output capacity (emulating the
/// caller-provided buffer of the Zig version). /// caller-provided buffer of the Zig version).
pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, ExpandError> {
expand_home_os_with_capacity(&osbytes::config_str_to_os(path), cap)
.map(|p| osbytes::os_to_config_string(&p))
}
/// Windows: `~/` is not an idiom generally used on Windows.
#[cfg(windows)] #[cfg(windows)]
pub fn expand_home_with_capacity(path: &str, _cap: usize) -> Result<String, ExpandError> { pub fn expand_home_os_with_capacity(path: &OsStr, _cap: usize) -> Result<OsString, ExpandError> {
// `~/` is not an idiom generally used on Windows
Ok(path.to_owned()) Ok(path.to_owned())
} }
/// See the Windows variant above. /// See the Windows variant above.
#[cfg(unix)] #[cfg(unix)]
pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, ExpandError> { pub fn expand_home_os_with_capacity(path: &OsStr, cap: usize) -> Result<OsString, ExpandError> {
if !path.starts_with("~/") { let bytes = osbytes::os_bytes(path);
if !bytes.starts_with(b"~/") {
return Ok(path.to_owned()); return Ok(path.to_owned());
} }
let Ok(Some(home_dir)) = home_with_capacity(cap) else { let Ok(Some(home_dir)) = home_os_with_capacity(cap) else {
return Err(ExpandError::HomeDetectionFailed); return Err(ExpandError::HomeDetectionFailed);
}; };
let rest = &path[1..]; // Skip the ~ let mut out = osbytes::os_bytes(&home_dir);
let expanded_len = home_dir.len() + rest.len(); let rest = &bytes[1..]; // Skip the ~
if out.len() + rest.len() > cap {
if expanded_len > cap {
return Err(ExpandError::BufferTooSmall); return Err(ExpandError::BufferTooSmall);
} }
out.extend_from_slice(rest);
Ok(format!("{home_dir}{rest}")) Ok(osbytes::os_from_bytes(out))
} }
#[cfg(test)] #[cfg(test)]
@@ -211,6 +239,20 @@ mod tests_unix {
}); });
} }
#[test]
fn home_preserves_non_utf8_env() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let h = OsString::from_vec(b"/home/\xffu".to_vec());
temp_env::with_var("HOME", Some(&h), || {
assert_eq!(home_os().unwrap().unwrap(), h);
let e = expand_home_os(OsStr::from_bytes(b"~/x\xfe")).unwrap();
assert_eq!(e.as_bytes(), b"/home/\xffu/x\xfe");
// The String API round-trips through the escape encoding.
let s = expand_home("~/y").unwrap();
assert_eq!(osbytes::config_str_to_bytes(&s), b"/home/\xffu/y");
});
}
#[test] #[test]
fn home_prefers_env() { fn home_prefers_env() {
temp_env::with_var("HOME", Some("/some/home"), || { temp_env::with_var("HOME", Some("/some/home"), || {
+66 -12
View File
@@ -79,26 +79,49 @@ pub fn tr(msgid: &str) -> String {
/// a POSIX-compliant value. /// a POSIX-compliant value.
/// ///
/// The Zig version is a thin layer over gnulib's /// The Zig version is a thin layer over gnulib's
/// `_libintl_locale_name_canonicalize` (only linked when building libintl /// `_libintl_locale_name_canonicalize` (the Mac OS X flavour of
/// from source, i.e. macOS). That function isn't available here, so this is /// `gl_locale_name_canonicalize`), after fixing up some `zh` locales. This
/// a pure-Rust approximation of the cases that matter: BCP-47 style tags /// ports that function exactly, including its legacy-name, language-tag and
/// have `-` replaced with `_` and the Chinese script tags are mapped to /// script tables ([`crate::i18n_canon_table`]). Encodings are NOT parsed out
/// their POSIX region equivalents. Encodings are NOT parsed out (like the /// (like the original, `en_US.UTF-8` becomes `en_US.UTF_8`).
/// original, `en_US.UTF-8` becomes `en_US.UTF_8`).
pub fn canonicalize_locale(locale: &str) -> String { pub fn canonicalize_locale(locale: &str) -> String {
// Fix zh locales // Fix zh locales
if let Some(fixed) = fix_zh_locale(locale) { if let Some(fixed) = fix_zh_locale(locale) {
return fixed.to_owned(); return fixed.to_owned();
} }
// Emulate gnulib's mapping of bare zh script tags. libintl_locale_name_canonicalize(locale)
match locale { }
"zh-Hans" => return "zh_CN".to_owned(),
"zh-Hant" => return "zh_TW".to_owned(), /// `gl_locale_name_canonicalize` (gnulib `localename-unsafe.c`).
_ => {} fn libintl_locale_name_canonicalize(name: &str) -> String {
use crate::i18n_canon_table::{LANGTAG_TABLE, LEGACY_TABLE, SCRIPT_TABLE};
// Step 1: Convert using legacy_table.
if name.as_bytes().first().is_some_and(u8::is_ascii_uppercase)
&& let Some((_, unixy)) = LEGACY_TABLE.iter().find(|(l, _)| *l == name)
{
return (*unixy).to_owned();
} }
locale.replace('-', "_") // Step 2: Convert using langtag_table and script_table.
if name.len() == 7 && name.as_bytes()[2] == b'-' {
if let Some((_, unixy)) = LANGTAG_TABLE.iter().find(|(l, _)| *l == name) {
return (*unixy).to_owned();
}
// `name.get(3..)` is None only when byte 3 is not a char boundary,
// in which case no script entry can match.
if let Some(script) = name.get(3..)
&& let Some((_, unixy)) = SCRIPT_TABLE.iter().find(|(s, _)| *s == script)
{
// name[2] = '@'; strcpy (name + 3, unixy)
return format!("{}@{unixy}", &name[..2]);
}
}
// Step 3: Convert new-style dash to Unix underscore.
name.replace('-', "_")
} }
/// Handles some zh locales canonicalization because internal libintl /// Handles some zh locales canonicalization because internal libintl
@@ -183,6 +206,37 @@ mod tests {
assert_eq!("en_US.UTF_8", canonicalize_locale("en_US.UTF-8")); assert_eq!("en_US.UTF_8", canonicalize_locale("en_US.UTF-8"));
} }
#[test]
fn canonicalize_locale_libintl_tables() {
// langtag_table
assert_eq!("pa_IN", canonicalize_locale("pa-Guru"));
assert_eq!("pa_PK", canonicalize_locale("pa-Arab"));
assert_eq!("az", canonicalize_locale("az-Latn"));
assert_eq!("sr", canonicalize_locale("sr-Cyrl"));
assert_eq!("kk", canonicalize_locale("kk-Cyrl"));
assert_eq!("ga", canonicalize_locale("ga-dots"));
// 8 bytes long: the `strlen == 7` guard skips the tables (upstream quirk).
assert_eq!("yue_Hans", canonicalize_locale("yue-Hans"));
// script_table (name[2] = '@')
assert_eq!("sr@latin", canonicalize_locale("sr-Latn"));
assert_eq!("uz@arabic", canonicalize_locale("uz-Arab"));
assert_eq!("mn@mongolian", canonicalize_locale("mn-Mong"));
// legacy_table (only for names starting uppercase)
assert_eq!("pt_BR", canonicalize_locale("Brazilian Portuguese"));
assert_eq!("zh_TW", canonicalize_locale("Traditional Chinese"));
assert_eq!("de", canonicalize_locale("German"));
assert_eq!("yi", canonicalize_locale("Yiddish"));
assert_eq!("english", canonicalize_locale("english"));
// dash fallback
assert_eq!("pt_BR", canonicalize_locale("pt-BR"));
assert_eq!("en_US", canonicalize_locale("en-US"));
// non-ASCII input of length 7 must not panic
assert_eq!(
"\u{e9}\u{e9}\u{e9}x",
canonicalize_locale("\u{e9}\u{e9}\u{e9}x")
);
}
#[test] #[test]
fn locales_map_and_static_locale() { fn locales_map_and_static_locale() {
assert!(is_supported_locale("zh_CN")); assert!(is_supported_locale("zh_CN"));
+167
View File
@@ -0,0 +1,167 @@
//! Tables for [`crate::i18n::canonicalize_locale`], transcribed from gnulib's
//! `localename-unsafe.c` (`gl_locale_name_canonicalize`, Mac OS X flavour) as
//! bundled in gettext (`_libintl_locale_name_canonicalize`).
/// Legacy (NeXTstep inherited) Mac OS X English names to Unix names
/// (gnulib `localename-unsafe.c`, `legacy_table`).
pub(crate) const LEGACY_TABLE: [(&str, &str); 131] = [
("Afrikaans", "af"),
("Albanian", "sq"),
("Amharic", "am"),
("Arabic", "ar"),
("Armenian", "hy"),
("Assamese", "as"),
("Aymara", "ay"),
("Azerbaijani", "az"),
("Basque", "eu"),
("Belarusian", "be"),
("Belorussian", "be"),
("Bengali", "bn"),
("Brazilian Portugese", "pt_BR"),
("Brazilian Portuguese", "pt_BR"),
("Breton", "br"),
("Bulgarian", "bg"),
("Burmese", "my"),
("Byelorussian", "be"),
("Catalan", "ca"),
("Chewa", "ny"),
("Chichewa", "ny"),
("Chinese", "zh"),
("Chinese, Simplified", "zh_CN"),
("Chinese, Traditional", "zh_TW"),
("Chinese, Tradtional", "zh_TW"),
("Croatian", "hr"),
("Czech", "cs"),
("Danish", "da"),
("Dutch", "nl"),
("Dzongkha", "dz"),
("English", "en"),
("Esperanto", "eo"),
("Estonian", "et"),
("Faroese", "fo"),
("Farsi", "fa"),
("Finnish", "fi"),
("Flemish", "nl_BE"),
("French", "fr"),
("Galician", "gl"),
("Gallegan", "gl"),
("Georgian", "ka"),
("German", "de"),
("Greek", "el"),
("Greenlandic", "kl"),
("Guarani", "gn"),
("Gujarati", "gu"),
("Hawaiian", "haw"),
("Hebrew", "he"),
("Hindi", "hi"),
("Hungarian", "hu"),
("Icelandic", "is"),
("Indonesian", "id"),
("Inuktitut", "iu"),
("Irish", "ga"),
("Italian", "it"),
("Japanese", "ja"),
("Javanese", "jv"),
("Kalaallisut", "kl"),
("Kannada", "kn"),
("Kashmiri", "ks"),
("Kazakh", "kk"),
("Khmer", "km"),
("Kinyarwanda", "rw"),
("Kirghiz", "ky"),
("Korean", "ko"),
("Kurdish", "ku"),
("Latin", "la"),
("Latvian", "lv"),
("Lithuanian", "lt"),
("Macedonian", "mk"),
("Malagasy", "mg"),
("Malay", "ms"),
("Malayalam", "ml"),
("Maltese", "mt"),
("Manx", "gv"),
("Marathi", "mr"),
("Moldavian", "mo"),
("Mongolian", "mn"),
("Nepali", "ne"),
("Norwegian", "nb"),
("Nyanja", "ny"),
("Nynorsk", "nn"),
("Oriya", "or"),
("Oromo", "om"),
("Panjabi", "pa"),
("Pashto", "ps"),
("Persian", "fa"),
("Polish", "pl"),
("Portuguese", "pt"),
("Portuguese, Brazilian", "pt_BR"),
("Punjabi", "pa"),
("Pushto", "ps"),
("Quechua", "qu"),
("Romanian", "ro"),
("Ruanda", "rw"),
("Rundi", "rn"),
("Russian", "ru"),
("Sami", "se_NO"),
("Sanskrit", "sa"),
("Scottish", "gd"),
("Serbian", "sr"),
("Simplified Chinese", "zh_CN"),
("Sindhi", "sd"),
("Sinhalese", "si"),
("Slovak", "sk"),
("Slovenian", "sl"),
("Somali", "so"),
("Spanish", "es"),
("Sundanese", "su"),
("Swahili", "sw"),
("Swedish", "sv"),
("Tagalog", "tl"),
("Tajik", "tg"),
("Tajiki", "tg"),
("Tamil", "ta"),
("Tatar", "tt"),
("Telugu", "te"),
("Thai", "th"),
("Tibetan", "bo"),
("Tigrinya", "ti"),
("Tongan", "to"),
("Traditional Chinese", "zh_TW"),
("Turkish", "tr"),
("Turkmen", "tk"),
("Uighur", "ug"),
("Ukrainian", "uk"),
("Urdu", "ur"),
("Uzbek", "uz"),
("Vietnamese", "vi"),
("Welsh", "cy"),
("Yiddish", "yi"),
];
/// New-style language tags (ISO 639 + ISO 15924) to Unix names (`langtag_table`).
pub(crate) const LANGTAG_TABLE: [(&str, &str); 16] = [
("az-Latn", "az"),
("bs-Latn", "bs"),
("ga-dots", "ga"),
("kk-Cyrl", "kk"),
("mn-Cyrl", "mn"),
("ms-Latn", "ms"),
("pa-Arab", "pa_PK"),
("pa-Guru", "pa_IN"),
("sr-Cyrl", "sr"),
("tg-Cyrl", "tg"),
("tk-Cyrl", "tk"),
("tt-Cyrl", "tt"),
("uz-Latn", "uz"),
("yue-Hans", "yue"),
("zh-Hans", "zh_CN"),
("zh-Hant", "zh_TW"),
];
/// Script names (ISO 15924) to Unix conventions (`script_table`).
pub(crate) const SCRIPT_TABLE: [(&str, &str); 4] = [
("Arab", "arabic"),
("Cyrl", "cyrillic"),
("Latn", "latin"),
("Mong", "mongolian"),
];
+19 -3
View File
@@ -30,6 +30,7 @@ pub mod file;
pub mod file_type; pub mod file_type;
#[cfg(unix)] #[cfg(unix)]
pub mod flatpak; pub mod flatpak;
pub mod osbytes;
#[cfg(windows)] #[cfg(windows)]
pub mod flatpak { pub mod flatpak {
//! Flatpak does not exist on Windows. //! Flatpak does not exist on Windows.
@@ -45,6 +46,8 @@ pub mod homedir;
pub mod hostname; pub mod hostname;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub mod i18n; pub mod i18n;
#[cfg(not(target_arch = "wasm32"))]
mod i18n_canon_table;
pub mod i18n_locales; pub mod i18n_locales;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub mod kernel_info; pub mod kernel_info;
@@ -73,10 +76,23 @@ pub mod passwd {
pub name: Option<String>, pub name: Option<String>,
} }
/// Byte-preserving entry (same shape as the Unix one).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EntryOs {
pub shell: Option<std::ffi::OsString>,
pub home: Option<std::ffi::OsString>,
pub name: Option<std::ffi::OsString>,
}
/// No passwd database on Windows: always an empty entry. /// No passwd database on Windows: always an empty entry.
pub fn get() -> std::io::Result<Entry> { pub fn get() -> std::io::Result<Entry> {
Ok(Entry::default()) Ok(Entry::default())
} }
/// See [`get`].
pub fn get_os() -> std::io::Result<EntryOs> {
Ok(EntryOs::default())
}
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub mod path; pub mod path;
@@ -123,13 +139,13 @@ pub use env::{
get_env_map, get_env_map,
getenv, getenv,
getenv_not_empty, getenv_not_empty,
getenv_not_empty_os,
getenv_os,
prepend_env, prepend_env,
prepend_env_os, prepend_env_os,
}; };
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub use file::alloc_tmp_dir; pub use file::{Rlimit, alloc_tmp_dir, alloc_tmp_dir_os, fix_max_files, restore_max_files};
#[cfg(unix)]
pub use file::{Rlimit, fix_max_files, restore_max_files};
#[cfg(unix)] #[cfg(unix)]
pub use flatpak::FlatpakHostCommand; pub use flatpak::FlatpakHostCommand;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
+224
View File
@@ -0,0 +1,224 @@
//! Byte-preserving conversions between OS strings and Rust `String`s.
//!
//! Zig treats paths, arguments and environment values as byte slices. Rust
//! `String` cannot hold invalid UTF-8, so the String based layers (the config
//! system) use a reversible encoding that is a true bijection on byte
//! strings:
//!
//! * the escape character is the Unicode noncharacter `U+FDD0` (never used
//! in real text);
//! * every byte that is not part of a valid UTF-8 sequence (always
//! `0x80..=0xFF`) is encoded as `U+FDD0` followed by the char `U+00bb`
//! (`bb` = the byte), e.g. `0xFF` becomes `U+FDD0 U+00FF`;
//! * a literal `U+FDD0` in valid input is escaped as `U+FDD0 U+FDD0`.
//!
//! Hence every valid UTF-8 string without `U+FDD0` is encoded unchanged, and
//! `config_str_to_bytes(bytes_to_config_string(x)) == x` for all byte strings.
use std::ffi::{OsStr, OsString};
const ESC: char = '\u{FDD0}';
/// Encode arbitrary bytes into a `String`, escaping invalid UTF-8 bytes.
pub fn bytes_to_config_string(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len());
for chunk in bytes.utf8_chunks() {
for c in chunk.valid().chars() {
out.push(c);
if c == ESC {
out.push(ESC);
}
}
for &b in chunk.invalid() {
out.push(ESC);
out.push(char::from(b));
}
}
out
}
/// Inverse of [`bytes_to_config_string`].
pub fn config_str_to_bytes(s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == ESC {
match chars.peek().copied() {
Some(ESC) => {
chars.next();
}
Some(n) if (0x80..=0xFF).contains(&(n as u32)) => {
chars.next();
out.push(n as u32 as u8);
continue;
}
// Not produced by the encoder: keep the char as-is.
_ => {}
}
}
let mut buf = [0u8; 4];
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
out
}
/// The raw bytes of an OS string (Unix; lossless). On other platforms the
/// string is converted via its UTF-8 (WTF-8) form.
#[cfg(unix)]
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
use std::os::unix::ffi::OsStrExt;
s.as_bytes().to_vec()
}
#[cfg(not(unix))]
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
s.to_string_lossy().into_owned().into_bytes()
}
/// Build an OS string from raw bytes.
#[cfg(unix)]
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
use std::os::unix::ffi::OsStringExt;
OsString::from_vec(b)
}
#[cfg(not(unix))]
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
OsString::from(String::from_utf8_lossy(&b).into_owned())
}
/// OS string to reversible `String` (see the module docs).
pub fn os_to_config_string(s: &OsStr) -> String {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
bytes_to_config_string(s.as_bytes())
}
#[cfg(not(unix))]
{
s.to_string_lossy().into_owned()
}
}
/// Inverse of [`os_to_config_string`].
pub fn config_str_to_os(s: &str) -> OsString {
os_from_bytes(config_str_to_bytes(s))
}
/// Path to reversible `String` (see the module docs).
pub fn path_to_config_string(p: &std::path::Path) -> String {
os_to_config_string(p.as_os_str())
}
/// Inverse of [`path_to_config_string`].
pub fn config_str_to_path(s: &str) -> std::path::PathBuf {
std::path::PathBuf::from(config_str_to_os(s))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_invalid_bytes() {
let raw = vec![b'a', 0xff, b'b', 0xc3, 0xa9, 0xc3];
let s = bytes_to_config_string(&raw);
assert_eq!(s, "a\u{fdd0}\u{ff}b\u{e9}\u{fdd0}\u{c3}");
assert_eq!(config_str_to_bytes(&s), raw);
}
#[test]
fn valid_utf8_unchanged() {
let s = "h\u{e9}llo \u{1f600}";
assert_eq!(bytes_to_config_string(s.as_bytes()), s);
assert_eq!(config_str_to_bytes(s), s.as_bytes());
// Nerd Font icons, including the old escape range, are untouched.
let icons = "\u{f500}\u{f780}\u{f7ff}\u{fd46}\u{e0b0}";
assert_eq!(bytes_to_config_string(icons.as_bytes()), icons);
assert_eq!(config_str_to_bytes(icons), icons.as_bytes());
}
#[test]
fn literal_escape_char_is_doubled() {
let s = "a\u{fdd0}\u{ff}";
let e = bytes_to_config_string(s.as_bytes());
assert_eq!(e, "a\u{fdd0}\u{fdd0}\u{ff}");
assert_eq!(config_str_to_bytes(&e), s.as_bytes());
}
/// Tiny xorshift generator (no external rand dependency).
fn rng(seed: &mut u64) -> u64 {
*seed ^= *seed << 13;
*seed ^= *seed >> 7;
*seed ^= *seed << 17;
*seed
}
#[test]
fn property_bytes_round_trip() {
// Fragments that stress the boundaries: icon ranges, the escape
// char, its doubled form, and truncated/invalid sequences.
let frags: [&[u8]; 12] = [
"\u{f780}".as_bytes(),
"\u{f7ff}".as_bytes(),
"\u{f500}".as_bytes(),
"\u{fd46}".as_bytes(),
"\u{fdd0}".as_bytes(),
"\u{fdd0}\u{fdd0}".as_bytes(),
"\u{fdd0}\u{ff}".as_bytes(),
"\u{1f600}".as_bytes(),
b"\xef\xb7",
b"\xff",
b"\xc3",
b"abc",
];
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
for _ in 0..5000 {
let mut x = Vec::new();
for _ in 0..(rng(&mut seed) % 12) {
match rng(&mut seed) % 3 {
0 => x.push(rng(&mut seed) as u8),
_ => x.extend_from_slice(frags[(rng(&mut seed) % 12) as usize]),
}
}
let enc = bytes_to_config_string(&x);
assert_eq!(config_str_to_bytes(&enc), x, "bytes {x:?}");
if let Ok(valid) = std::str::from_utf8(&x)
&& !valid.contains('\u{fdd0}')
{
assert_eq!(enc, valid);
}
}
}
#[test]
fn property_valid_strings_round_trip() {
// Every string (valid UTF-8) decodes back to itself after encoding.
let mut seed = 0x1234_5678_9ABC_DEF1u64;
for _ in 0..5000 {
let mut s = String::new();
for _ in 0..(rng(&mut seed) % 10) {
let cp = match rng(&mut seed) % 4 {
0 => 0xF500 + (rng(&mut seed) % 0x847) as u32,
1 => 0xFDD0,
2 => 0x80 + (rng(&mut seed) % 0x80) as u32,
_ => 0x20 + (rng(&mut seed) % 0x5f) as u32,
};
s.push(char::from_u32(cp).unwrap());
}
let enc = bytes_to_config_string(s.as_bytes());
assert_eq!(config_str_to_bytes(&enc), s.as_bytes(), "{s:?}");
if !s.contains('\u{fdd0}') {
assert_eq!(enc, s);
}
}
}
#[cfg(unix)]
#[test]
fn os_roundtrip() {
let o = os_from_bytes(vec![0xff, b'/', 0x80]);
let s = os_to_config_string(&o);
assert_eq!(config_str_to_os(&s), o);
}
}
+62 -18
View File
@@ -1,5 +1,8 @@
use std::ffi::OsString;
#[cfg(any(target_os = "linux", target_os = "android"))] #[cfg(any(target_os = "linux", target_os = "android"))]
use crate::flatpak::is_flatpak; use crate::flatpak::is_flatpak;
use crate::osbytes;
/// Entry that is retrieved from the passwd API. This only contains the fields /// Entry that is retrieved from the passwd API. This only contains the fields
/// we care about. /// we care about.
@@ -10,19 +13,45 @@ pub struct Entry {
pub name: Option<String>, pub name: Option<String>,
} }
/// Get the passwd entry for the currently executing user. /// Byte-preserving [`Entry`] (Zig slices are arbitrary bytes).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EntryOs {
pub shell: Option<OsString>,
pub home: Option<OsString>,
pub name: Option<OsString>,
}
impl From<EntryOs> for Entry {
fn from(e: EntryOs) -> Self {
let f = |v: Option<OsString>| v.map(|v| osbytes::os_to_config_string(&v));
Entry {
shell: f(e.shell),
home: f(e.home),
name: f(e.name),
}
}
}
/// Get the passwd entry for the currently executing user. Non-UTF-8 bytes
/// are carried via the reversible [`crate::osbytes`] encoding; see
/// [`get_os`] for the raw bytes.
pub fn get() -> std::io::Result<Entry> { pub fn get() -> std::io::Result<Entry> {
get_os().map(Entry::from)
}
/// Byte-preserving [`get`].
pub fn get_os() -> std::io::Result<EntryOs> {
let uid = nix::unistd::getuid(); let uid = nix::unistd::getuid();
let user = match nix::unistd::User::from_uid(uid) { let user = match nix::unistd::User::from_uid(uid) {
Ok(Some(user)) => user, Ok(Some(user)) => user,
Ok(None) => { Ok(None) => {
// Future: let's check if a better shell is available like zsh // Future: let's check if a better shell is available like zsh
log::warn!("no pw entry to detect default shell, will default to 'sh'"); log::warn!("no pw entry to detect default shell, will default to 'sh'");
return Ok(Entry::default()); return Ok(EntryOs::default());
} }
Err(e) => { Err(e) => {
log::warn!("error retrieving pw entry code={e}"); log::warn!("error retrieving pw entry code={e}");
return Ok(Entry::default()); return Ok(EntryOs::default());
} }
}; };
@@ -35,10 +64,10 @@ pub fn get() -> std::io::Result<Entry> {
return host_entry(&user.name); return host_entry(&user.name);
} }
Ok(Entry { Ok(EntryOs {
shell: Some(user.shell.to_string_lossy().into_owned()), shell: Some(user.shell.into_os_string()),
home: Some(user.dir.to_string_lossy().into_owned()), home: Some(user.dir.into_os_string()),
name: Some(user.name), name: Some(OsString::from(user.name)),
}) })
} }
@@ -49,7 +78,7 @@ pub fn get() -> std::io::Result<Entry> {
/// `Pty`) depends on this crate, so a minimal openpt/ptsname pair is opened /// `Pty`) depends on this crate, so a minimal openpt/ptsname pair is opened
/// here with rustix instead. Like Zig, only `shell` and `home` are filled in. /// here with rustix instead. Like Zig, only `shell` and `home` are filled in.
#[cfg(any(target_os = "linux", target_os = "android"))] #[cfg(any(target_os = "linux", target_os = "android"))]
fn host_entry(name: &str) -> std::io::Result<Entry> { fn host_entry(name: &str) -> std::io::Result<EntryOs> {
use std::io::Read; use std::io::Read;
use rustix::{ use rustix::{
@@ -112,17 +141,20 @@ fn host_entry(name: &str) -> std::io::Result<Entry> {
} }
} }
Ok(parse_getent(&String::from_utf8_lossy(&output))) Ok(parse_getent(&output))
} }
#[cfg(any(target_os = "linux", target_os = "android"))]
/// Shell and home are the last two `:`-separated fields of the output. /// Shell and home are the last two `:`-separated fields of the output.
#[cfg(any(target_os = "linux", target_os = "android"))] #[cfg(any(target_os = "linux", target_os = "android"))]
fn parse_getent(output: &str) -> Entry { fn parse_getent(output: &[u8]) -> EntryOs {
let mut it = output.trim_end_matches([' ', '\r', '\n']).rsplit(':'); let end = output
let shell = it.next().map(str::to_owned); .iter()
let home = it.next().map(str::to_owned); .rposition(|b| !matches!(b, b' ' | b'\r' | b'\n'))
Entry { .map_or(0, |i| i + 1);
let mut it = output[..end].rsplit(|&b| b == b':');
let shell = it.next().map(|b| osbytes::os_from_bytes(b.to_vec()));
let home = it.next().map(|b| osbytes::os_from_bytes(b.to_vec()));
EntryOs {
shell, shell,
home, home,
name: None, name: None,
@@ -136,12 +168,24 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "android"))] #[cfg(any(target_os = "linux", target_os = "android"))]
#[test] #[test]
fn parse_getent_output() { fn parse_getent_output() {
let e = parse_getent("mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n"); let e = parse_getent(b"mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n");
assert_eq!(e.shell.as_deref(), Some("/bin/zsh")); assert_eq!(e.shell.as_deref(), Some(std::ffi::OsStr::new("/bin/zsh")));
assert_eq!(e.home.as_deref(), Some("/home/mitchell")); assert_eq!(
e.home.as_deref(),
Some(std::ffi::OsStr::new("/home/mitchell"))
);
assert_eq!(e.name, None); assert_eq!(e.name, None);
} }
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn parse_getent_keeps_non_utf8() {
use std::os::unix::ffi::OsStrExt;
let e = parse_getent(b"u:x:1:1::/home/\xff:/bin/\xfesh\n");
assert_eq!(e.shell.unwrap().as_bytes(), b"/bin/\xfesh");
assert_eq!(e.home.unwrap().as_bytes(), b"/home/\xff");
}
#[test] #[test]
fn get_entry() { fn get_entry() {
// We should be able to get an entry // We should be able to get an entry
+43 -8
View File
@@ -1,6 +1,6 @@
use std::io;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::{ffi::OsStr, io, path::PathBuf};
/// `std.fs.path.delimiter` / `sep`. /// `std.fs.path.delimiter` / `sep`.
#[cfg(windows)] #[cfg(windows)]
@@ -25,28 +25,44 @@ pub enum Error {
Io(#[from] io::Error), Io(#[from] io::Error),
} }
/// Search for "cmd" in the PATH and return the absolute path. /// Search for "cmd" in the PATH and return the absolute path. Non-UTF-8
/// bytes are carried via [`crate::osbytes`]; see [`expand_os`].
pub fn expand(cmd: &str) -> Result<Option<String>, Error> { pub fn expand(cmd: &str) -> Result<Option<String>, Error> {
let cmd = crate::osbytes::config_str_to_os(cmd);
Ok(expand_os(&cmd)?.map(|p| crate::osbytes::os_to_config_string(p.as_os_str())))
}
/// Byte-preserving [`expand`]: `$PATH` and the command are handled as raw
/// bytes, so executables in directories with non-UTF-8 names are found.
pub fn expand_os(cmd: &OsStr) -> Result<Option<PathBuf>, Error> {
let cmd_bytes = crate::osbytes::os_bytes(cmd);
// If the command already contains a slash, then we return it as-is // If the command already contains a slash, then we return it as-is
// because it is assumed to be absolute or relative. // because it is assumed to be absolute or relative.
if cmd.contains('/') { if cmd_bytes.contains(&b'/') {
return Ok(Some(cmd.to_owned())); return Ok(Some(PathBuf::from(cmd)));
} }
let Some(path_var) = std::env::var_os("PATH") else { let Some(path_var) = std::env::var_os("PATH") else {
return Ok(None); return Ok(None);
}; };
let path_var = path_var.to_string_lossy(); let path_var = crate::osbytes::os_bytes(&path_var);
let mut seen_eacces = false; let mut seen_eacces = false;
for search_path in path_var.split(DELIMITER).filter(|s| !s.is_empty()) { for search_path in path_var
.split(|&b| b == DELIMITER as u8)
.filter(|s| !s.is_empty())
{
// We need enough space in our path buffer to store this // We need enough space in our path buffer to store this
let path_len = search_path.len() + cmd.len() + 1; let path_len = search_path.len() + cmd_bytes.len() + 1;
if MAX_PATH_BYTES < path_len { if MAX_PATH_BYTES < path_len {
return Err(Error::PathTooLong); return Err(Error::PathTooLong);
} }
let full_path = format!("{search_path}{SEP}{cmd}"); let mut full = search_path.to_vec();
full.push(SEP as u8);
full.extend_from_slice(&cmd_bytes);
let full_path = PathBuf::from(crate::osbytes::os_from_bytes(full));
// Stat it // Stat it
let f = match std::fs::File::open(&full_path) { let f = match std::fs::File::open(&full_path) {
@@ -127,4 +143,23 @@ mod tests {
assert!(expand("adir").unwrap().is_none()); assert!(expand("adir").unwrap().is_none());
}); });
} }
#[cfg(unix)]
#[test]
fn expand_finds_executable_in_non_utf8_dir() {
use std::os::unix::{
ffi::{OsStrExt, OsStringExt},
fs::PermissionsExt,
};
let td = crate::TempDir::init().unwrap();
let dir = td.path().join(OsStr::from_bytes(b"d\xffir"));
std::fs::create_dir(&dir).unwrap();
let exe = dir.join("mytool");
std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
let pv = std::ffi::OsString::from_vec(dir.as_os_str().as_bytes().to_vec());
temp_env::with_var("PATH", Some(&pv), || {
assert_eq!(expand_os(OsStr::new("mytool")).unwrap(), Some(exe.clone()));
});
}
} }
+59 -17
View File
@@ -1,6 +1,14 @@
use std::{io, path::Path}; use std::{
ffi::OsString,
io,
path::{Path, PathBuf},
};
use crate::path_join; use crate::osbytes::{config_str_to_os, os_to_config_string};
fn enc(p: &Path) -> String {
os_to_config_string(p.as_os_str())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourcesDir { pub struct ResourcesDir {
@@ -25,6 +33,17 @@ impl ResourcesDir {
pub fn host(&self) -> Option<&str> { pub fn host(&self) -> Option<&str> {
self.host_path.as_deref().or(self.app_path.as_deref()) self.host_path.as_deref().or(self.app_path.as_deref())
} }
/// Byte-preserving [`ResourcesDir::app`]: the string fields carry
/// non-UTF-8 bytes via [`crate::osbytes`], this decodes them.
pub fn app_os(&self) -> Option<PathBuf> {
self.app().map(|p| PathBuf::from(config_str_to_os(p)))
}
/// Byte-preserving [`ResourcesDir::host`].
pub fn host_os(&self) -> Option<PathBuf> {
self.host().map(|p| PathBuf::from(config_str_to_os(p)))
}
} }
/// Sentinels we look for in the path to know we've found the resources /// Sentinels we look for in the path to know we've found the resources
@@ -50,7 +69,7 @@ const DEV_SENTINELS: [&str; 1] = ["terminfo/x/xterm-ghostty"];
/// Zig's `builtin.mode == .Debug` is mapped to `cfg!(debug_assertions)`. /// Zig's `builtin.mode == .Debug` is mapped to `cfg!(debug_assertions)`.
pub fn resources_dir() -> io::Result<ResourcesDir> { pub fn resources_dir() -> io::Result<ResourcesDir> {
let exe = std::env::current_exe().ok(); let exe = std::env::current_exe().ok();
let env = std::env::var("GHOSTTY_RESOURCES_DIR").ok(); let env = std::env::var_os("GHOSTTY_RESOURCES_DIR");
Ok(resources_dir_impl( Ok(resources_dir_impl(
cfg!(debug_assertions), cfg!(debug_assertions),
exe.as_deref(), exe.as_deref(),
@@ -58,7 +77,7 @@ pub fn resources_dir() -> io::Result<ResourcesDir> {
)) ))
} }
fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> ResourcesDir { fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<OsString>) -> ResourcesDir {
// Use the GHOSTTY_RESOURCES_DIR environment variable in release builds. // Use the GHOSTTY_RESOURCES_DIR environment variable in release builds.
// //
// In debug builds we try using terminfo detection first instead, since // In debug builds we try using terminfo detection first instead, since
@@ -67,7 +86,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
// freshly built ones under zig-out/share/ghostty. // freshly built ones under zig-out/share/ghostty.
if !debug && let Some(dir) = env.as_ref().filter(|d| !d.is_empty()) { if !debug && let Some(dir) = env.as_ref().filter(|d| !d.is_empty()) {
return ResourcesDir { return ResourcesDir {
app_path: Some(dir.clone()), app_path: Some(os_to_config_string(dir)),
host_path: None, host_path: None,
}; };
} }
@@ -82,14 +101,12 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
// bundle as we expect it. // bundle as we expect it.
while let Some(dir) = exe.parent() { while let Some(dir) = exe.parent() {
exe = dir; exe = dir;
let dir_str = dir.to_string_lossy();
// On MacOS, we look for the app bundle path. // On MacOS, we look for the app bundle path.
#[cfg(target_vendor = "apple")] #[cfg(target_vendor = "apple")]
for sentinel in SENTINELS { for sentinel in SENTINELS {
if let Some(v) = maybe_dir(&dir_str, "Contents/Resources", sentinel) { if let Some(v) = maybe_dir_os(dir, "Contents/Resources", sentinel) {
return ResourcesDir { return ResourcesDir {
app_path: Some(path_join(&[&v, "ghostty"])), app_path: Some(enc(&v.join("ghostty"))),
host_path: None, host_path: None,
}; };
} }
@@ -97,9 +114,9 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
// We look for a /usr/share style path. // We look for a /usr/share style path.
for sentinel in SENTINELS { for sentinel in SENTINELS {
if let Some(v) = maybe_dir(&dir_str, "share", sentinel) { if let Some(v) = maybe_dir_os(dir, "share", sentinel) {
return ResourcesDir { return ResourcesDir {
app_path: Some(path_join(&[&v, "ghostty"])), app_path: Some(enc(&v.join("ghostty"))),
host_path: None, host_path: None,
}; };
} }
@@ -110,7 +127,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
// fallback and use the provided resources dir. // fallback and use the provided resources dir.
if debug && let Some(dir) = env.filter(|d| !d.is_empty()) { if debug && let Some(dir) = env.filter(|d| !d.is_empty()) {
return ResourcesDir { return ResourcesDir {
app_path: Some(dir), app_path: Some(os_to_config_string(&dir)),
host_path: None, host_path: None,
}; };
} }
@@ -122,7 +139,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
// lives inside this workspace. // lives inside this workspace.
if debug && let Some(dir) = dev_resources_dir(exe_start) { if debug && let Some(dir) = dev_resources_dir(exe_start) {
return ResourcesDir { return ResourcesDir {
app_path: Some(dir), app_path: Some(enc(&dir)),
host_path: None, host_path: None,
}; };
} }
@@ -137,7 +154,7 @@ fn workspace_root() -> std::path::PathBuf {
/// Returns `<workspace>/resources` if `exe` is inside the workspace this /// Returns `<workspace>/resources` if `exe` is inside the workspace this
/// crate was built from and the directory contains our sentinel. /// crate was built from and the directory contains our sentinel.
fn dev_resources_dir(exe: &Path) -> Option<String> { fn dev_resources_dir(exe: &Path) -> Option<PathBuf> {
let root = workspace_root().canonicalize().ok()?; let root = workspace_root().canonicalize().ok()?;
let exe = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf()); let exe = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
if !exe.starts_with(&root) { if !exe.starts_with(&root) {
@@ -148,7 +165,15 @@ fn dev_resources_dir(exe: &Path) -> Option<String> {
.iter() .iter()
.chain(DEV_SENTINELS.iter()) .chain(DEV_SENTINELS.iter())
.any(|s| res.join(s).exists()) .any(|s| res.join(s).exists())
.then(|| res.to_string_lossy().into_owned()) .then_some(res)
}
/// Byte-preserving [`maybe_dir`].
pub fn maybe_dir_os(base: &Path, sub: &str, suffix: &str) -> Option<PathBuf> {
let dir = base.join(sub);
// Folder doesn't exist. If a different error happens its okay
// we just ignore it and move on.
dir.join(suffix).exists().then_some(dir)
} }
/// Little helper to check if the "base/sub/suffix" directory exists and /// Little helper to check if the "base/sub/suffix" directory exists and
@@ -204,7 +229,7 @@ mod tests {
fn env_in_release_wins_and_in_debug_is_fallback() { fn env_in_release_wins_and_in_debug_is_fallback() {
let td = make_tree(); let td = make_tree();
let exe = td.path().join("bin/ghostty"); let exe = td.path().join("bin/ghostty");
let env = Some("/env/res".to_owned()); let env = Some(OsString::from("/env/res"));
assert_eq!( assert_eq!(
resources_dir_impl(false, Some(&exe), env.clone()).app(), resources_dir_impl(false, Some(&exe), env.clone()).app(),
Some("/env/res") Some("/env/res")
@@ -220,7 +245,7 @@ mod tests {
); );
// Empty env is ignored. // Empty env is ignored.
assert_eq!( assert_eq!(
resources_dir_impl(false, None, Some(String::new())).app(), resources_dir_impl(false, None, Some(OsString::new())).app(),
None None
); );
} }
@@ -238,6 +263,23 @@ mod tests {
assert_eq!(r.app(), None); assert_eq!(r.app(), None);
} }
#[test]
fn non_utf8_env_dir_is_honored_and_exe_dir_detected() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let env = OsString::from_vec(b"/env/\xffres".to_vec());
let r = resources_dir_impl(false, None, Some(env.clone()));
assert_eq!(r.app_os().unwrap().as_os_str(), env.as_os_str());
// Executable lives under a directory with a non-UTF-8 name.
let td = crate::TempDir::init().unwrap();
let root = td.path().join(std::ffi::OsStr::from_bytes(b"r\xfeoot"));
std::fs::create_dir_all(root.join("share/terminfo/x")).unwrap();
std::fs::write(root.join("share/terminfo/x/xterm-ghostty"), b"").unwrap();
std::fs::create_dir_all(root.join("bin")).unwrap();
let r = resources_dir_impl(true, Some(&root.join("bin/ghostty")), None);
assert_eq!(r.app_os().unwrap(), root.join("share/ghostty"));
}
#[test] #[test]
fn maybe_dir_checks_suffix() { fn maybe_dir_checks_suffix() {
let td = make_tree(); let td = make_tree();
+3 -3
View File
@@ -8,7 +8,7 @@ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use crate::file::alloc_tmp_dir; use crate::file::alloc_tmp_dir_os;
// The amount of random bytes to get to determine our filename. // The amount of random bytes to get to determine our filename.
const RANDOM_BYTES: usize = 16; const RANDOM_BYTES: usize = 16;
@@ -89,8 +89,8 @@ fn fill_random(buf: &mut [u8]) -> io::Result<()> {
impl TempDir { impl TempDir {
/// Create the temporary directory. /// Create the temporary directory.
pub fn init() -> io::Result<TempDir> { pub fn init() -> io::Result<TempDir> {
let parent = match alloc_tmp_dir() { let parent = match alloc_tmp_dir_os() {
Some(p) => PathBuf::from(p), Some(p) => p,
None => std::env::current_dir()?, None => std::env::current_dir()?,
}; };
// Verify the parent can be opened (Zig opens it as a Dir). // Verify the parent can be opened (Zig opens it as a Dir).
+79
View File
@@ -0,0 +1,79 @@
# Z1 final pass: os / config / cli / root main (byte fidelity, i18n, build info)
Owner: Z1. Crates: `ghostty-os`, `ghostty-config`, `ghostty-cli`, new `ghostty-build-info`, root `src/main.rs`.
## Byte fidelity design
Zig paths/args/env are byte slices. The config system is `&str`/`String` based
throughout, so rewriting it to a byte type was not feasible without changing
hundreds of `ConfigField` impls. Instead:
* `ghostty_os::osbytes` is the single conversion module:
* `bytes_to_config_string` / `config_str_to_bytes`, `os_to_config_string` /
`config_str_to_os`, `path_to_config_string` / `config_str_to_path`,
`os_bytes`, `os_from_bytes`.
* The encoding is a true bijection on byte strings. Escape char = noncharacter
`U+FDD0`; an invalid byte `b` becomes `U+FDD0` + char `U+00bb` (`0xFF` ->
`U+FDD0 U+00FF`); a literal `U+FDD0` in valid input becomes `U+FDD0 U+FDD0`.
Valid UTF-8 without `U+FDD0` is encoded unchanged (Nerd Font icons, incl.
`U+F500..U+FD46`, are safe), and `decode(encode(x)) == x` for all bytes.
Property tests (random bytes and strings, icon ranges, escape char) cover this.
* Every os function that returned `String` now has a byte-exact OS-typed twin
(`*_os`), and the `String` function is a thin wrapper using the reversible
encoding (a present non-UTF-8 value is never "absent" and never U+FFFD).
Existing callers keep compiling.
* The config/cli layers decode back to OS strings at every filesystem / exec /
stdout boundary they own.
## Audit rows fixed
| Row | Change |
|---|---|
| main_ghostty argv | `src/main.rs` collects `Vec<OsString>` (`os::args::collect_os`), dispatches with `ghostty_cli::ghostty::{detect_os, run_from_os_args}` (reversible encoding; no lossy). `collect_lossy` removed; `collect_bytes`, `collect_config_strings` added. |
| cli/args.zig | `ArgsIterator` via `args::collect_config_strings`; `LineIterator` no longer `from_utf8_lossy`; `parse_zig_string_literal_bytes` returns raw bytes (`\xFF` stays `0xFF`), `parse_zig_string_literal` returns the reversible string. |
| Config.zig | xdg-terminal-exec args and `cwd` keep bytes; `load_file`, config-file open, `load_reader` parent dir decode/encode paths. |
| os/env | `getenv_os`, `getenv_not_empty_os`; `getenv*` return `Some` for non-UTF-8 values. |
| os/homedir | `home_os`, `home_os_with_capacity`, `expand_home_os[_with_capacity]`; HOME, passwd and `cd && pwd` bytes kept (shell output trimmed as bytes). |
| os/path | `path::expand_os` ($PATH split and lookup as bytes). |
| os/passwd | `EntryOs`, `get_os` (native `shell`/`home`, flatpak `getent` output parsed as bytes). `Entry`/`get` unchanged shape (encoded strings). |
| os/resourcesdir | `GHOSTTY_RESOURCES_DIR` read via `var_os`; exe-relative probing uses `Path`s (`maybe_dir_os`); `ResourcesDir::{app_os, host_os}`. |
| os/file | `alloc_tmp_dir_os` (TMPDIR/TMP bytes, used by `TempDir`); `Rlimit` (unit struct), `fix_max_files` (-> `None`), `restore_max_files` (no-op) now exist on non-Unix targets like Zig's fallbacks. |
| os/cgroup | `current_os`; `scope_matches` compares bytes. |
| config/path | canonicalization, `resolve_lexical`, `theme.rs` locations use decoded paths and keep canonical (symlink-resolved) non-UTF-8 targets. |
| cli/validate_config | canonicalizes the decoded option path and passes the reversible string to `load_file`. |
| cli/list_themes | directory entries keep raw bytes (names/paths), plain listing writes raw bytes, `read_dir` on decoded dir; preview auto-theme file/clipboard copy decode bytes. |
| cli edit_config / new_window | exec editor / canonical working directory keep bytes. |
| os/i18n | `canonicalize_locale` is now an exact port of gnulib `gl_locale_name_canonicalize` (macOS flavour used by `_libintl_locale_name_canonicalize`): legacy table (131 names), langtag table (16), script table (4), `strlen==7` guard, dash->underscore; tables in `i18n_canon_table.rs` generated from gettext 1.0's `localename-unsafe.c`. `pa-Guru` -> `pa_IN`, `sr-Latn` -> `sr@latin`, `Brazilian Portuguese` -> `pt_BR`. |
Not touched (other owners): `ghostty-lib` `ghostty_init`/`config_capi`/`embedded` lossy
conversions, GTK file drop, `Surface::pwd()` -- Z2. Z2 may use the `osbytes`
helpers and `*_os` functions; `Entry`/`ResourcesDir` string fields carry the
reversible encoding, so use `config_str_to_os` before using them as paths.
## Build info (single source of truth) -- Z1 owns, nobody else edits
New crate `crates/ghostty-build-info` (no deps, `build.rs` derived; moved out of
`ghostty-cli/build.rs`, which now only keeps the action help table and
`GHOSTTY_RUSTC_VERSION`). Added to `[workspace.dependencies]`.
```rust
ghostty_build_info::VERSION_STRING // &str "1.3.1" or "1.3.1-<branch>+<hash>"; env GHOSTTY_VERSION_STRING overrides
ghostty_build_info::COMMIT // Option<&str>
ghostty_build_info::RELEASE_CHANNEL // "stable" | "tip"
ghostty_build_info::BUILD_MODE // ".Debug" | ".ReleaseSafe" | ".ReleaseFast"
ghostty_build_info::{version(), parse(), SemanticVersion}
```
`ghostty_cli::build_info::Default` and `ghostty_cli::build_info::derived`
re-export/use it. For Z2: add `ghostty-build-info.workspace = true` to
termio/apprt-gtk/crash and use `VERSION_STRING` for XTVERSION
(`stream_handler.rs`), `TERM_PROGRAM_VERSION` (`exec*.rs`), the About dialog
(`window.rs`), and crash `release` (`sentry.rs`; `RELEASE_CHANNEL`/`COMMIT`
for tags).
## Tests added
`osbytes` (3), env non-UTF-8, homedir non-UTF-8, path non-UTF-8 dir lookup, passwd getent bytes,
resourcesdir non-UTF-8 env + exe dir, file tmpdir bytes, cgroup, i18n tables,
cli_args (`\xFF` raw byte, LineIterator), config path symlink to non-UTF-8 target,
validate_config symlink, list_themes non-UTF-8 entry, ghostty `os_args`, build-info (3).