diff --git a/crates/ghostty-build-info/Cargo.toml b/crates/ghostty-build-info/Cargo.toml new file mode 100644 index 0000000..0dc266e --- /dev/null +++ b/crates/ghostty-build-info/Cargo.toml @@ -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 diff --git a/crates/ghostty-build-info/build.rs b/crates/ghostty-build-info/build.rs new file mode 100644 index 0000000..93a89c0 --- /dev/null +++ b/crates/ghostty-build-info/build.rs @@ -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 { + 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::().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) { + 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()); +} diff --git a/crates/ghostty-build-info/src/lib.rs b/crates/ghostty-build-info/src/lib.rs new file mode 100644 index 0000000..694b95e --- /dev/null +++ b/crates/ghostty-build-info/src/lib.rs @@ -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> { + 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> { + 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); + } +} diff --git a/crates/ghostty-cli/Cargo.toml b/crates/ghostty-cli/Cargo.toml index 643863c..f6a32b9 100644 --- a/crates/ghostty-cli/Cargo.toml +++ b/crates/ghostty-cli/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] flate2 = "1.1.10" +ghostty-build-info.workspace = true ghostty-apprt.workspace = true ghostty-config.workspace = true ghostty-crash.workspace = true diff --git a/crates/ghostty-cli/build.rs b/crates/ghostty-cli/build.rs index 83c603e..e2bbd20 100644 --- a/crates/ghostty-cli/build.rs +++ b/crates/ghostty-cli/build.rs @@ -1,130 +1,15 @@ //! Build metadata for `+version` and the generated docs. //! //! * 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: -//! `-Dversion-string`), which must be a semantic version. +//! The app version, channel, commit and build mode live in +//! `ghostty-build-info` (single source of truth). use std::{ path::{Path, PathBuf}, process::Command, }; -fn run(cmd: &str, args: &[&str], dir: &Path) -> Option { - 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::().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) { - 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`): /// the doc comment of each action's `run` function becomes /// `Action::help_text`. @@ -175,20 +60,4 @@ fn main() { .and_then(|s| s.split_whitespace().nth(1).map(str::to_owned)) .unwrap_or_else(|| "unknown".into()); 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()); } diff --git a/crates/ghostty-cli/src/action.rs b/crates/ghostty-cli/src/action.rs index fdf8001..5ef856f 100644 --- a/crates/ghostty-cli/src/action.rs +++ b/crates/ghostty-cli/src/action.rs @@ -53,7 +53,7 @@ pub trait ActionEnum: Sized + Copy { /// Detect the action from the process CLI args (Zig: `detectArgs`). pub fn detect_args() -> Result, 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 diff --git a/crates/ghostty-cli/src/build_info.rs b/crates/ghostty-cli/src/build_info.rs index 489eb3c..bfb8bda 100644 --- a/crates/ghostty-cli/src/build_info.rs +++ b/crates/ghostty-cli/src/build_info.rs @@ -6,6 +6,10 @@ 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`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Version { @@ -49,11 +53,11 @@ pub struct BuildInfo { impl Default for BuildInfo { fn default() -> Self { BuildInfo { - // Derived by `build.rs` (git describe / workspace app-version). - version_string: env!("GHOSTTY_VERSION_STRING").to_string(), - commit: Some(env!("GHOSTTY_COMMIT").to_string()).filter(|c| !c.is_empty()), - release_channel: env!("GHOSTTY_RELEASE_CHANNEL").to_string(), - build_mode: env!("GHOSTTY_BUILD_MODE").to_string(), + // Derived by `ghostty-build-info` (single source of truth). + version_string: ghostty_build_info::VERSION_STRING.to_string(), + commit: ghostty_build_info::COMMIT.map(str::to_string), + release_channel: ghostty_build_info::RELEASE_CHANNEL.to_string(), + build_mode: ghostty_build_info::BUILD_MODE.to_string(), app_runtime: ".gtk".to_string(), font_backend: ".fontconfig_freetype".to_string(), renderer: "renderer.generic.Renderer(renderer.OpenGL)".to_string(), diff --git a/crates/ghostty-cli/src/edit_config.rs b/crates/ghostty-cli/src/edit_config.rs index a6ef00a..3efc05a 100644 --- a/crates/ghostty-cli/src/edit_config.rs +++ b/crates/ghostty-cli/src/edit_config.rs @@ -93,8 +93,7 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result { let Some(editor) = find_editor() else { // If we don't have `$EDITOR` set then we can't do anything // but we can still print a helpful message. - write!( - stderr, + let msg = String::from( "The $EDITOR or $VISUAL environment variable is not set or is empty.\n\ This environment variable is required to edit the Ghostty configuration\n\ via this CLI command.\n\ @@ -104,17 +103,20 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result { \n\ If you prefer to edit the configuration file another way,\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. - 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); }; // Flush before exec so no output is lost. 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. let err = match err.kind() { @@ -122,8 +124,7 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result { std::io::ErrorKind::PermissionDenied => "error.AccessDenied".to_string(), _ => err.to_string(), }; - write!( - stderr, + let msg = format!( "Failed to execute the editor. Error code={err}.\n\ \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 { \n\ Editor: {editor}\n\ Path: {path}\n" - )?; + ); + stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&msg))?; Ok(1) } diff --git a/crates/ghostty-cli/src/ghostty.rs b/crates/ghostty-cli/src/ghostty.rs index ee3b580..49e10f6 100644 --- a/crates/ghostty-cli/src/ghostty.rs +++ b/crates/ghostty-cli/src/ghostty.rs @@ -243,6 +243,28 @@ pub fn detect(args: &[String]) -> Result, DetectError> { detect_iter::(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 { + 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, 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 { + run_from_args(&os_args_to_strings(args)) +} + /// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the /// action from `args` (the full argv), print the errors Ghostty prints for /// 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] fn names_round_trip() { for a in Action::ALL { diff --git a/crates/ghostty-cli/src/list_themes.rs b/crates/ghostty-cli/src/list_themes.rs index 2957ea8..f9631ab 100644 --- a/crates/ghostty-cli/src/list_themes.rs +++ b/crates/ghostty-cli/src/list_themes.rs @@ -15,6 +15,12 @@ use ghostty_config::{ config_struct, theme::{Location, LocationIterator}, }; +use ghostty_os::osbytes::{ + config_str_to_bytes, + config_str_to_path, + os_to_config_string, + path_to_config_string, +}; use crate::{error::Error, tui}; @@ -121,7 +127,7 @@ pub fn collect_themes(resources_dir: Option<&str>) -> Vec { let mut themes = vec![]; let mut it = LocationIterator::new(resources_dir); while let Some((location, dir)) = it.next_location() { - let rd = match std::fs::read_dir(&dir) { + let rd = match std::fs::read_dir(config_str_to_path(&dir)) { Ok(rd) => rd, Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, Err(e) => { @@ -137,15 +143,14 @@ pub fn collect_themes(resources_dir: Option<&str>) -> Vec { if !(ft.is_file() || ft.is_symlink()) { 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" { continue; } themes.push(ThemeListElement { - path: std::path::Path::new(&dir) - .join(&name) - .to_string_lossy() - .into_owned(), + path: path_to_config_string(&config_str_to_path(&dir).join(&raw_name)), location, theme: name, 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). pub(crate) fn absolute_path(path: &str) -> String { - std::path::absolute(path) - .map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()) + std::path::absolute(config_str_to_path(path)) + .map_or_else(|_| path.to_string(), |p| path_to_config_string(&p)) } fn resources_dir() -> Option { @@ -273,11 +278,14 @@ pub fn write_plain( continue; } let loc = location_name(theme.location); - if opts.path { - writeln!(out, "{} ({loc}) {}", theme.theme, theme.path)?; + // Names/paths are raw bytes in Zig: decode the reversible escape so + // non-UTF-8 bytes are written back unchanged. + let line = if opts.path { + format!("{} ({loc}) {}\n", theme.theme, theme.path) } else { - writeln!(out, "{} ({loc})", theme.theme)?; - } + format!("{} ({loc})\n", theme.theme) + }; + out.write_all(&config_str_to_bytes(&line))?; } Ok(()) } @@ -297,6 +305,29 @@ mod tests { 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] fn collect_sort_and_filter() { let tmp = theme_dir(&[ diff --git a/crates/ghostty-cli/src/list_themes/preview.rs b/crates/ghostty-cli/src/list_themes/preview.rs index e87e739..7c99bd7 100644 --- a/crates/ghostty-cli/src/list_themes/preview.rs +++ b/crates/ghostty-cli/src/list_themes/preview.rs @@ -1408,14 +1408,13 @@ fn error_name(e: &ghostty_config::config::Error) -> String { pub fn resolve_auto_theme_path() -> Result { let main_cfg_path = ghostty_config::file_load::preferred_default_file_path() .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() .ok_or_else(|| Error::Other("BadPathName".to_string()))?; - Ok(base_dir - .join("auto") - .join("theme.ghostty") - .to_string_lossy() - .into_owned()) + Ok(ghostty_os::osbytes::path_to_config_string( + &base_dir.join("auto").join("theme.ghostty"), + )) } /// `writeAutoThemeFile`. @@ -1424,10 +1423,14 @@ pub fn write_auto_theme_file(path: Option<&str>, theme_name: &str) -> Result<(), Some(p) => p.to_string(), 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::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(()) } @@ -1734,7 +1737,11 @@ fn run_loop(app: &mut Preview) -> Result<(), Error> { match effect { Effect::Copy(text) => { let out = terminal.backend_mut(); - write!(out, "\x1b]52;c;{}\x1b\\", base64(text.as_bytes()))?; + write!( + out, + "\x1b]52;c;{}\x1b\\", + base64(&ghostty_os::osbytes::config_str_to_bytes(&text)) + )?; out.flush()?; } } diff --git a/crates/ghostty-cli/src/new_window.rs b/crates/ghostty-cli/src/new_window.rs index 792e575..b3b589f 100644 --- a/crates/ghostty-cli/src/new_window.rs +++ b/crates/ghostty-cli/src/new_window.rs @@ -59,11 +59,12 @@ impl Options { let expanded = ghostty_os::expand_home(stripped).map_err(|e| ArgError::Other(e.to_string()))?; 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; return Ok(Some(format!( "--working-directory={}", - realpath.to_string_lossy() + ghostty_os::osbytes::path_to_config_string(&realpath) ))); } @@ -192,8 +193,13 @@ pub fn run(args: &[String]) -> Result { if !opts.working_directory_seen { // This should be inserted at the beginning of the list, just in case `-e` was used. let wd = std::fs::canonicalize(".")?; - opts.arguments - .insert(0, format!("--working-directory={}", wd.to_string_lossy())); + opts.arguments.insert( + 0, + format!( + "--working-directory={}", + ghostty_os::osbytes::path_to_config_string(&wd) + ), + ); } let target = match opts.class { diff --git a/crates/ghostty-cli/src/validate_config.rs b/crates/ghostty-cli/src/validate_config.rs index f8d0fcc..1237843 100644 --- a/crates/ghostty-cli/src/validate_config.rs +++ b/crates/ghostty-cli/src/validate_config.rs @@ -39,8 +39,10 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result { // If a config path is passed, validate it, otherwise validate default configs if let Some(config_path) = &opts.config_file { - let abs_path = std::fs::canonicalize(config_path)?; - cfg.load_file(&abs_path.to_string_lossy())?; + // Byte-preserving: the option string carries non-UTF-8 bytes via the + // reversible `osbytes` escape and the canonical path may too. + let abs_path = std::fs::canonicalize(ghostty_os::osbytes::config_str_to_path(config_path))?; + cfg.load_file(&ghostty_os::osbytes::path_to_config_string(&abs_path))?; cfg.load_recursive_files()?; } else { cfg = Config::load()?; @@ -109,4 +111,21 @@ mod tests { }; 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); + } } diff --git a/crates/ghostty-config/src/cli_args.rs b/crates/ghostty-config/src/cli_args.rs index bae91a3..55d1e9e 100644 --- a/crates/ghostty-config/src/cli_args.rs +++ b/crates/ghostty-config/src/cli_args.rs @@ -343,10 +343,21 @@ pub fn parse_auto_struct_with( /// Decode a double-quoted Zig string literal (including the quotes). /// 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] pub fn parse_zig_string_literal(s: &str) -> Option { - 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> { + 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'"' { return None; } @@ -395,7 +406,7 @@ pub fn parse_zig_string_literal(s: &str) -> Option { } i += 1; } - Some(String::from_utf8_lossy(&out).into_owned()) + Some(out) } /// Parse a "packed struct" value (comma separated names, optionally @@ -652,8 +663,13 @@ impl> ArgIter for ArgsIterator { /// Create an args iterator for the process args. This will skip argv0. #[must_use] pub fn args_iterator() -> ArgsIterator>> { - // Byte-safe: never panics on non-UTF-8 arguments. - ArgsIterator::new(ghostty_os::args::collect_lossy().into_iter().skip(1)) + // Byte-safe: non-UTF-8 arguments keep their bytes (reversible escape + // 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. @@ -791,7 +807,8 @@ impl LineIterator { 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 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); } + + #[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}')); + } } diff --git a/crates/ghostty-config/src/config.rs b/crates/ghostty-config/src/config.rs index a41cbab..6f90072 100644 --- a/crates/ghostty-config/src/config.rs +++ b/crates/ghostty-config/src/config.rs @@ -3877,7 +3877,7 @@ impl Config { /// /// `path` must be resolved and absolute. 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) { Ok(f) => f, Err(crate::file_load::OpenFileError::NotAFile) => { @@ -3916,9 +3916,9 @@ impl Config { } let mut iter = cli_args::LineIterator::with_path(reader, path); self.load_iter(&mut iter)?; - let dir = std::path::Path::new(path) + let dir = ghostty_os::osbytes::config_str_to_path(path) .parent() - .map(|p| p.to_string_lossy().into_owned()) + .map(ghostty_os::osbytes::path_to_config_string) .unwrap_or_default(); self.expand_paths(&dir)?; Ok(()) @@ -4067,7 +4067,7 @@ impl Config { // a command to execute. let mut builder: Vec = Vec::new(); 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())); builder.push(copy); } @@ -4126,7 +4126,7 @@ impl Config { // Any paths referenced from the CLI are relative to the current working // directory. let cwd = std::fs::canonicalize(".")?; - self.expand_paths(&cwd.to_string_lossy())?; + self.expand_paths(&ghostty_os::osbytes::path_to_config_string(&cwd))?; Ok(()) } @@ -4170,7 +4170,7 @@ impl Config { // All paths should already be absolute at this point because // 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 if !loaded.insert(path.clone()) { @@ -4183,7 +4183,7 @@ impl Config { 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, Err(err) => { if err.kind() != std::io::ErrorKind::NotFound || !optional { diff --git a/crates/ghostty-config/src/file_load.rs b/crates/ghostty-config/src/file_load.rs index a1f702b..e983744 100644 --- a/crates/ghostty-config/src/file_load.rs +++ b/crates/ghostty-config/src/file_load.rs @@ -59,7 +59,7 @@ pub fn legacy_default_app_support_path() -> Result { #[cfg(target_os = "macos")] fn app_support_dir(sub_path: &str) -> Result { 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) } @@ -159,9 +159,10 @@ impl std::error::Error for OpenFileError {} /// if it exists and is non-empty. This also constrains the possible /// errors to a smaller set that we can explicitly handle. pub fn open(path: &str) -> Result { - 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 { OpenFileError::FileNotFound } else { diff --git a/crates/ghostty-config/src/path.rs b/crates/ghostty-config/src/path.rs index 614d604..c879e9d 100644 --- a/crates/ghostty-config/src/path.rs +++ b/crates/ghostty-config/src/path.rs @@ -2,9 +2,11 @@ use std::{ fmt, - path::{Component, Path as StdPath, PathBuf}, + path::{Component, PathBuf}, }; +use ghostty_os::osbytes::{config_str_to_path, path_to_config_string}; + use crate::{ c_value::CValue, cli_args::{ArgError, ConfigField}, @@ -32,7 +34,7 @@ impl Default for Path { /// Lexically resolve `base/path` like `std.fs.path.resolve`. 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(); for c in joined.components() { match c { @@ -43,7 +45,7 @@ fn resolve_lexical(base: &str, path: &str) -> String { other => out.push(other.as_os_str()), } } - out.to_string_lossy().into_owned() + path_to_config_string(&out) } impl Path { @@ -109,12 +111,15 @@ impl Path { /// absolute path. Errors are added to `diags` if they occur; an `Err` is /// only returned if the base directory cannot be opened. pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> { - 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(); // 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(()); } @@ -143,11 +148,11 @@ impl Path { } // 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) { - Ok(p) => p.to_string_lossy().into_owned(), + Ok(p) => path_to_config_string(&p), Err(err) if err.kind() == std::io::ErrorKind::NotFound => { // The file doesn't exist. Try to resolve the relative path // another way. @@ -374,4 +379,23 @@ mod tests { assert_eq!(p, Path::Optional("/abs/path".into())); 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()); + } } diff --git a/crates/ghostty-config/src/theme.rs b/crates/ghostty-config/src/theme.rs index e55db70..31f1d00 100644 --- a/crates/ghostty-config/src/theme.rs +++ b/crates/ghostty-config/src/theme.rs @@ -3,7 +3,14 @@ //! Zig reads the resources dir from global state; here it is passed //! 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}; @@ -35,12 +42,9 @@ impl Location { .ok(), Location::Resources => { let base = resources_dir?; - Some( - Path::new(base) - .join("themes") - .to_string_lossy() - .into_owned(), - ) + Some(path_to_config_string( + &config_str_to_path(base).join("themes"), + )) } } } @@ -161,7 +165,7 @@ pub fn open( diags: &mut DiagnosticList, ) -> Option { // 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)?; if !check_regular(&file, theme, diags) { return None; @@ -172,9 +176,9 @@ pub fn open( }); } - let basename = Path::new(theme) + let basename = config_str_to_path(theme) .file_name() - .map(|s| s.to_string_lossy().into_owned()) + .map(os_to_config_string) .unwrap_or_default(); if theme != basename { diag( @@ -190,8 +194,8 @@ pub fn open( // one that exists. let mut it = LocationIterator::new(resources_dir); while let Some((_, dir)) = it.next_location() { - let path = Path::new(&dir).join(theme).to_string_lossy().into_owned(); - match File::open(&path) { + let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme))); + match File::open(config_str_to_path(&path)) { Ok(file) => { if !check_regular(&file, theme, diags) { return None; @@ -215,7 +219,7 @@ pub fn open( // our iterator, reiterate over in order to build a better error message. it.reset(); while let Some((_, dir)) = it.next_location() { - let path = Path::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( diags, 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 /// returned. pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option { - match File::open(theme) { + match File::open(config_str_to_path(theme)) { Ok(f) => Some(f), Err(err) => { if err.kind() == std::io::ErrorKind::NotFound { diff --git a/crates/ghostty-os/src/args.rs b/crates/ghostty-os/src/args.rs index 2e9c029..11e61ac 100644 --- a/crates/ghostty-os/src/args.rs +++ b/crates/ghostty-os/src/args.rs @@ -5,9 +5,28 @@ //! don't panic). /// 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"))] -pub fn iterator() -> std::env::ArgsOs { - std::env::args_os() +pub fn iterator() -> std::vec::IntoIter { + match ARGV_OVERRIDE.lock().ok().and_then(|g| g.clone()) { + Some(v) => v.into_iter(), + None => std::env::args_os().collect::>().into_iter(), + } +} + +static ARGV_OVERRIDE: std::sync::Mutex>> = + 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) { + if let Ok(mut g) = ARGV_OVERRIDE.lock() { + *g = Some(args); + } } /// Returns an iterator over the command line arguments. @@ -27,15 +46,25 @@ pub fn iterator() -> std::vec::IntoIter { .into_iter() } -/// Collect the process arguments as `String`s without ever panicking on -/// non-UTF-8 bytes (invalid sequences become U+FFFD). The CLI/config layers -/// are string based; `+action` dispatch only depends on the ASCII prefix. -pub fn collect_lossy() -> Vec { - lossy(iterator()) +/// Collect the process arguments as raw OS strings (no conversion, never +/// panics on non-UTF-8 bytes). +pub fn collect_os() -> Vec { + iterator().collect() } -fn lossy(it: impl Iterator) -> Vec { - it.map(|a| a.to_string_lossy().into_owned()).collect() +/// Collect the process arguments as raw byte vectors (Zig `[]const u8`). +pub fn collect_bytes() -> Vec> { + 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 { + iterator() + .map(|a| crate::osbytes::os_to_config_string(&a)) + .collect() } #[cfg(test)] @@ -50,13 +79,13 @@ mod tests { #[cfg(unix)] #[test] - fn lossy_does_not_panic_on_non_utf8() { + fn config_strings_keep_non_utf8_bytes() { use std::os::unix::ffi::OsStringExt; - let args = vec![ - std::ffi::OsString::from("ghostty"), - std::ffi::OsString::from_vec(vec![b'+', b'x', 0xff]), - ]; - let out = lossy(args.into_iter()); - assert_eq!(out, vec!["ghostty".to_owned(), "+x\u{fffd}".to_owned()]); + let a = std::ffi::OsString::from_vec(vec![b'+', b'x', 0xff]); + let s = crate::osbytes::os_to_config_string(&a); + assert_eq!( + crate::osbytes::config_str_to_bytes(&s), + vec![b'+', b'x', 0xff] + ); } } diff --git a/crates/ghostty-os/src/cgroup.rs b/crates/ghostty-os/src/cgroup.rs index 9768532..db7786b 100644 --- a/crates/ghostty-os/src/cgroup.rs +++ b/crates/ghostty-os/src/cgroup.rs @@ -4,8 +4,14 @@ use std::io::Read; /// into a caller-provided buffer; callers there use path-sized buffers). 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 { + current_os(pid).map(|p| crate::osbytes::os_to_config_string(&p)) +} + +/// Byte-preserving [`current`]. +pub fn current_os(pid: u32) -> Option { // Read our cgroup by opening /proc//cgroup and reading the first // line. The first line will look something like this: // 0::/user.slice/user-1000.slice/session-1.scope @@ -21,15 +27,15 @@ pub fn current(pid: u32) -> Option { .iter() .rposition(|b| !matches!(b, b' ' | b'\r' | b'\n')) .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`. pub fn scope_matches(pid: u32, expected: &str) -> bool { - current(pid) - .as_deref() - .and_then(|raw| raw.rsplit_once('/')) - .is_some_and(|(_, last)| last == expected) + current_os(pid).is_some_and(|raw| { + let raw = crate::osbytes::os_bytes(&raw); + raw.rsplit(|&b| b == b'/').next() == Some(expected.as_bytes()) && raw.contains(&b'/') + }) } /// 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()); } + + #[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")); + } } diff --git a/crates/ghostty-os/src/env.rs b/crates/ghostty-os/src/env.rs index c4c8d0b..1ab261c 100644 --- a/crates/ghostty-os/src/env.rs +++ b/crates/ghostty-os/src/env.rs @@ -235,10 +235,24 @@ pub fn prepend_env(current: &str, value: &str) -> String { format!("{value}{DELIMITER}{current}") } -/// Gets the value of an environment variable, or None if not found -/// (or not valid unicode). +/// Gets the raw value of an environment variable, or None if not found. +/// A present value that is not valid UTF-8 is returned as-is (never treated +/// as absent). +pub fn getenv_os(key: &str) -> Option { + 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 { + 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 { - 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 @@ -294,6 +308,19 @@ mod tests { 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)] #[test] fn os_variants_preserve_bytes() { diff --git a/crates/ghostty-os/src/file.rs b/crates/ghostty-os/src/file.rs index 2edba9c..d7ed730 100644 --- a/crates/ghostty-os/src/file.rs +++ b/crates/ghostty-os/src/file.rs @@ -70,19 +70,41 @@ pub fn restore_max_files(lim: Rlimit) { let _ = setrlimit(Resource::Nofile, lim); } -/// Return the recommended path for temporary files. -pub fn alloc_tmp_dir() -> Option { +/// Resource limit placeholder on platforms without `rlimit` (Zig: +/// `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 { + 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 { if cfg!(windows) { // 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") { - return Some(v.to_string_lossy().into_owned()); + return Some(v.into()); } 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 { + alloc_tmp_dir_os().map(|p| crate::osbytes::os_to_config_string(p.as_os_str())) } #[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)] #[test] fn fix_and_restore_max_files() { diff --git a/crates/ghostty-os/src/homedir.rs b/crates/ghostty-os/src/homedir.rs index bb4884a..d91ac26 100644 --- a/crates/ghostty-os/src/homedir.rs +++ b/crates/ghostty-os/src/homedir.rs @@ -1,6 +1,8 @@ +use std::ffi::{OsStr, OsString}; #[cfg(unix)] use std::process::{Command, Stdio}; +use crate::osbytes; #[cfg(unix)] use crate::passwd; @@ -24,16 +26,29 @@ const MAX_PATH_BYTES: usize = 4096; /// Determine the home directory for the currently executing user. This /// 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, Error> { home_with_capacity(MAX_PATH_BYTES) } +/// Byte-preserving [`home`]. +pub fn home_os() -> Result, Error> { + home_os_with_capacity(MAX_PATH_BYTES) +} + /// Like [`home`] but emulates the fixed output buffer of the Zig version: /// errors with `BufferTooSmall` if the result is longer than `cap` bytes. -#[cfg(unix)] pub fn home_with_capacity(cap: usize) -> Result, Error> { - let check = |s: String| { - if cap < s.len() { + Ok(home_os_with_capacity(cap)?.map(|h| osbytes::os_to_config_string(&h))) +} + +/// Byte-preserving [`home_with_capacity`]. +#[cfg(unix)] +pub fn home_os_with_capacity(cap: usize) -> Result, Error> { + let check = |s: OsString| { + if cap < osbytes::os_bytes(&s).len() { Err(Error::BufferTooSmall) } else { Ok(Some(s)) @@ -42,7 +57,7 @@ pub fn home_with_capacity(cap: usize) -> Result, Error> { // First: if we have a HOME env var, then we use that. 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 @@ -53,12 +68,12 @@ pub fn home_with_capacity(cap: usize) -> Result, Error> { let manager = NSFileManager::defaultManager(); let home_url = manager.homeDirectoryForCurrentUser(); 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. - if let Ok(pw) = passwd::get() + if let Ok(pw) = passwd::get_os() && let Some(result) = pw.home { return check(result); @@ -73,8 +88,7 @@ pub fn home_with_capacity(cap: usize) -> Result, Error> { if let Ok(run) = run && run.status.success() { - let out = String::from_utf8_lossy(&run.stdout); - return check(trim_space(&out).to_owned()); + return check(osbytes::os_from_bytes(trim_space(&run.stdout).to_vec())); } Ok(None) @@ -83,12 +97,10 @@ pub fn home_with_capacity(cap: usize) -> Result, Error> { /// Windows: `%HOMEDRIVE%%HOMEPATH%` (`homeWindows`). Returns `None` if /// either variable is missing. #[cfg(windows)] -pub fn home_with_capacity(cap: usize) -> Result, Error> { - home_windows( - std::env::var("HOMEDRIVE").ok().as_deref(), - std::env::var("HOMEPATH").ok().as_deref(), - cap, - ) +pub fn home_os_with_capacity(cap: usize) -> Result, Error> { + let drive = std::env::var_os("HOMEDRIVE").map(|v| v.to_string_lossy().into_owned()); + let path = std::env::var_os("HOMEPATH").map(|v| v.to_string_lossy().into_owned()); + Ok(home_windows(drive.as_deref(), path.as_deref(), cap)?.map(OsString::from)) } /// Pure part of [`home_with_capacity`] on Windows. @@ -108,8 +120,14 @@ fn home_windows( } #[cfg(unix)] -fn trim_space(input: &str) -> &str { - input.trim_matches([' ', '\n', '\t']) +fn trim_space(input: &[u8]) -> &[u8] { + 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 @@ -121,31 +139,41 @@ pub fn expand_home(path: &str) -> Result { expand_home_with_capacity(path, MAX_PATH_BYTES) } +/// Byte-preserving [`expand_home`]. +pub fn expand_home_os(path: &OsStr) -> Result { + expand_home_os_with_capacity(path, MAX_PATH_BYTES) +} + /// Like [`expand_home`] with an explicit output capacity (emulating the /// caller-provided buffer of the Zig version). +pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result { + 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)] -pub fn expand_home_with_capacity(path: &str, _cap: usize) -> Result { - // `~/` is not an idiom generally used on Windows +pub fn expand_home_os_with_capacity(path: &OsStr, _cap: usize) -> Result { Ok(path.to_owned()) } /// See the Windows variant above. #[cfg(unix)] -pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result { - if !path.starts_with("~/") { +pub fn expand_home_os_with_capacity(path: &OsStr, cap: usize) -> Result { + let bytes = osbytes::os_bytes(path); + if !bytes.starts_with(b"~/") { 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); }; - let rest = &path[1..]; // Skip the ~ - let expanded_len = home_dir.len() + rest.len(); - - if expanded_len > cap { + let mut out = osbytes::os_bytes(&home_dir); + let rest = &bytes[1..]; // Skip the ~ + if out.len() + rest.len() > cap { return Err(ExpandError::BufferTooSmall); } - - Ok(format!("{home_dir}{rest}")) + out.extend_from_slice(rest); + Ok(osbytes::os_from_bytes(out)) } #[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] fn home_prefers_env() { temp_env::with_var("HOME", Some("/some/home"), || { diff --git a/crates/ghostty-os/src/i18n.rs b/crates/ghostty-os/src/i18n.rs index 0a26d63..a311c05 100644 --- a/crates/ghostty-os/src/i18n.rs +++ b/crates/ghostty-os/src/i18n.rs @@ -79,26 +79,49 @@ pub fn tr(msgid: &str) -> String { /// a POSIX-compliant value. /// /// The Zig version is a thin layer over gnulib's -/// `_libintl_locale_name_canonicalize` (only linked when building libintl -/// from source, i.e. macOS). That function isn't available here, so this is -/// a pure-Rust approximation of the cases that matter: BCP-47 style tags -/// have `-` replaced with `_` and the Chinese script tags are mapped to -/// their POSIX region equivalents. Encodings are NOT parsed out (like the -/// original, `en_US.UTF-8` becomes `en_US.UTF_8`). +/// `_libintl_locale_name_canonicalize` (the Mac OS X flavour of +/// `gl_locale_name_canonicalize`), after fixing up some `zh` locales. This +/// ports that function exactly, including its legacy-name, language-tag and +/// script tables ([`crate::i18n_canon_table`]). Encodings are NOT parsed out +/// (like the original, `en_US.UTF-8` becomes `en_US.UTF_8`). pub fn canonicalize_locale(locale: &str) -> String { // Fix zh locales if let Some(fixed) = fix_zh_locale(locale) { return fixed.to_owned(); } - // Emulate gnulib's mapping of bare zh script tags. - match locale { - "zh-Hans" => return "zh_CN".to_owned(), - "zh-Hant" => return "zh_TW".to_owned(), - _ => {} + libintl_locale_name_canonicalize(locale) +} + +/// `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 @@ -183,6 +206,37 @@ mod tests { 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] fn locales_map_and_static_locale() { assert!(is_supported_locale("zh_CN")); diff --git a/crates/ghostty-os/src/i18n_canon_table.rs b/crates/ghostty-os/src/i18n_canon_table.rs new file mode 100644 index 0000000..a2c61d7 --- /dev/null +++ b/crates/ghostty-os/src/i18n_canon_table.rs @@ -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"), +]; diff --git a/crates/ghostty-os/src/lib.rs b/crates/ghostty-os/src/lib.rs index 51fd3df..da4a702 100644 --- a/crates/ghostty-os/src/lib.rs +++ b/crates/ghostty-os/src/lib.rs @@ -30,6 +30,7 @@ pub mod file; pub mod file_type; #[cfg(unix)] pub mod flatpak; +pub mod osbytes; #[cfg(windows)] pub mod flatpak { //! Flatpak does not exist on Windows. @@ -45,6 +46,8 @@ pub mod homedir; pub mod hostname; #[cfg(not(target_arch = "wasm32"))] pub mod i18n; +#[cfg(not(target_arch = "wasm32"))] +mod i18n_canon_table; pub mod i18n_locales; #[cfg(not(target_arch = "wasm32"))] pub mod kernel_info; @@ -73,10 +76,23 @@ pub mod passwd { pub name: Option, } + /// Byte-preserving entry (same shape as the Unix one). + #[derive(Debug, Clone, Default, PartialEq, Eq)] + pub struct EntryOs { + pub shell: Option, + pub home: Option, + pub name: Option, + } + /// No passwd database on Windows: always an empty entry. pub fn get() -> std::io::Result { Ok(Entry::default()) } + + /// See [`get`]. + pub fn get_os() -> std::io::Result { + Ok(EntryOs::default()) + } } #[cfg(not(target_arch = "wasm32"))] pub mod path; @@ -123,13 +139,13 @@ pub use env::{ get_env_map, getenv, getenv_not_empty, + getenv_not_empty_os, + getenv_os, prepend_env, prepend_env_os, }; #[cfg(not(target_arch = "wasm32"))] -pub use file::alloc_tmp_dir; -#[cfg(unix)] -pub use file::{Rlimit, fix_max_files, restore_max_files}; +pub use file::{Rlimit, alloc_tmp_dir, alloc_tmp_dir_os, fix_max_files, restore_max_files}; #[cfg(unix)] pub use flatpak::FlatpakHostCommand; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ghostty-os/src/osbytes.rs b/crates/ghostty-os/src/osbytes.rs new file mode 100644 index 0000000..73fdce8 --- /dev/null +++ b/crates/ghostty-os/src/osbytes.rs @@ -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 { + 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 { + use std::os::unix::ffi::OsStrExt; + s.as_bytes().to_vec() +} + +#[cfg(not(unix))] +pub fn os_bytes(s: &OsStr) -> Vec { + s.to_string_lossy().into_owned().into_bytes() +} + +/// Build an OS string from raw bytes. +#[cfg(unix)] +pub fn os_from_bytes(b: Vec) -> OsString { + use std::os::unix::ffi::OsStringExt; + OsString::from_vec(b) +} + +#[cfg(not(unix))] +pub fn os_from_bytes(b: Vec) -> 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); + } +} diff --git a/crates/ghostty-os/src/passwd.rs b/crates/ghostty-os/src/passwd.rs index 6f28a99..b903949 100644 --- a/crates/ghostty-os/src/passwd.rs +++ b/crates/ghostty-os/src/passwd.rs @@ -1,5 +1,8 @@ +use std::ffi::OsString; + #[cfg(any(target_os = "linux", target_os = "android"))] use crate::flatpak::is_flatpak; +use crate::osbytes; /// Entry that is retrieved from the passwd API. This only contains the fields /// we care about. @@ -10,19 +13,45 @@ pub struct Entry { pub name: Option, } -/// 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, + pub home: Option, + pub name: Option, +} + +impl From for Entry { + fn from(e: EntryOs) -> Self { + let f = |v: Option| 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 { + get_os().map(Entry::from) +} + +/// Byte-preserving [`get`]. +pub fn get_os() -> std::io::Result { let uid = nix::unistd::getuid(); let user = match nix::unistd::User::from_uid(uid) { Ok(Some(user)) => user, Ok(None) => { // 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'"); - return Ok(Entry::default()); + return Ok(EntryOs::default()); } Err(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 { return host_entry(&user.name); } - Ok(Entry { - shell: Some(user.shell.to_string_lossy().into_owned()), - home: Some(user.dir.to_string_lossy().into_owned()), - name: Some(user.name), + Ok(EntryOs { + shell: Some(user.shell.into_os_string()), + home: Some(user.dir.into_os_string()), + name: Some(OsString::from(user.name)), }) } @@ -49,7 +78,7 @@ pub fn get() -> std::io::Result { /// `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. #[cfg(any(target_os = "linux", target_os = "android"))] -fn host_entry(name: &str) -> std::io::Result { +fn host_entry(name: &str) -> std::io::Result { use std::io::Read; use rustix::{ @@ -112,17 +141,20 @@ fn host_entry(name: &str) -> std::io::Result { } } - 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. #[cfg(any(target_os = "linux", target_os = "android"))] -fn parse_getent(output: &str) -> Entry { - let mut it = output.trim_end_matches([' ', '\r', '\n']).rsplit(':'); - let shell = it.next().map(str::to_owned); - let home = it.next().map(str::to_owned); - Entry { +fn parse_getent(output: &[u8]) -> EntryOs { + let end = output + .iter() + .rposition(|b| !matches!(b, b' ' | b'\r' | b'\n')) + .map_or(0, |i| i + 1); + let mut it = output[..end].rsplit(|&b| b == b':'); + let shell = it.next().map(|b| osbytes::os_from_bytes(b.to_vec())); + let home = it.next().map(|b| osbytes::os_from_bytes(b.to_vec())); + EntryOs { shell, home, name: None, @@ -136,12 +168,24 @@ mod tests { #[cfg(any(target_os = "linux", target_os = "android"))] #[test] fn parse_getent_output() { - let e = parse_getent("mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n"); - assert_eq!(e.shell.as_deref(), Some("/bin/zsh")); - assert_eq!(e.home.as_deref(), Some("/home/mitchell")); + let e = parse_getent(b"mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n"); + assert_eq!(e.shell.as_deref(), Some(std::ffi::OsStr::new("/bin/zsh"))); + assert_eq!( + e.home.as_deref(), + Some(std::ffi::OsStr::new("/home/mitchell")) + ); 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] fn get_entry() { // We should be able to get an entry diff --git a/crates/ghostty-os/src/path.rs b/crates/ghostty-os/src/path.rs index 7eeaad3..01ca94c 100644 --- a/crates/ghostty-os/src/path.rs +++ b/crates/ghostty-os/src/path.rs @@ -1,6 +1,6 @@ -use std::io; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +use std::{ffi::OsStr, io, path::PathBuf}; /// `std.fs.path.delimiter` / `sep`. #[cfg(windows)] @@ -25,28 +25,44 @@ pub enum 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, 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, Error> { + let cmd_bytes = crate::osbytes::os_bytes(cmd); + // If the command already contains a slash, then we return it as-is // because it is assumed to be absolute or relative. - if cmd.contains('/') { - return Ok(Some(cmd.to_owned())); + if cmd_bytes.contains(&b'/') { + return Ok(Some(PathBuf::from(cmd))); } let Some(path_var) = std::env::var_os("PATH") else { 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; - 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 - 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 { 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 let f = match std::fs::File::open(&full_path) { @@ -127,4 +143,23 @@ mod tests { 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())); + }); + } } diff --git a/crates/ghostty-os/src/resourcesdir.rs b/crates/ghostty-os/src/resourcesdir.rs index d98d278..ec39798 100644 --- a/crates/ghostty-os/src/resourcesdir.rs +++ b/crates/ghostty-os/src/resourcesdir.rs @@ -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)] pub struct ResourcesDir { @@ -25,6 +33,17 @@ impl ResourcesDir { pub fn host(&self) -> Option<&str> { 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 { + self.app().map(|p| PathBuf::from(config_str_to_os(p))) + } + + /// Byte-preserving [`ResourcesDir::host`]. + pub fn host_os(&self) -> Option { + 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 @@ -50,7 +69,7 @@ const DEV_SENTINELS: [&str; 1] = ["terminfo/x/xterm-ghostty"]; /// Zig's `builtin.mode == .Debug` is mapped to `cfg!(debug_assertions)`. pub fn resources_dir() -> io::Result { 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( cfg!(debug_assertions), exe.as_deref(), @@ -58,7 +77,7 @@ pub fn resources_dir() -> io::Result { )) } -fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option) -> ResourcesDir { +fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option) -> ResourcesDir { // Use the GHOSTTY_RESOURCES_DIR environment variable in release builds. // // 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) -> R // freshly built ones under zig-out/share/ghostty. if !debug && let Some(dir) = env.as_ref().filter(|d| !d.is_empty()) { return ResourcesDir { - app_path: Some(dir.clone()), + app_path: Some(os_to_config_string(dir)), host_path: None, }; } @@ -82,14 +101,12 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option) -> R // bundle as we expect it. while let Some(dir) = exe.parent() { exe = dir; - let dir_str = dir.to_string_lossy(); - // On MacOS, we look for the app bundle path. #[cfg(target_vendor = "apple")] 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 { - app_path: Some(path_join(&[&v, "ghostty"])), + app_path: Some(enc(&v.join("ghostty"))), host_path: None, }; } @@ -97,9 +114,9 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option) -> R // We look for a /usr/share style path. 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 { - app_path: Some(path_join(&[&v, "ghostty"])), + app_path: Some(enc(&v.join("ghostty"))), host_path: None, }; } @@ -110,7 +127,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option) -> R // fallback and use the provided resources dir. if debug && let Some(dir) = env.filter(|d| !d.is_empty()) { return ResourcesDir { - app_path: Some(dir), + app_path: Some(os_to_config_string(&dir)), host_path: None, }; } @@ -122,7 +139,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option) -> R // lives inside this workspace. if debug && let Some(dir) = dev_resources_dir(exe_start) { return ResourcesDir { - app_path: Some(dir), + app_path: Some(enc(&dir)), host_path: None, }; } @@ -137,7 +154,7 @@ fn workspace_root() -> std::path::PathBuf { /// Returns `/resources` if `exe` is inside the workspace this /// crate was built from and the directory contains our sentinel. -fn dev_resources_dir(exe: &Path) -> Option { +fn dev_resources_dir(exe: &Path) -> Option { let root = workspace_root().canonicalize().ok()?; let exe = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf()); if !exe.starts_with(&root) { @@ -148,7 +165,15 @@ fn dev_resources_dir(exe: &Path) -> Option { .iter() .chain(DEV_SENTINELS.iter()) .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 { + 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 @@ -204,7 +229,7 @@ mod tests { fn env_in_release_wins_and_in_debug_is_fallback() { let td = make_tree(); let exe = td.path().join("bin/ghostty"); - let env = Some("/env/res".to_owned()); + let env = Some(OsString::from("/env/res")); assert_eq!( resources_dir_impl(false, Some(&exe), env.clone()).app(), Some("/env/res") @@ -220,7 +245,7 @@ mod tests { ); // Empty env is ignored. assert_eq!( - resources_dir_impl(false, None, Some(String::new())).app(), + resources_dir_impl(false, None, Some(OsString::new())).app(), None ); } @@ -238,6 +263,23 @@ mod tests { 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] fn maybe_dir_checks_suffix() { let td = make_tree(); diff --git a/crates/ghostty-os/src/temp_dir.rs b/crates/ghostty-os/src/temp_dir.rs index db27290..cae10f6 100644 --- a/crates/ghostty-os/src/temp_dir.rs +++ b/crates/ghostty-os/src/temp_dir.rs @@ -8,7 +8,7 @@ use std::{ 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. const RANDOM_BYTES: usize = 16; @@ -89,8 +89,8 @@ fn fill_random(buf: &mut [u8]) -> io::Result<()> { impl TempDir { /// Create the temporary directory. pub fn init() -> io::Result { - let parent = match alloc_tmp_dir() { - Some(p) => PathBuf::from(p), + let parent = match alloc_tmp_dir_os() { + Some(p) => p, None => std::env::current_dir()?, }; // Verify the parent can be opened (Zig opens it as a Dir). diff --git a/docs/rewrite/notes/final-Z1.md b/docs/rewrite/notes/final-Z1.md new file mode 100644 index 0000000..8dfa871 --- /dev/null +++ b/docs/rewrite/notes/final-Z1.md @@ -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` (`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-+"; 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).