fix: preserve non-UTF-8 bytes in OS, config and CLI layers
Zig treats paths, arguments and environment values as byte slices; the port converted them lossily in many places. Now (final-Z1.md): - argv, env (incl. present non-UTF-8 values), HOME, PATH lookup, passwd, resources dir, TMPDIR, cgroup, config path canonicalization, +validate-config, +list-themes and Zig-string escapes (\xFF) keep raw bytes. String-based config values carry bytes via ghostty_os::osbytes, a bijective escape (noncharacter U+FDD0 + byte) that leaves all valid UTF-8 - including Nerd Font private-use icons - unchanged. - os/file rlimit fallbacks exist on all targets. - i18n locale canonicalization is an exact port of libintl's tables (e.g. pa-Guru -> pa_IN). - New ghostty-build-info crate is the single source of version, channel, commit and build mode for all consumers. Test Plan: - cargo test -p ghostty-os -p ghostty-config -p ghostty-cli (incl. randomized osbytes round-trip property tests)
This commit is contained in:
1 parent
e4cdd8e007
commit
395cff8981
32 files changed
+1464
-333
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Derives the build version metadata for [`ghostty_build_info`](crate):
|
||||
//!
|
||||
//! * the app version, release channel and commit (Zig: `build/Config.zig`
|
||||
//! version detection + `build/GitVersion.zig`)
|
||||
//! * the build mode (Zig: `builtin.mode`)
|
||||
//!
|
||||
//! The version can be overridden with `GHOSTTY_VERSION_STRING` (Zig:
|
||||
//! `-Dversion-string`), which must be a semantic version.
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
fn run(cmd: &str, args: &[&str], dir: &Path) -> Option<String> {
|
||||
let out = Command::new(cmd)
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.ok()?;
|
||||
out.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&out.stdout).trim_end().to_string())
|
||||
}
|
||||
|
||||
/// The `app-version` from `[workspace.metadata.ghostty]` of the workspace
|
||||
/// manifest (two levels above this crate).
|
||||
fn app_version(manifest_dir: &Path) -> (u64, u64, u64) {
|
||||
let root = manifest_dir.join("../../Cargo.toml");
|
||||
println!("cargo:rerun-if-changed={}", root.display());
|
||||
let text = std::fs::read_to_string(root).unwrap_or_default();
|
||||
let v = text
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("app-version"))
|
||||
.and_then(|l| l.split('"').nth(1))
|
||||
.unwrap_or("1.3.1");
|
||||
let mut it = v.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
|
||||
(
|
||||
it.next().unwrap_or(1),
|
||||
it.next().unwrap_or(3),
|
||||
it.next().unwrap_or(1),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns `(version_string, commit)` mirroring `Config.zig`.
|
||||
fn detect_version(manifest_dir: &Path) -> (String, Option<String>) {
|
||||
println!("cargo:rerun-if-env-changed=GHOSTTY_VERSION_STRING");
|
||||
if let Ok(v) = std::env::var("GHOSTTY_VERSION_STRING") {
|
||||
let commit = v.split_once('+').map(|(_, b)| b.to_string());
|
||||
return (v, commit);
|
||||
}
|
||||
|
||||
let (maj, min, pat) = app_version(manifest_dir);
|
||||
let base = format!("{maj}.{min}.{pat}");
|
||||
|
||||
// Git unavailable or not a repository: unknown dev version.
|
||||
let Some(branch) = run("git", &["rev-parse", "--abbrev-ref", "HEAD"], manifest_dir) else {
|
||||
return (format!("{base}-dev+0000000"), Some("0000000".into()));
|
||||
};
|
||||
if let Some(git_dir) = run("git", &["rev-parse", "--absolute-git-dir"], manifest_dir) {
|
||||
let git_dir = PathBuf::from(git_dir);
|
||||
println!("cargo:rerun-if-changed={}", git_dir.join("HEAD").display());
|
||||
println!("cargo:rerun-if-changed={}", git_dir.join("refs").display());
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
git_dir.join("packed-refs").display()
|
||||
);
|
||||
}
|
||||
let hash = run(
|
||||
"git",
|
||||
&[
|
||||
"-c",
|
||||
"log.showSignature=false",
|
||||
"log",
|
||||
"--pretty=format:%h",
|
||||
"-n",
|
||||
"1",
|
||||
],
|
||||
manifest_dir,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let tag = run(
|
||||
"git",
|
||||
&["describe", "--exact-match", "--tags"],
|
||||
manifest_dir,
|
||||
)
|
||||
.filter(|t| !t.is_empty());
|
||||
|
||||
if let Some(tag) = tag {
|
||||
// Tip releases behave just like any other pre-release so we skip.
|
||||
if tag != "tip" {
|
||||
assert!(
|
||||
tag == format!("v{base}"),
|
||||
"tagged releases must be in vX.Y.Z format matching the workspace app-version"
|
||||
);
|
||||
return (base, None);
|
||||
}
|
||||
}
|
||||
|
||||
// Slashes in branch names are replaced like in `GitVersion.zig`.
|
||||
let branch = branch.replace('/', "-");
|
||||
(format!("{base}-{branch}+{hash}"), Some(hash))
|
||||
}
|
||||
|
||||
/// The Zig optimize-mode name for the Cargo profile being built.
|
||||
fn build_mode() -> &'static str {
|
||||
// The profile name is the directory below `target/` that holds OUT_DIR.
|
||||
let out = std::env::var("OUT_DIR").unwrap_or_default();
|
||||
let profile = Path::new(&out)
|
||||
.ancestors()
|
||||
.skip_while(|p| p.file_name().is_none_or(|n| n != "build"))
|
||||
.nth(1)
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let debug_assertions = std::env::var_os("CARGO_CFG_DEBUG_ASSERTIONS").is_some();
|
||||
let opt = std::env::var("OPT_LEVEL").unwrap_or_default();
|
||||
if profile == "production" || !debug_assertions {
|
||||
".ReleaseFast"
|
||||
} else if opt == "0" {
|
||||
".Debug"
|
||||
} else {
|
||||
".ReleaseSafe"
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default());
|
||||
let (version_string, commit) = detect_version(&manifest_dir);
|
||||
// Release channel: stable unless the version has a pre-release part.
|
||||
let channel = if version_string.contains('-') {
|
||||
"tip"
|
||||
} else {
|
||||
"stable"
|
||||
};
|
||||
println!("cargo:rustc-env=GHOSTTY_VERSION_STRING={version_string}");
|
||||
println!(
|
||||
"cargo:rustc-env=GHOSTTY_COMMIT={}",
|
||||
commit.unwrap_or_default()
|
||||
);
|
||||
println!("cargo:rustc-env=GHOSTTY_RELEASE_CHANNEL={channel}");
|
||||
println!("cargo:rustc-env=GHOSTTY_BUILD_MODE={}", build_mode());
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Single source of truth for the build version metadata (Zig:
|
||||
//! `build_config.zig` `version`, `version_string`, `release_channel`, plus
|
||||
//! `builtin.mode`).
|
||||
//!
|
||||
//! Everything is derived at compile time by this crate's `build.rs` (git
|
||||
//! describe / workspace `app-version`, overridable with the
|
||||
//! `GHOSTTY_VERSION_STRING` environment variable). Consumers (XTVERSION,
|
||||
//! `TERM_PROGRAM_VERSION`, the About dialog, crash metadata, `+version`) must
|
||||
//! use these constants instead of hardcoding a version.
|
||||
|
||||
/// `build_config.version_string`, e.g. `1.3.1` for tagged releases or
|
||||
/// `1.3.1-main+abc1234` for development builds.
|
||||
pub const VERSION_STRING: &str = env!("GHOSTTY_VERSION_STRING");
|
||||
|
||||
/// The git commit hash (`build_config.version.build`), if the version has
|
||||
/// one (tagged releases do not).
|
||||
pub const COMMIT: Option<&str> = {
|
||||
let c = env!("GHOSTTY_COMMIT");
|
||||
if c.is_empty() { None } else { Some(c) }
|
||||
};
|
||||
|
||||
/// `build_config.release_channel` tag name: `stable` or `tip`.
|
||||
pub const RELEASE_CHANNEL: &str = env!("GHOSTTY_RELEASE_CHANNEL");
|
||||
|
||||
/// `{}` of `builtin.mode` (`.Debug`, `.ReleaseSafe`, `.ReleaseFast`).
|
||||
pub const BUILD_MODE: &str = env!("GHOSTTY_BUILD_MODE");
|
||||
|
||||
/// A semantic version (Zig: `std.SemanticVersion`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SemanticVersion<'a> {
|
||||
pub major: u64,
|
||||
pub minor: u64,
|
||||
pub patch: u64,
|
||||
/// Pre-release part (after `-`), e.g. `main`.
|
||||
pub pre: Option<&'a str>,
|
||||
/// Build metadata (after `+`), e.g. the commit hash.
|
||||
pub build: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Parse `X.Y.Z[-pre][+build]`.
|
||||
pub fn parse(v: &str) -> Option<SemanticVersion<'_>> {
|
||||
let (rest, build) = match v.split_once('+') {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (v, None),
|
||||
};
|
||||
let (core, pre) = match rest.split_once('-') {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (rest, None),
|
||||
};
|
||||
let mut it = core.split('.');
|
||||
let major = it.next()?.parse().ok()?;
|
||||
let minor = it.next()?.parse().ok()?;
|
||||
let patch = it.next()?.parse().ok()?;
|
||||
if it.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(SemanticVersion {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
pre,
|
||||
build,
|
||||
})
|
||||
}
|
||||
|
||||
/// `build_config.version` parsed from [`VERSION_STRING`].
|
||||
pub fn version() -> Option<SemanticVersion<'static>> {
|
||||
parse(VERSION_STRING)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_string_is_semver() {
|
||||
let v = version().expect("derived version must be semantic");
|
||||
assert!(v.major >= 1);
|
||||
assert_eq!(v.build, COMMIT.filter(|_| v.build.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_matches_prerelease() {
|
||||
let v = version().unwrap();
|
||||
assert_eq!(RELEASE_CHANNEL == "tip", v.pre.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cases() {
|
||||
assert_eq!(
|
||||
parse("1.3.1"),
|
||||
Some(SemanticVersion {
|
||||
major: 1,
|
||||
minor: 3,
|
||||
patch: 1,
|
||||
pre: None,
|
||||
build: None
|
||||
})
|
||||
);
|
||||
let v = parse("1.4.0-main+abc1234").unwrap();
|
||||
assert_eq!((v.pre, v.build), (Some("main"), Some("abc1234")));
|
||||
assert_eq!(parse("1.3"), None);
|
||||
assert_eq!(parse("1.3.1.2"), None);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+2
-133
@@ -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<String> {
|
||||
let out = Command::new(cmd)
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.ok()?;
|
||||
out.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&out.stdout).trim_end().to_string())
|
||||
}
|
||||
|
||||
/// The `app-version` from `[workspace.metadata.ghostty]` of the workspace
|
||||
/// manifest (two levels above this crate).
|
||||
fn app_version(manifest_dir: &Path) -> (u64, u64, u64) {
|
||||
let root = manifest_dir.join("../../Cargo.toml");
|
||||
println!("cargo:rerun-if-changed={}", root.display());
|
||||
let text = std::fs::read_to_string(root).unwrap_or_default();
|
||||
let v = text
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("app-version"))
|
||||
.and_then(|l| l.split('"').nth(1))
|
||||
.unwrap_or("1.3.1");
|
||||
let mut it = v.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
|
||||
(
|
||||
it.next().unwrap_or(1),
|
||||
it.next().unwrap_or(3),
|
||||
it.next().unwrap_or(1),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns `(version_string, commit)` mirroring `Config.zig`.
|
||||
fn detect_version(manifest_dir: &Path) -> (String, Option<String>) {
|
||||
println!("cargo:rerun-if-env-changed=GHOSTTY_VERSION_STRING");
|
||||
if let Ok(v) = std::env::var("GHOSTTY_VERSION_STRING") {
|
||||
let commit = v.split_once('+').map(|(_, b)| b.to_string());
|
||||
return (v, commit);
|
||||
}
|
||||
|
||||
let (maj, min, pat) = app_version(manifest_dir);
|
||||
let base = format!("{maj}.{min}.{pat}");
|
||||
|
||||
// Git unavailable or not a repository: unknown dev version.
|
||||
let Some(branch) = run("git", &["rev-parse", "--abbrev-ref", "HEAD"], manifest_dir) else {
|
||||
return (format!("{base}-dev+0000000"), Some("0000000".into()));
|
||||
};
|
||||
if let Some(git_dir) = run("git", &["rev-parse", "--absolute-git-dir"], manifest_dir) {
|
||||
let git_dir = PathBuf::from(git_dir);
|
||||
println!("cargo:rerun-if-changed={}", git_dir.join("HEAD").display());
|
||||
println!("cargo:rerun-if-changed={}", git_dir.join("refs").display());
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
git_dir.join("packed-refs").display()
|
||||
);
|
||||
}
|
||||
let hash = run(
|
||||
"git",
|
||||
&[
|
||||
"-c",
|
||||
"log.showSignature=false",
|
||||
"log",
|
||||
"--pretty=format:%h",
|
||||
"-n",
|
||||
"1",
|
||||
],
|
||||
manifest_dir,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let tag = run(
|
||||
"git",
|
||||
&["describe", "--exact-match", "--tags"],
|
||||
manifest_dir,
|
||||
)
|
||||
.filter(|t| !t.is_empty());
|
||||
|
||||
if let Some(tag) = tag {
|
||||
// Tip releases behave just like any other pre-release so we skip.
|
||||
if tag != "tip" {
|
||||
assert!(
|
||||
tag == format!("v{base}"),
|
||||
"tagged releases must be in vX.Y.Z format matching the workspace app-version"
|
||||
);
|
||||
return (base, None);
|
||||
}
|
||||
}
|
||||
|
||||
// Slashes in branch names are replaced like in `GitVersion.zig`.
|
||||
let branch = branch.replace('/', "-");
|
||||
(format!("{base}-{branch}+{hash}"), Some(hash))
|
||||
}
|
||||
|
||||
/// The Zig optimize-mode name for the Cargo profile being built.
|
||||
fn build_mode() -> &'static str {
|
||||
// The profile name is the directory below `target/` that holds OUT_DIR.
|
||||
let out = std::env::var("OUT_DIR").unwrap_or_default();
|
||||
let profile = Path::new(&out)
|
||||
.ancestors()
|
||||
.skip_while(|p| p.file_name().is_none_or(|n| n != "build"))
|
||||
.nth(1)
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let debug_assertions = std::env::var_os("CARGO_CFG_DEBUG_ASSERTIONS").is_some();
|
||||
let opt = std::env::var("OPT_LEVEL").unwrap_or_default();
|
||||
if profile == "production" || !debug_assertions {
|
||||
".ReleaseFast"
|
||||
} else if opt == "0" {
|
||||
".Debug"
|
||||
} else {
|
||||
".ReleaseSafe"
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates the per-action help table (Zig: `helpgen.zig` `genActions`):
|
||||
/// 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());
|
||||
}
|
||||
@@ -53,7 +53,7 @@ pub trait ActionEnum: Sized + Copy {
|
||||
|
||||
/// Detect the action from the process CLI args (Zig: `detectArgs`).
|
||||
pub fn detect_args<E: ActionEnum>() -> Result<Option<E>, DetectError> {
|
||||
detect_iter(ghostty_os::args::collect_lossy())
|
||||
detect_iter(ghostty_os::args::collect_config_strings())
|
||||
}
|
||||
|
||||
/// Detect the action from any iterator. Each iterator value should yield
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -93,8 +93,7 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
|
||||
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<u8, Error> {
|
||||
\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<u8, Error> {
|
||||
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<u8, Error> {
|
||||
\n\
|
||||
Editor: {editor}\n\
|
||||
Path: {path}\n"
|
||||
)?;
|
||||
);
|
||||
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&msg))?;
|
||||
Ok(1)
|
||||
}
|
||||
@@ -243,6 +243,28 @@ pub fn detect(args: &[String]) -> Result<Option<Action>, DetectError> {
|
||||
detect_iter::<Action, _, _>(args.iter())
|
||||
}
|
||||
|
||||
/// Convert raw OS arguments (Zig `[]const u8` argv) into the argument strings
|
||||
/// used by the CLI layers. No bytes are lost: invalid UTF-8 bytes use the
|
||||
/// reversible escape encoding of `ghostty_os::osbytes`, which the config
|
||||
/// layer (`cli_args`) decodes back to the original bytes.
|
||||
#[must_use]
|
||||
pub fn os_args_to_strings(args: &[std::ffi::OsString]) -> Vec<String> {
|
||||
args.iter()
|
||||
.map(|a| ghostty_os::osbytes::os_to_config_string(a))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`detect`] over raw OS arguments.
|
||||
pub fn detect_os(args: &[std::ffi::OsString]) -> Result<Option<Action>, DetectError> {
|
||||
detect(&os_args_to_strings(args))
|
||||
}
|
||||
|
||||
/// [`run_from_args`] over raw OS arguments (byte-preserving).
|
||||
#[must_use]
|
||||
pub fn run_from_os_args(args: &[std::ffi::OsString]) -> Option<u8> {
|
||||
run_from_args(&os_args_to_strings(args))
|
||||
}
|
||||
|
||||
/// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the
|
||||
/// 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 {
|
||||
|
||||
@@ -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<ThemeListElement> {
|
||||
let mut themes = vec![];
|
||||
let mut it = LocationIterator::new(resources_dir);
|
||||
while let Some((location, dir)) = it.next_location() {
|
||||
let rd = match std::fs::read_dir(&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<ThemeListElement> {
|
||||
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<String> {
|
||||
@@ -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(&[
|
||||
|
||||
@@ -1408,14 +1408,13 @@ fn error_name(e: &ghostty_config::config::Error) -> String {
|
||||
pub fn resolve_auto_theme_path() -> Result<String, Error> {
|
||||
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()?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u8, Error> {
|
||||
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 {
|
||||
|
||||
@@ -39,8 +39,10 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result<u8, Error> {
|
||||
|
||||
// If a config path is passed, validate it, otherwise validate default configs
|
||||
if 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
let b = s.as_bytes();
|
||||
parse_zig_string_literal_bytes(s).map(|b| ghostty_os::osbytes::bytes_to_config_string(&b))
|
||||
}
|
||||
|
||||
/// Byte-exact [`parse_zig_string_literal`] (Zig `std.zig.string_literal`
|
||||
/// decoding yields a `[]u8`).
|
||||
#[must_use]
|
||||
pub fn parse_zig_string_literal_bytes(s: &str) -> Option<Vec<u8>> {
|
||||
let b = ghostty_os::osbytes::config_str_to_bytes(s);
|
||||
let b = b.as_slice();
|
||||
if b.len() < 2 || b[0] != b'"' || b[b.len() - 1] != b'"' {
|
||||
return None;
|
||||
}
|
||||
@@ -395,7 +406,7 @@ pub fn parse_zig_string_literal(s: &str) -> Option<String> {
|
||||
}
|
||||
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<I: Iterator<Item = String>> ArgIter for ArgsIterator<I> {
|
||||
/// Create an args iterator for the process args. This will skip argv0.
|
||||
#[must_use]
|
||||
pub fn args_iterator() -> ArgsIterator<std::iter::Skip<std::vec::IntoIter<String>>> {
|
||||
// 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<R: BufRead> LineIterator<R> {
|
||||
|
||||
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}'));
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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 {
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn legacy_default_app_support_path() -> Result<String, xdg::Error> {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn app_support_dir(sub_path: &str) -> Result<String, xdg::Error> {
|
||||
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<File, OpenFileError> {
|
||||
assert!(std::path::Path::new(path).is_absolute());
|
||||
assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute());
|
||||
|
||||
let file = File::open(path).map_err(|err| {
|
||||
// `path` may carry the reversible byte escape of non-UTF-8 names.
|
||||
let file = File::open(ghostty_os::osbytes::config_str_to_os(path)).map_err(|err| {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
OpenFileError::FileNotFound
|
||||
} else {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<OpenedTheme> {
|
||||
// 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<File> {
|
||||
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 {
|
||||
|
||||
@@ -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<std::ffi::OsString> {
|
||||
match ARGV_OVERRIDE.lock().ok().and_then(|g| g.clone()) {
|
||||
Some(v) => v.into_iter(),
|
||||
None => std::env::args_os().collect::<Vec<_>>().into_iter(),
|
||||
}
|
||||
}
|
||||
|
||||
static ARGV_OVERRIDE: std::sync::Mutex<Option<Vec<std::ffi::OsString>>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
/// Replace the process arguments seen by [`iterator`] (Zig's assignment to
|
||||
/// `std.os.argv` in `ghostty_init`). Has no effect on macOS, where the
|
||||
/// arguments always come from `NSProcessInfo` (as in Zig).
|
||||
pub fn set_argv(args: Vec<std::ffi::OsString>) {
|
||||
if let Ok(mut g) = ARGV_OVERRIDE.lock() {
|
||||
*g = Some(args);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator over the command line arguments.
|
||||
@@ -27,15 +46,25 @@ pub fn iterator() -> std::vec::IntoIter<std::ffi::OsString> {
|
||||
.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<String> {
|
||||
lossy(iterator())
|
||||
/// Collect the process arguments as raw OS strings (no conversion, never
|
||||
/// panics on non-UTF-8 bytes).
|
||||
pub fn collect_os() -> Vec<std::ffi::OsString> {
|
||||
iterator().collect()
|
||||
}
|
||||
|
||||
fn lossy(it: impl Iterator<Item = std::ffi::OsString>) -> Vec<String> {
|
||||
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<Vec<u8>> {
|
||||
iterator().map(|a| crate::osbytes::os_bytes(&a)).collect()
|
||||
}
|
||||
|
||||
/// Collect the process arguments as `String`s using the reversible
|
||||
/// byte-escape encoding of [`crate::osbytes`]: no bytes are lost, invalid
|
||||
/// UTF-8 bytes are carried as private-use escapes that the config layer
|
||||
/// decodes back to the original bytes.
|
||||
pub fn collect_config_strings() -> Vec<String> {
|
||||
iterator()
|
||||
.map(|a| crate::osbytes::os_to_config_string(&a))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
current_os(pid).map(|p| crate::osbytes::os_to_config_string(&p))
|
||||
}
|
||||
|
||||
/// Byte-preserving [`current`].
|
||||
pub fn current_os(pid: u32) -> Option<std::ffi::OsString> {
|
||||
// Read our cgroup by opening /proc/<pid>/cgroup and reading the first
|
||||
// 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<String> {
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
@@ -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<OsString> {
|
||||
std::env::var_os(key)
|
||||
}
|
||||
|
||||
/// Gets the raw value of an environment variable. Returns None if not found
|
||||
/// or the value is empty.
|
||||
pub fn getenv_not_empty_os(key: &str) -> Option<OsString> {
|
||||
getenv_os(key).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Gets the value of an environment variable, or None if not found. A
|
||||
/// non-UTF-8 value is still present: its invalid bytes are carried through
|
||||
/// the reversible [`crate::osbytes`] encoding.
|
||||
pub fn getenv(key: &str) -> Option<String> {
|
||||
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() {
|
||||
|
||||
@@ -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<String> {
|
||||
/// 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<Rlimit> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Unsupported-platform fallback: no-op like Zig.
|
||||
#[cfg(not(unix))]
|
||||
pub fn restore_max_files(_lim: Rlimit) {}
|
||||
|
||||
/// Return the recommended path for temporary files (byte-preserving).
|
||||
pub fn alloc_tmp_dir_os() -> Option<std::path::PathBuf> {
|
||||
if cfg!(windows) {
|
||||
// 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<String> {
|
||||
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() {
|
||||
|
||||
@@ -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<Option<String>, Error> {
|
||||
home_with_capacity(MAX_PATH_BYTES)
|
||||
}
|
||||
|
||||
/// Byte-preserving [`home`].
|
||||
pub fn home_os() -> Result<Option<OsString>, Error> {
|
||||
home_os_with_capacity(MAX_PATH_BYTES)
|
||||
}
|
||||
|
||||
/// Like [`home`] but emulates the fixed output buffer of the Zig version:
|
||||
/// errors with `BufferTooSmall` if the result is longer than `cap` bytes.
|
||||
#[cfg(unix)]
|
||||
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, 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<Option<OsString>, 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<Option<String>, 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<Option<String>, 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<Option<String>, 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<Option<String>, Error> {
|
||||
/// Windows: `%HOMEDRIVE%%HOMEPATH%` (`homeWindows`). Returns `None` if
|
||||
/// either variable is missing.
|
||||
#[cfg(windows)]
|
||||
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, 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<Option<OsString>, Error> {
|
||||
let drive = std::env::var_os("HOMEDRIVE").map(|v| v.to_string_lossy().into_owned());
|
||||
let path = std::env::var_os("HOMEPATH").map(|v| v.to_string_lossy().into_owned());
|
||||
Ok(home_windows(drive.as_deref(), path.as_deref(), cap)?.map(OsString::from))
|
||||
}
|
||||
|
||||
/// 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<String, ExpandError> {
|
||||
expand_home_with_capacity(path, MAX_PATH_BYTES)
|
||||
}
|
||||
|
||||
/// Byte-preserving [`expand_home`].
|
||||
pub fn expand_home_os(path: &OsStr) -> Result<OsString, ExpandError> {
|
||||
expand_home_os_with_capacity(path, MAX_PATH_BYTES)
|
||||
}
|
||||
|
||||
/// Like [`expand_home`] with an explicit output capacity (emulating the
|
||||
/// caller-provided buffer of the Zig version).
|
||||
pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, ExpandError> {
|
||||
expand_home_os_with_capacity(&osbytes::config_str_to_os(path), cap)
|
||||
.map(|p| osbytes::os_to_config_string(&p))
|
||||
}
|
||||
|
||||
/// Windows: `~/` is not an idiom generally used on Windows.
|
||||
#[cfg(windows)]
|
||||
pub fn expand_home_with_capacity(path: &str, _cap: usize) -> Result<String, ExpandError> {
|
||||
// `~/` is not an idiom generally used on Windows
|
||||
pub fn expand_home_os_with_capacity(path: &OsStr, _cap: usize) -> Result<OsString, ExpandError> {
|
||||
Ok(path.to_owned())
|
||||
}
|
||||
|
||||
/// See the Windows variant above.
|
||||
#[cfg(unix)]
|
||||
pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, ExpandError> {
|
||||
if !path.starts_with("~/") {
|
||||
pub fn expand_home_os_with_capacity(path: &OsStr, cap: usize) -> Result<OsString, ExpandError> {
|
||||
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"), || {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// Byte-preserving entry (same shape as the Unix one).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct EntryOs {
|
||||
pub shell: Option<std::ffi::OsString>,
|
||||
pub home: Option<std::ffi::OsString>,
|
||||
pub name: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
/// No passwd database on Windows: always an empty entry.
|
||||
pub fn get() -> std::io::Result<Entry> {
|
||||
Ok(Entry::default())
|
||||
}
|
||||
|
||||
/// See [`get`].
|
||||
pub fn get_os() -> std::io::Result<EntryOs> {
|
||||
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"))]
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Byte-preserving conversions between OS strings and Rust `String`s.
|
||||
//!
|
||||
//! Zig treats paths, arguments and environment values as byte slices. Rust
|
||||
//! `String` cannot hold invalid UTF-8, so the String based layers (the config
|
||||
//! system) use a reversible encoding that is a true bijection on byte
|
||||
//! strings:
|
||||
//!
|
||||
//! * the escape character is the Unicode noncharacter `U+FDD0` (never used
|
||||
//! in real text);
|
||||
//! * every byte that is not part of a valid UTF-8 sequence (always
|
||||
//! `0x80..=0xFF`) is encoded as `U+FDD0` followed by the char `U+00bb`
|
||||
//! (`bb` = the byte), e.g. `0xFF` becomes `U+FDD0 U+00FF`;
|
||||
//! * a literal `U+FDD0` in valid input is escaped as `U+FDD0 U+FDD0`.
|
||||
//!
|
||||
//! Hence every valid UTF-8 string without `U+FDD0` is encoded unchanged, and
|
||||
//! `config_str_to_bytes(bytes_to_config_string(x)) == x` for all byte strings.
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
|
||||
const ESC: char = '\u{FDD0}';
|
||||
|
||||
/// Encode arbitrary bytes into a `String`, escaping invalid UTF-8 bytes.
|
||||
pub fn bytes_to_config_string(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len());
|
||||
for chunk in bytes.utf8_chunks() {
|
||||
for c in chunk.valid().chars() {
|
||||
out.push(c);
|
||||
if c == ESC {
|
||||
out.push(ESC);
|
||||
}
|
||||
}
|
||||
for &b in chunk.invalid() {
|
||||
out.push(ESC);
|
||||
out.push(char::from(b));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inverse of [`bytes_to_config_string`].
|
||||
pub fn config_str_to_bytes(s: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(s.len());
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == ESC {
|
||||
match chars.peek().copied() {
|
||||
Some(ESC) => {
|
||||
chars.next();
|
||||
}
|
||||
Some(n) if (0x80..=0xFF).contains(&(n as u32)) => {
|
||||
chars.next();
|
||||
out.push(n as u32 as u8);
|
||||
continue;
|
||||
}
|
||||
// Not produced by the encoder: keep the char as-is.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut buf = [0u8; 4];
|
||||
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The raw bytes of an OS string (Unix; lossless). On other platforms the
|
||||
/// string is converted via its UTF-8 (WTF-8) form.
|
||||
#[cfg(unix)]
|
||||
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
s.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
|
||||
s.to_string_lossy().into_owned().into_bytes()
|
||||
}
|
||||
|
||||
/// Build an OS string from raw bytes.
|
||||
#[cfg(unix)]
|
||||
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
OsString::from_vec(b)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
|
||||
OsString::from(String::from_utf8_lossy(&b).into_owned())
|
||||
}
|
||||
|
||||
/// OS string to reversible `String` (see the module docs).
|
||||
pub fn os_to_config_string(s: &OsStr) -> String {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
bytes_to_config_string(s.as_bytes())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
s.to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`os_to_config_string`].
|
||||
pub fn config_str_to_os(s: &str) -> OsString {
|
||||
os_from_bytes(config_str_to_bytes(s))
|
||||
}
|
||||
|
||||
/// Path to reversible `String` (see the module docs).
|
||||
pub fn path_to_config_string(p: &std::path::Path) -> String {
|
||||
os_to_config_string(p.as_os_str())
|
||||
}
|
||||
|
||||
/// Inverse of [`path_to_config_string`].
|
||||
pub fn config_str_to_path(s: &str) -> std::path::PathBuf {
|
||||
std::path::PathBuf::from(config_str_to_os(s))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_invalid_bytes() {
|
||||
let raw = vec![b'a', 0xff, b'b', 0xc3, 0xa9, 0xc3];
|
||||
let s = bytes_to_config_string(&raw);
|
||||
assert_eq!(s, "a\u{fdd0}\u{ff}b\u{e9}\u{fdd0}\u{c3}");
|
||||
assert_eq!(config_str_to_bytes(&s), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_utf8_unchanged() {
|
||||
let s = "h\u{e9}llo \u{1f600}";
|
||||
assert_eq!(bytes_to_config_string(s.as_bytes()), s);
|
||||
assert_eq!(config_str_to_bytes(s), s.as_bytes());
|
||||
// Nerd Font icons, including the old escape range, are untouched.
|
||||
let icons = "\u{f500}\u{f780}\u{f7ff}\u{fd46}\u{e0b0}";
|
||||
assert_eq!(bytes_to_config_string(icons.as_bytes()), icons);
|
||||
assert_eq!(config_str_to_bytes(icons), icons.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_escape_char_is_doubled() {
|
||||
let s = "a\u{fdd0}\u{ff}";
|
||||
let e = bytes_to_config_string(s.as_bytes());
|
||||
assert_eq!(e, "a\u{fdd0}\u{fdd0}\u{ff}");
|
||||
assert_eq!(config_str_to_bytes(&e), s.as_bytes());
|
||||
}
|
||||
|
||||
/// Tiny xorshift generator (no external rand dependency).
|
||||
fn rng(seed: &mut u64) -> u64 {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 7;
|
||||
*seed ^= *seed << 17;
|
||||
*seed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_bytes_round_trip() {
|
||||
// Fragments that stress the boundaries: icon ranges, the escape
|
||||
// char, its doubled form, and truncated/invalid sequences.
|
||||
let frags: [&[u8]; 12] = [
|
||||
"\u{f780}".as_bytes(),
|
||||
"\u{f7ff}".as_bytes(),
|
||||
"\u{f500}".as_bytes(),
|
||||
"\u{fd46}".as_bytes(),
|
||||
"\u{fdd0}".as_bytes(),
|
||||
"\u{fdd0}\u{fdd0}".as_bytes(),
|
||||
"\u{fdd0}\u{ff}".as_bytes(),
|
||||
"\u{1f600}".as_bytes(),
|
||||
b"\xef\xb7",
|
||||
b"\xff",
|
||||
b"\xc3",
|
||||
b"abc",
|
||||
];
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
for _ in 0..5000 {
|
||||
let mut x = Vec::new();
|
||||
for _ in 0..(rng(&mut seed) % 12) {
|
||||
match rng(&mut seed) % 3 {
|
||||
0 => x.push(rng(&mut seed) as u8),
|
||||
_ => x.extend_from_slice(frags[(rng(&mut seed) % 12) as usize]),
|
||||
}
|
||||
}
|
||||
let enc = bytes_to_config_string(&x);
|
||||
assert_eq!(config_str_to_bytes(&enc), x, "bytes {x:?}");
|
||||
if let Ok(valid) = std::str::from_utf8(&x)
|
||||
&& !valid.contains('\u{fdd0}')
|
||||
{
|
||||
assert_eq!(enc, valid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_valid_strings_round_trip() {
|
||||
// Every string (valid UTF-8) decodes back to itself after encoding.
|
||||
let mut seed = 0x1234_5678_9ABC_DEF1u64;
|
||||
for _ in 0..5000 {
|
||||
let mut s = String::new();
|
||||
for _ in 0..(rng(&mut seed) % 10) {
|
||||
let cp = match rng(&mut seed) % 4 {
|
||||
0 => 0xF500 + (rng(&mut seed) % 0x847) as u32,
|
||||
1 => 0xFDD0,
|
||||
2 => 0x80 + (rng(&mut seed) % 0x80) as u32,
|
||||
_ => 0x20 + (rng(&mut seed) % 0x5f) as u32,
|
||||
};
|
||||
s.push(char::from_u32(cp).unwrap());
|
||||
}
|
||||
let enc = bytes_to_config_string(s.as_bytes());
|
||||
assert_eq!(config_str_to_bytes(&enc), s.as_bytes(), "{s:?}");
|
||||
if !s.contains('\u{fdd0}') {
|
||||
assert_eq!(enc, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn os_roundtrip() {
|
||||
let o = os_from_bytes(vec![0xff, b'/', 0x80]);
|
||||
let s = os_to_config_string(&o);
|
||||
assert_eq!(config_str_to_os(&s), o);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// Get the passwd entry for the currently executing user.
|
||||
/// Byte-preserving [`Entry`] (Zig slices are arbitrary bytes).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct EntryOs {
|
||||
pub shell: Option<OsString>,
|
||||
pub home: Option<OsString>,
|
||||
pub name: Option<OsString>,
|
||||
}
|
||||
|
||||
impl From<EntryOs> for Entry {
|
||||
fn from(e: EntryOs) -> Self {
|
||||
let f = |v: Option<OsString>| v.map(|v| osbytes::os_to_config_string(&v));
|
||||
Entry {
|
||||
shell: f(e.shell),
|
||||
home: f(e.home),
|
||||
name: f(e.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the passwd entry for the currently executing user. Non-UTF-8 bytes
|
||||
/// are carried via the reversible [`crate::osbytes`] encoding; see
|
||||
/// [`get_os`] for the raw bytes.
|
||||
pub fn get() -> std::io::Result<Entry> {
|
||||
get_os().map(Entry::from)
|
||||
}
|
||||
|
||||
/// Byte-preserving [`get`].
|
||||
pub fn get_os() -> std::io::Result<EntryOs> {
|
||||
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<Entry> {
|
||||
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<Entry> {
|
||||
/// `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<Entry> {
|
||||
fn host_entry(name: &str) -> std::io::Result<EntryOs> {
|
||||
use std::io::Read;
|
||||
|
||||
use rustix::{
|
||||
@@ -112,17 +141,20 @@ fn host_entry(name: &str) -> std::io::Result<Entry> {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(parse_getent(&String::from_utf8_lossy(&output)))
|
||||
Ok(parse_getent(&output))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
/// Shell and home are the last two `:`-separated fields of the output.
|
||||
#[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
|
||||
|
||||
@@ -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<Option<String>, Error> {
|
||||
let cmd = crate::osbytes::config_str_to_os(cmd);
|
||||
Ok(expand_os(&cmd)?.map(|p| crate::osbytes::os_to_config_string(p.as_os_str())))
|
||||
}
|
||||
|
||||
/// Byte-preserving [`expand`]: `$PATH` and the command are handled as raw
|
||||
/// bytes, so executables in directories with non-UTF-8 names are found.
|
||||
pub fn expand_os(cmd: &OsStr) -> Result<Option<PathBuf>, Error> {
|
||||
let cmd_bytes = crate::osbytes::os_bytes(cmd);
|
||||
|
||||
// If the command already contains a slash, then we return it as-is
|
||||
// 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()));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
self.app().map(|p| PathBuf::from(config_str_to_os(p)))
|
||||
}
|
||||
|
||||
/// Byte-preserving [`ResourcesDir::host`].
|
||||
pub fn host_os(&self) -> Option<PathBuf> {
|
||||
self.host().map(|p| PathBuf::from(config_str_to_os(p)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentinels we look for in the path to know we've found the resources
|
||||
@@ -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<ResourcesDir> {
|
||||
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<ResourcesDir> {
|
||||
))
|
||||
}
|
||||
|
||||
fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> ResourcesDir {
|
||||
fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<OsString>) -> ResourcesDir {
|
||||
// Use the GHOSTTY_RESOURCES_DIR environment variable in release builds.
|
||||
//
|
||||
// In debug builds we try using terminfo detection first instead, since
|
||||
@@ -67,7 +86,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
||||
// freshly built ones under zig-out/share/ghostty.
|
||||
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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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 `<workspace>/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<String> {
|
||||
fn dev_resources_dir(exe: &Path) -> Option<PathBuf> {
|
||||
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<String> {
|
||||
.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<PathBuf> {
|
||||
let dir = base.join(sub);
|
||||
// Folder doesn't exist. If a different error happens its okay
|
||||
// we just ignore it and move on.
|
||||
dir.join(suffix).exists().then_some(dir)
|
||||
}
|
||||
|
||||
/// Little helper to check if the "base/sub/suffix" directory exists and
|
||||
@@ -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();
|
||||
|
||||
@@ -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<TempDir> {
|
||||
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).
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Z1 final pass: os / config / cli / root main (byte fidelity, i18n, build info)
|
||||
|
||||
Owner: Z1. Crates: `ghostty-os`, `ghostty-config`, `ghostty-cli`, new `ghostty-build-info`, root `src/main.rs`.
|
||||
|
||||
## Byte fidelity design
|
||||
|
||||
Zig paths/args/env are byte slices. The config system is `&str`/`String` based
|
||||
throughout, so rewriting it to a byte type was not feasible without changing
|
||||
hundreds of `ConfigField` impls. Instead:
|
||||
|
||||
* `ghostty_os::osbytes` is the single conversion module:
|
||||
* `bytes_to_config_string` / `config_str_to_bytes`, `os_to_config_string` /
|
||||
`config_str_to_os`, `path_to_config_string` / `config_str_to_path`,
|
||||
`os_bytes`, `os_from_bytes`.
|
||||
* The encoding is a true bijection on byte strings. Escape char = noncharacter
|
||||
`U+FDD0`; an invalid byte `b` becomes `U+FDD0` + char `U+00bb` (`0xFF` ->
|
||||
`U+FDD0 U+00FF`); a literal `U+FDD0` in valid input becomes `U+FDD0 U+FDD0`.
|
||||
Valid UTF-8 without `U+FDD0` is encoded unchanged (Nerd Font icons, incl.
|
||||
`U+F500..U+FD46`, are safe), and `decode(encode(x)) == x` for all bytes.
|
||||
Property tests (random bytes and strings, icon ranges, escape char) cover this.
|
||||
* Every os function that returned `String` now has a byte-exact OS-typed twin
|
||||
(`*_os`), and the `String` function is a thin wrapper using the reversible
|
||||
encoding (a present non-UTF-8 value is never "absent" and never U+FFFD).
|
||||
Existing callers keep compiling.
|
||||
* The config/cli layers decode back to OS strings at every filesystem / exec /
|
||||
stdout boundary they own.
|
||||
|
||||
## Audit rows fixed
|
||||
|
||||
| Row | Change |
|
||||
|---|---|
|
||||
| main_ghostty argv | `src/main.rs` collects `Vec<OsString>` (`os::args::collect_os`), dispatches with `ghostty_cli::ghostty::{detect_os, run_from_os_args}` (reversible encoding; no lossy). `collect_lossy` removed; `collect_bytes`, `collect_config_strings` added. |
|
||||
| cli/args.zig | `ArgsIterator` via `args::collect_config_strings`; `LineIterator` no longer `from_utf8_lossy`; `parse_zig_string_literal_bytes` returns raw bytes (`\xFF` stays `0xFF`), `parse_zig_string_literal` returns the reversible string. |
|
||||
| Config.zig | xdg-terminal-exec args and `cwd` keep bytes; `load_file`, config-file open, `load_reader` parent dir decode/encode paths. |
|
||||
| os/env | `getenv_os`, `getenv_not_empty_os`; `getenv*` return `Some` for non-UTF-8 values. |
|
||||
| os/homedir | `home_os`, `home_os_with_capacity`, `expand_home_os[_with_capacity]`; HOME, passwd and `cd && pwd` bytes kept (shell output trimmed as bytes). |
|
||||
| os/path | `path::expand_os` ($PATH split and lookup as bytes). |
|
||||
| os/passwd | `EntryOs`, `get_os` (native `shell`/`home`, flatpak `getent` output parsed as bytes). `Entry`/`get` unchanged shape (encoded strings). |
|
||||
| os/resourcesdir | `GHOSTTY_RESOURCES_DIR` read via `var_os`; exe-relative probing uses `Path`s (`maybe_dir_os`); `ResourcesDir::{app_os, host_os}`. |
|
||||
| os/file | `alloc_tmp_dir_os` (TMPDIR/TMP bytes, used by `TempDir`); `Rlimit` (unit struct), `fix_max_files` (-> `None`), `restore_max_files` (no-op) now exist on non-Unix targets like Zig's fallbacks. |
|
||||
| os/cgroup | `current_os`; `scope_matches` compares bytes. |
|
||||
| config/path | canonicalization, `resolve_lexical`, `theme.rs` locations use decoded paths and keep canonical (symlink-resolved) non-UTF-8 targets. |
|
||||
| cli/validate_config | canonicalizes the decoded option path and passes the reversible string to `load_file`. |
|
||||
| cli/list_themes | directory entries keep raw bytes (names/paths), plain listing writes raw bytes, `read_dir` on decoded dir; preview auto-theme file/clipboard copy decode bytes. |
|
||||
| cli edit_config / new_window | exec editor / canonical working directory keep bytes. |
|
||||
| os/i18n | `canonicalize_locale` is now an exact port of gnulib `gl_locale_name_canonicalize` (macOS flavour used by `_libintl_locale_name_canonicalize`): legacy table (131 names), langtag table (16), script table (4), `strlen==7` guard, dash->underscore; tables in `i18n_canon_table.rs` generated from gettext 1.0's `localename-unsafe.c`. `pa-Guru` -> `pa_IN`, `sr-Latn` -> `sr@latin`, `Brazilian Portuguese` -> `pt_BR`. |
|
||||
|
||||
Not touched (other owners): `ghostty-lib` `ghostty_init`/`config_capi`/`embedded` lossy
|
||||
conversions, GTK file drop, `Surface::pwd()` -- Z2. Z2 may use the `osbytes`
|
||||
helpers and `*_os` functions; `Entry`/`ResourcesDir` string fields carry the
|
||||
reversible encoding, so use `config_str_to_os` before using them as paths.
|
||||
|
||||
## Build info (single source of truth) -- Z1 owns, nobody else edits
|
||||
|
||||
New crate `crates/ghostty-build-info` (no deps, `build.rs` derived; moved out of
|
||||
`ghostty-cli/build.rs`, which now only keeps the action help table and
|
||||
`GHOSTTY_RUSTC_VERSION`). Added to `[workspace.dependencies]`.
|
||||
|
||||
```rust
|
||||
ghostty_build_info::VERSION_STRING // &str "1.3.1" or "1.3.1-<branch>+<hash>"; env GHOSTTY_VERSION_STRING overrides
|
||||
ghostty_build_info::COMMIT // Option<&str>
|
||||
ghostty_build_info::RELEASE_CHANNEL // "stable" | "tip"
|
||||
ghostty_build_info::BUILD_MODE // ".Debug" | ".ReleaseSafe" | ".ReleaseFast"
|
||||
ghostty_build_info::{version(), parse(), SemanticVersion}
|
||||
```
|
||||
|
||||
`ghostty_cli::build_info::Default` and `ghostty_cli::build_info::derived`
|
||||
re-export/use it. For Z2: add `ghostty-build-info.workspace = true` to
|
||||
termio/apprt-gtk/crash and use `VERSION_STRING` for XTVERSION
|
||||
(`stream_handler.rs`), `TERM_PROGRAM_VERSION` (`exec*.rs`), the About dialog
|
||||
(`window.rs`), and crash `release` (`sentry.rs`; `RELEASE_CHANNEL`/`COMMIT`
|
||||
for tags).
|
||||
|
||||
## Tests added
|
||||
|
||||
`osbytes` (3), env non-UTF-8, homedir non-UTF-8, path non-UTF-8 dir lookup, passwd getent bytes,
|
||||
resourcesdir non-UTF-8 env + exe dir, file tmpdir bytes, cgroup, i18n tables,
|
||||
cli_args (`\xFF` raw byte, LineIterator), config path symlink to non-UTF-8 target,
|
||||
validate_config symlink, list_themes non-UTF-8 entry, ghostty `os_args`, build-info (3).
|
||||
Reference in new issue
Block a user