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
3f3f6680a5
commit
8519f1ba1b
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]
|
[dependencies]
|
||||||
flate2 = "1.1.10"
|
flate2 = "1.1.10"
|
||||||
|
ghostty-build-info.workspace = true
|
||||||
ghostty-apprt.workspace = true
|
ghostty-apprt.workspace = true
|
||||||
ghostty-config.workspace = true
|
ghostty-config.workspace = true
|
||||||
ghostty-crash.workspace = true
|
ghostty-crash.workspace = true
|
||||||
|
|||||||
+2
-133
@@ -1,130 +1,15 @@
|
|||||||
//! Build metadata for `+version` and the generated docs.
|
//! Build metadata for `+version` and the generated docs.
|
||||||
//!
|
//!
|
||||||
//! * the compiler version (Zig: `builtin.zig_version_string`)
|
//! * the compiler version (Zig: `builtin.zig_version_string`)
|
||||||
//! * the app version, release channel and commit (Zig: `build/Config.zig`
|
|
||||||
//! version detection + `build/GitVersion.zig`)
|
|
||||||
//! * the build mode (Zig: `builtin.mode`)
|
|
||||||
//!
|
//!
|
||||||
//! The version can be overridden with `GHOSTTY_VERSION_STRING` (Zig:
|
//! The app version, channel, commit and build mode live in
|
||||||
//! `-Dversion-string`), which must be a semantic version.
|
//! `ghostty-build-info` (single source of truth).
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::Command,
|
process::Command,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn run(cmd: &str, args: &[&str], dir: &Path) -> Option<String> {
|
|
||||||
let out = Command::new(cmd)
|
|
||||||
.args(args)
|
|
||||||
.current_dir(dir)
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
out.status
|
|
||||||
.success()
|
|
||||||
.then(|| String::from_utf8_lossy(&out.stdout).trim_end().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `app-version` from `[workspace.metadata.ghostty]` of the workspace
|
|
||||||
/// manifest (two levels above this crate).
|
|
||||||
fn app_version(manifest_dir: &Path) -> (u64, u64, u64) {
|
|
||||||
let root = manifest_dir.join("../../Cargo.toml");
|
|
||||||
println!("cargo:rerun-if-changed={}", root.display());
|
|
||||||
let text = std::fs::read_to_string(root).unwrap_or_default();
|
|
||||||
let v = text
|
|
||||||
.lines()
|
|
||||||
.find_map(|l| l.trim().strip_prefix("app-version"))
|
|
||||||
.and_then(|l| l.split('"').nth(1))
|
|
||||||
.unwrap_or("1.3.1");
|
|
||||||
let mut it = v.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
|
|
||||||
(
|
|
||||||
it.next().unwrap_or(1),
|
|
||||||
it.next().unwrap_or(3),
|
|
||||||
it.next().unwrap_or(1),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `(version_string, commit)` mirroring `Config.zig`.
|
|
||||||
fn detect_version(manifest_dir: &Path) -> (String, Option<String>) {
|
|
||||||
println!("cargo:rerun-if-env-changed=GHOSTTY_VERSION_STRING");
|
|
||||||
if let Ok(v) = std::env::var("GHOSTTY_VERSION_STRING") {
|
|
||||||
let commit = v.split_once('+').map(|(_, b)| b.to_string());
|
|
||||||
return (v, commit);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (maj, min, pat) = app_version(manifest_dir);
|
|
||||||
let base = format!("{maj}.{min}.{pat}");
|
|
||||||
|
|
||||||
// Git unavailable or not a repository: unknown dev version.
|
|
||||||
let Some(branch) = run("git", &["rev-parse", "--abbrev-ref", "HEAD"], manifest_dir) else {
|
|
||||||
return (format!("{base}-dev+0000000"), Some("0000000".into()));
|
|
||||||
};
|
|
||||||
if let Some(git_dir) = run("git", &["rev-parse", "--absolute-git-dir"], manifest_dir) {
|
|
||||||
let git_dir = PathBuf::from(git_dir);
|
|
||||||
println!("cargo:rerun-if-changed={}", git_dir.join("HEAD").display());
|
|
||||||
println!("cargo:rerun-if-changed={}", git_dir.join("refs").display());
|
|
||||||
println!(
|
|
||||||
"cargo:rerun-if-changed={}",
|
|
||||||
git_dir.join("packed-refs").display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let hash = run(
|
|
||||||
"git",
|
|
||||||
&[
|
|
||||||
"-c",
|
|
||||||
"log.showSignature=false",
|
|
||||||
"log",
|
|
||||||
"--pretty=format:%h",
|
|
||||||
"-n",
|
|
||||||
"1",
|
|
||||||
],
|
|
||||||
manifest_dir,
|
|
||||||
)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let tag = run(
|
|
||||||
"git",
|
|
||||||
&["describe", "--exact-match", "--tags"],
|
|
||||||
manifest_dir,
|
|
||||||
)
|
|
||||||
.filter(|t| !t.is_empty());
|
|
||||||
|
|
||||||
if let Some(tag) = tag {
|
|
||||||
// Tip releases behave just like any other pre-release so we skip.
|
|
||||||
if tag != "tip" {
|
|
||||||
assert!(
|
|
||||||
tag == format!("v{base}"),
|
|
||||||
"tagged releases must be in vX.Y.Z format matching the workspace app-version"
|
|
||||||
);
|
|
||||||
return (base, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slashes in branch names are replaced like in `GitVersion.zig`.
|
|
||||||
let branch = branch.replace('/', "-");
|
|
||||||
(format!("{base}-{branch}+{hash}"), Some(hash))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The Zig optimize-mode name for the Cargo profile being built.
|
|
||||||
fn build_mode() -> &'static str {
|
|
||||||
// The profile name is the directory below `target/` that holds OUT_DIR.
|
|
||||||
let out = std::env::var("OUT_DIR").unwrap_or_default();
|
|
||||||
let profile = Path::new(&out)
|
|
||||||
.ancestors()
|
|
||||||
.skip_while(|p| p.file_name().is_none_or(|n| n != "build"))
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|p| p.file_name())
|
|
||||||
.map(|n| n.to_string_lossy().into_owned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let debug_assertions = std::env::var_os("CARGO_CFG_DEBUG_ASSERTIONS").is_some();
|
|
||||||
let opt = std::env::var("OPT_LEVEL").unwrap_or_default();
|
|
||||||
if profile == "production" || !debug_assertions {
|
|
||||||
".ReleaseFast"
|
|
||||||
} else if opt == "0" {
|
|
||||||
".Debug"
|
|
||||||
} else {
|
|
||||||
".ReleaseSafe"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates the per-action help table (Zig: `helpgen.zig` `genActions`):
|
/// Generates the per-action help table (Zig: `helpgen.zig` `genActions`):
|
||||||
/// the doc comment of each action's `run` function becomes
|
/// the doc comment of each action's `run` function becomes
|
||||||
/// `Action::help_text`.
|
/// `Action::help_text`.
|
||||||
@@ -175,20 +60,4 @@ fn main() {
|
|||||||
.and_then(|s| s.split_whitespace().nth(1).map(str::to_owned))
|
.and_then(|s| s.split_whitespace().nth(1).map(str::to_owned))
|
||||||
.unwrap_or_else(|| "unknown".into());
|
.unwrap_or_else(|| "unknown".into());
|
||||||
println!("cargo:rustc-env=GHOSTTY_RUSTC_VERSION={version}");
|
println!("cargo:rustc-env=GHOSTTY_RUSTC_VERSION={version}");
|
||||||
|
|
||||||
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default());
|
|
||||||
let (version_string, commit) = detect_version(&manifest_dir);
|
|
||||||
// Release channel: stable unless the version has a pre-release part.
|
|
||||||
let channel = if version_string.contains('-') {
|
|
||||||
"tip"
|
|
||||||
} else {
|
|
||||||
"stable"
|
|
||||||
};
|
|
||||||
println!("cargo:rustc-env=GHOSTTY_VERSION_STRING={version_string}");
|
|
||||||
println!(
|
|
||||||
"cargo:rustc-env=GHOSTTY_COMMIT={}",
|
|
||||||
commit.unwrap_or_default()
|
|
||||||
);
|
|
||||||
println!("cargo:rustc-env=GHOSTTY_RELEASE_CHANNEL={channel}");
|
|
||||||
println!("cargo:rustc-env=GHOSTTY_BUILD_MODE={}", build_mode());
|
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ pub trait ActionEnum: Sized + Copy {
|
|||||||
|
|
||||||
/// Detect the action from the process CLI args (Zig: `detectArgs`).
|
/// Detect the action from the process CLI args (Zig: `detectArgs`).
|
||||||
pub fn detect_args<E: ActionEnum>() -> Result<Option<E>, DetectError> {
|
pub fn detect_args<E: ActionEnum>() -> Result<Option<E>, DetectError> {
|
||||||
detect_iter(ghostty_os::args::collect_lossy())
|
detect_iter(ghostty_os::args::collect_config_strings())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect the action from any iterator. Each iterator value should yield
|
/// Detect the action from any iterator. Each iterator value should yield
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Re-export of the derived version constants (`VERSION_STRING`, `COMMIT`,
|
||||||
|
/// `RELEASE_CHANNEL`, `BUILD_MODE`) from `ghostty-build-info`.
|
||||||
|
pub use ghostty_build_info as derived;
|
||||||
|
|
||||||
/// A `major.minor.micro` version (Zig: `gtk_version.zig` / `adw_version.zig`).
|
/// A `major.minor.micro` version (Zig: `gtk_version.zig` / `adw_version.zig`).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Version {
|
pub struct Version {
|
||||||
@@ -49,11 +53,11 @@ pub struct BuildInfo {
|
|||||||
impl Default for BuildInfo {
|
impl Default for BuildInfo {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
BuildInfo {
|
BuildInfo {
|
||||||
// Derived by `build.rs` (git describe / workspace app-version).
|
// Derived by `ghostty-build-info` (single source of truth).
|
||||||
version_string: env!("GHOSTTY_VERSION_STRING").to_string(),
|
version_string: ghostty_build_info::VERSION_STRING.to_string(),
|
||||||
commit: Some(env!("GHOSTTY_COMMIT").to_string()).filter(|c| !c.is_empty()),
|
commit: ghostty_build_info::COMMIT.map(str::to_string),
|
||||||
release_channel: env!("GHOSTTY_RELEASE_CHANNEL").to_string(),
|
release_channel: ghostty_build_info::RELEASE_CHANNEL.to_string(),
|
||||||
build_mode: env!("GHOSTTY_BUILD_MODE").to_string(),
|
build_mode: ghostty_build_info::BUILD_MODE.to_string(),
|
||||||
app_runtime: ".gtk".to_string(),
|
app_runtime: ".gtk".to_string(),
|
||||||
font_backend: ".fontconfig_freetype".to_string(),
|
font_backend: ".fontconfig_freetype".to_string(),
|
||||||
renderer: "renderer.generic.Renderer(renderer.OpenGL)".to_string(),
|
renderer: "renderer.generic.Renderer(renderer.OpenGL)".to_string(),
|
||||||
|
|||||||
@@ -93,8 +93,7 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
|
|||||||
let Some(editor) = find_editor() else {
|
let Some(editor) = find_editor() else {
|
||||||
// If we don't have `$EDITOR` set then we can't do anything
|
// If we don't have `$EDITOR` set then we can't do anything
|
||||||
// but we can still print a helpful message.
|
// but we can still print a helpful message.
|
||||||
write!(
|
let msg = String::from(
|
||||||
stderr,
|
|
||||||
"The $EDITOR or $VISUAL environment variable is not set or is empty.\n\
|
"The $EDITOR or $VISUAL environment variable is not set or is empty.\n\
|
||||||
This environment variable is required to edit the Ghostty configuration\n\
|
This environment variable is required to edit the Ghostty configuration\n\
|
||||||
via this CLI command.\n\
|
via this CLI command.\n\
|
||||||
@@ -104,17 +103,20 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
|
|||||||
\n\
|
\n\
|
||||||
If you prefer to edit the configuration file another way,\n\
|
If you prefer to edit the configuration file another way,\n\
|
||||||
you can find the configuration file at the following path:\n\
|
you can find the configuration file at the following path:\n\
|
||||||
\n"
|
\n",
|
||||||
)?;
|
);
|
||||||
|
|
||||||
// Output the path using the OSC8 sequence so that it is linked.
|
// Output the path using the OSC8 sequence so that it is linked.
|
||||||
writeln!(stderr, "\x1b]8;;file://{path}\x1b\\{path}\x1b]8;;\x1b\\")?;
|
// (Paths are raw bytes: decode the reversible escape when writing.)
|
||||||
|
let msg = format!("{msg}\x1b]8;;file://{path}\x1b\\{path}\x1b]8;;\x1b\\\n");
|
||||||
|
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&msg))?;
|
||||||
return Ok(1);
|
return Ok(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Flush before exec so no output is lost.
|
// Flush before exec so no output is lost.
|
||||||
let _ = stderr.flush();
|
let _ = stderr.flush();
|
||||||
let err = std::process::Command::new(&editor).arg(path).exec();
|
let err = std::process::Command::new(ghostty_os::osbytes::config_str_to_os(&editor))
|
||||||
|
.arg(ghostty_os::osbytes::config_str_to_os(path))
|
||||||
|
.exec();
|
||||||
|
|
||||||
// If we reached this point then exec failed.
|
// If we reached this point then exec failed.
|
||||||
let err = match err.kind() {
|
let err = match err.kind() {
|
||||||
@@ -122,8 +124,7 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
|
|||||||
std::io::ErrorKind::PermissionDenied => "error.AccessDenied".to_string(),
|
std::io::ErrorKind::PermissionDenied => "error.AccessDenied".to_string(),
|
||||||
_ => err.to_string(),
|
_ => err.to_string(),
|
||||||
};
|
};
|
||||||
write!(
|
let msg = format!(
|
||||||
stderr,
|
|
||||||
"Failed to execute the editor. Error code={err}.\n\
|
"Failed to execute the editor. Error code={err}.\n\
|
||||||
\n\
|
\n\
|
||||||
This is usually due to the executable path not existing, invalid\n\
|
This is usually due to the executable path not existing, invalid\n\
|
||||||
@@ -132,6 +133,7 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
|
|||||||
\n\
|
\n\
|
||||||
Editor: {editor}\n\
|
Editor: {editor}\n\
|
||||||
Path: {path}\n"
|
Path: {path}\n"
|
||||||
)?;
|
);
|
||||||
|
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&msg))?;
|
||||||
Ok(1)
|
Ok(1)
|
||||||
}
|
}
|
||||||
@@ -243,6 +243,28 @@ pub fn detect(args: &[String]) -> Result<Option<Action>, DetectError> {
|
|||||||
detect_iter::<Action, _, _>(args.iter())
|
detect_iter::<Action, _, _>(args.iter())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert raw OS arguments (Zig `[]const u8` argv) into the argument strings
|
||||||
|
/// used by the CLI layers. No bytes are lost: invalid UTF-8 bytes use the
|
||||||
|
/// reversible escape encoding of `ghostty_os::osbytes`, which the config
|
||||||
|
/// layer (`cli_args`) decodes back to the original bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn os_args_to_strings(args: &[std::ffi::OsString]) -> Vec<String> {
|
||||||
|
args.iter()
|
||||||
|
.map(|a| ghostty_os::osbytes::os_to_config_string(a))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`detect`] over raw OS arguments.
|
||||||
|
pub fn detect_os(args: &[std::ffi::OsString]) -> Result<Option<Action>, DetectError> {
|
||||||
|
detect(&os_args_to_strings(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`run_from_args`] over raw OS arguments (byte-preserving).
|
||||||
|
#[must_use]
|
||||||
|
pub fn run_from_os_args(args: &[std::ffi::OsString]) -> Option<u8> {
|
||||||
|
run_from_args(&os_args_to_strings(args))
|
||||||
|
}
|
||||||
|
|
||||||
/// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the
|
/// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the
|
||||||
/// action from `args` (the full argv), print the errors Ghostty prints for
|
/// action from `args` (the full argv), print the errors Ghostty prints for
|
||||||
/// invalid invocations and run the action.
|
/// invalid invocations and run the action.
|
||||||
@@ -338,6 +360,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn os_args_keep_non_utf8_bytes_and_still_dispatch() {
|
||||||
|
use std::os::unix::ffi::OsStringExt;
|
||||||
|
let args = vec![
|
||||||
|
std::ffi::OsString::from("ghostty"),
|
||||||
|
std::ffi::OsString::from_vec(b"--title=\xff".to_vec()),
|
||||||
|
std::ffi::OsString::from("+version"),
|
||||||
|
];
|
||||||
|
assert_eq!(Some(Action::Version), detect_os(&args).unwrap());
|
||||||
|
let strings = os_args_to_strings(&args);
|
||||||
|
assert_eq!(
|
||||||
|
ghostty_os::osbytes::config_str_to_bytes(&strings[1]),
|
||||||
|
b"--title=\xff"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn names_round_trip() {
|
fn names_round_trip() {
|
||||||
for a in Action::ALL {
|
for a in Action::ALL {
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ use ghostty_config::{
|
|||||||
config_struct,
|
config_struct,
|
||||||
theme::{Location, LocationIterator},
|
theme::{Location, LocationIterator},
|
||||||
};
|
};
|
||||||
|
use ghostty_os::osbytes::{
|
||||||
|
config_str_to_bytes,
|
||||||
|
config_str_to_path,
|
||||||
|
os_to_config_string,
|
||||||
|
path_to_config_string,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{error::Error, tui};
|
use crate::{error::Error, tui};
|
||||||
|
|
||||||
@@ -121,7 +127,7 @@ pub fn collect_themes(resources_dir: Option<&str>) -> Vec<ThemeListElement> {
|
|||||||
let mut themes = vec![];
|
let mut themes = vec![];
|
||||||
let mut it = LocationIterator::new(resources_dir);
|
let mut it = LocationIterator::new(resources_dir);
|
||||||
while let Some((location, dir)) = it.next_location() {
|
while let Some((location, dir)) = it.next_location() {
|
||||||
let rd = match std::fs::read_dir(&dir) {
|
let rd = match std::fs::read_dir(config_str_to_path(&dir)) {
|
||||||
Ok(rd) => rd,
|
Ok(rd) => rd,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -137,15 +143,14 @@ pub fn collect_themes(resources_dir: Option<&str>) -> Vec<ThemeListElement> {
|
|||||||
if !(ft.is_file() || ft.is_symlink()) {
|
if !(ft.is_file() || ft.is_symlink()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let name = entry.file_name().to_string_lossy().into_owned();
|
// Entry names are raw bytes: keep them via the reversible escape.
|
||||||
|
let raw_name = entry.file_name();
|
||||||
|
let name = os_to_config_string(&raw_name);
|
||||||
if name == ".DS_Store" {
|
if name == ".DS_Store" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
themes.push(ThemeListElement {
|
themes.push(ThemeListElement {
|
||||||
path: std::path::Path::new(&dir)
|
path: path_to_config_string(&config_str_to_path(&dir).join(&raw_name)),
|
||||||
.join(&name)
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned(),
|
|
||||||
location,
|
location,
|
||||||
theme: name,
|
theme: name,
|
||||||
rank: None,
|
rank: None,
|
||||||
@@ -170,8 +175,8 @@ pub fn should_include_theme(theme_filter: ColorScheme, theme_config: &Config) ->
|
|||||||
|
|
||||||
/// Make `path` absolute (`Config::load_file` requires absolute paths).
|
/// Make `path` absolute (`Config::load_file` requires absolute paths).
|
||||||
pub(crate) fn absolute_path(path: &str) -> String {
|
pub(crate) fn absolute_path(path: &str) -> String {
|
||||||
std::path::absolute(path)
|
std::path::absolute(config_str_to_path(path))
|
||||||
.map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned())
|
.map_or_else(|_| path.to_string(), |p| path_to_config_string(&p))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resources_dir() -> Option<String> {
|
fn resources_dir() -> Option<String> {
|
||||||
@@ -273,11 +278,14 @@ pub fn write_plain(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let loc = location_name(theme.location);
|
let loc = location_name(theme.location);
|
||||||
if opts.path {
|
// Names/paths are raw bytes in Zig: decode the reversible escape so
|
||||||
writeln!(out, "{} ({loc}) {}", theme.theme, theme.path)?;
|
// non-UTF-8 bytes are written back unchanged.
|
||||||
|
let line = if opts.path {
|
||||||
|
format!("{} ({loc}) {}\n", theme.theme, theme.path)
|
||||||
} else {
|
} else {
|
||||||
writeln!(out, "{} ({loc})", theme.theme)?;
|
format!("{} ({loc})\n", theme.theme)
|
||||||
}
|
};
|
||||||
|
out.write_all(&config_str_to_bytes(&line))?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -297,6 +305,29 @@ mod tests {
|
|||||||
tmp
|
tmp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn collect_themes_keeps_non_utf8_names() {
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let dir = tmp.path().join("themes");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let raw = std::ffi::OsStr::from_bytes(b"th\xffeme");
|
||||||
|
std::fs::write(dir.join(raw), "background = #000000\n").unwrap();
|
||||||
|
let res = tmp.path().to_string_lossy().into_owned();
|
||||||
|
let themes: Vec<_> = collect_themes(Some(&res))
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| t.location == Location::Resources)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(1, themes.len());
|
||||||
|
assert_eq!(config_str_to_bytes(&themes[0].theme), b"th\xffeme".to_vec());
|
||||||
|
// The path points at the real file and the listing loads it.
|
||||||
|
assert_eq!(config_str_to_path(&themes[0].path), dir.join(raw));
|
||||||
|
let mut out = Vec::new();
|
||||||
|
write_plain(&mut out, &themes, &Options::default()).unwrap();
|
||||||
|
assert_eq!(out, b"th\xffeme (resources)\n");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn collect_sort_and_filter() {
|
fn collect_sort_and_filter() {
|
||||||
let tmp = theme_dir(&[
|
let tmp = theme_dir(&[
|
||||||
|
|||||||
@@ -1408,14 +1408,13 @@ fn error_name(e: &ghostty_config::config::Error) -> String {
|
|||||||
pub fn resolve_auto_theme_path() -> Result<String, Error> {
|
pub fn resolve_auto_theme_path() -> Result<String, Error> {
|
||||||
let main_cfg_path = ghostty_config::file_load::preferred_default_file_path()
|
let main_cfg_path = ghostty_config::file_load::preferred_default_file_path()
|
||||||
.map_err(|e| Error::Other(e.to_string()))?;
|
.map_err(|e| Error::Other(e.to_string()))?;
|
||||||
let base_dir = std::path::Path::new(&main_cfg_path)
|
let main_cfg_path = ghostty_os::osbytes::config_str_to_path(&main_cfg_path);
|
||||||
|
let base_dir = main_cfg_path
|
||||||
.parent()
|
.parent()
|
||||||
.ok_or_else(|| Error::Other("BadPathName".to_string()))?;
|
.ok_or_else(|| Error::Other("BadPathName".to_string()))?;
|
||||||
Ok(base_dir
|
Ok(ghostty_os::osbytes::path_to_config_string(
|
||||||
.join("auto")
|
&base_dir.join("auto").join("theme.ghostty"),
|
||||||
.join("theme.ghostty")
|
))
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `writeAutoThemeFile`.
|
/// `writeAutoThemeFile`.
|
||||||
@@ -1424,10 +1423,14 @@ pub fn write_auto_theme_file(path: Option<&str>, theme_name: &str) -> Result<(),
|
|||||||
Some(p) => p.to_string(),
|
Some(p) => p.to_string(),
|
||||||
None => resolve_auto_theme_path()?,
|
None => resolve_auto_theme_path()?,
|
||||||
};
|
};
|
||||||
if let Some(dir) = std::path::Path::new(&auto_path).parent() {
|
let auto_path = ghostty_os::osbytes::config_str_to_path(&auto_path);
|
||||||
|
if let Some(dir) = auto_path.parent() {
|
||||||
std::fs::create_dir_all(dir)?;
|
std::fs::create_dir_all(dir)?;
|
||||||
}
|
}
|
||||||
std::fs::write(&auto_path, format!("theme = {theme_name}\n"))?;
|
std::fs::write(
|
||||||
|
&auto_path,
|
||||||
|
ghostty_os::osbytes::config_str_to_bytes(&format!("theme = {theme_name}\n")),
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1734,7 +1737,11 @@ fn run_loop(app: &mut Preview) -> Result<(), Error> {
|
|||||||
match effect {
|
match effect {
|
||||||
Effect::Copy(text) => {
|
Effect::Copy(text) => {
|
||||||
let out = terminal.backend_mut();
|
let out = terminal.backend_mut();
|
||||||
write!(out, "\x1b]52;c;{}\x1b\\", base64(text.as_bytes()))?;
|
write!(
|
||||||
|
out,
|
||||||
|
"\x1b]52;c;{}\x1b\\",
|
||||||
|
base64(&ghostty_os::osbytes::config_str_to_bytes(&text))
|
||||||
|
)?;
|
||||||
out.flush()?;
|
out.flush()?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,12 @@ impl Options {
|
|||||||
let expanded =
|
let expanded =
|
||||||
ghostty_os::expand_home(stripped).map_err(|e| ArgError::Other(e.to_string()))?;
|
ghostty_os::expand_home(stripped).map_err(|e| ArgError::Other(e.to_string()))?;
|
||||||
let realpath =
|
let realpath =
|
||||||
std::fs::canonicalize(&expanded).map_err(|e| ArgError::Other(e.to_string()))?;
|
std::fs::canonicalize(ghostty_os::osbytes::config_str_to_path(&expanded))
|
||||||
|
.map_err(|e| ArgError::Other(e.to_string()))?;
|
||||||
self.working_directory_seen = true;
|
self.working_directory_seen = true;
|
||||||
return Ok(Some(format!(
|
return Ok(Some(format!(
|
||||||
"--working-directory={}",
|
"--working-directory={}",
|
||||||
realpath.to_string_lossy()
|
ghostty_os::osbytes::path_to_config_string(&realpath)
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,8 +193,13 @@ pub fn run(args: &[String]) -> Result<u8, Error> {
|
|||||||
if !opts.working_directory_seen {
|
if !opts.working_directory_seen {
|
||||||
// This should be inserted at the beginning of the list, just in case `-e` was used.
|
// This should be inserted at the beginning of the list, just in case `-e` was used.
|
||||||
let wd = std::fs::canonicalize(".")?;
|
let wd = std::fs::canonicalize(".")?;
|
||||||
opts.arguments
|
opts.arguments.insert(
|
||||||
.insert(0, format!("--working-directory={}", wd.to_string_lossy()));
|
0,
|
||||||
|
format!(
|
||||||
|
"--working-directory={}",
|
||||||
|
ghostty_os::osbytes::path_to_config_string(&wd)
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let target = match opts.class {
|
let target = match opts.class {
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result<u8, Error> {
|
|||||||
|
|
||||||
// If a config path is passed, validate it, otherwise validate default configs
|
// If a config path is passed, validate it, otherwise validate default configs
|
||||||
if let Some(config_path) = &opts.config_file {
|
if let Some(config_path) = &opts.config_file {
|
||||||
let abs_path = std::fs::canonicalize(config_path)?;
|
// Byte-preserving: the option string carries non-UTF-8 bytes via the
|
||||||
cfg.load_file(&abs_path.to_string_lossy())?;
|
// reversible `osbytes` escape and the canonical path may too.
|
||||||
|
let abs_path = std::fs::canonicalize(ghostty_os::osbytes::config_str_to_path(config_path))?;
|
||||||
|
cfg.load_file(&ghostty_os::osbytes::path_to_config_string(&abs_path))?;
|
||||||
cfg.load_recursive_files()?;
|
cfg.load_recursive_files()?;
|
||||||
} else {
|
} else {
|
||||||
cfg = Config::load()?;
|
cfg = Config::load()?;
|
||||||
@@ -109,4 +111,21 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(run_inner(&opts, &mut Vec::new()).is_err());
|
assert!(run_inner(&opts, &mut Vec::new()).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn validate_symlink_to_non_utf8_target() {
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let target = tmp.path().join(std::ffi::OsStr::from_bytes(b"cfg\xff"));
|
||||||
|
std::fs::write(&target, "font-size = 14\n").unwrap();
|
||||||
|
let link = tmp.path().join("link");
|
||||||
|
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||||
|
let opts = Options {
|
||||||
|
config_file: Some(link.to_string_lossy().into_owned()),
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
assert_eq!(0, run_inner(&opts, &mut out).unwrap());
|
||||||
|
assert_eq!(out.len(), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -343,10 +343,21 @@ pub fn parse_auto_struct_with(
|
|||||||
|
|
||||||
/// Decode a double-quoted Zig string literal (including the quotes).
|
/// Decode a double-quoted Zig string literal (including the quotes).
|
||||||
/// Returns `None` on failure. Bytes produced by `\x` escapes that do not form
|
/// Returns `None` on failure. Bytes produced by `\x` escapes that do not form
|
||||||
/// valid UTF-8 are converted lossily.
|
/// valid UTF-8 stay raw bytes: they are carried through the reversible
|
||||||
|
/// `ghostty_os::osbytes` escape encoding (`\xFF` -> `U+FDD0 U+00FF`, decoded back
|
||||||
|
/// to `0xFF` by `config_str_to_bytes`). See [`parse_zig_string_literal_bytes`]
|
||||||
|
/// for the raw bytes.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn parse_zig_string_literal(s: &str) -> Option<String> {
|
pub fn parse_zig_string_literal(s: &str) -> Option<String> {
|
||||||
let b = s.as_bytes();
|
parse_zig_string_literal_bytes(s).map(|b| ghostty_os::osbytes::bytes_to_config_string(&b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte-exact [`parse_zig_string_literal`] (Zig `std.zig.string_literal`
|
||||||
|
/// decoding yields a `[]u8`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse_zig_string_literal_bytes(s: &str) -> Option<Vec<u8>> {
|
||||||
|
let b = ghostty_os::osbytes::config_str_to_bytes(s);
|
||||||
|
let b = b.as_slice();
|
||||||
if b.len() < 2 || b[0] != b'"' || b[b.len() - 1] != b'"' {
|
if b.len() < 2 || b[0] != b'"' || b[b.len() - 1] != b'"' {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -395,7 +406,7 @@ pub fn parse_zig_string_literal(s: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
Some(String::from_utf8_lossy(&out).into_owned())
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a "packed struct" value (comma separated names, optionally
|
/// Parse a "packed struct" value (comma separated names, optionally
|
||||||
@@ -652,8 +663,13 @@ impl<I: Iterator<Item = String>> ArgIter for ArgsIterator<I> {
|
|||||||
/// Create an args iterator for the process args. This will skip argv0.
|
/// Create an args iterator for the process args. This will skip argv0.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn args_iterator() -> ArgsIterator<std::iter::Skip<std::vec::IntoIter<String>>> {
|
pub fn args_iterator() -> ArgsIterator<std::iter::Skip<std::vec::IntoIter<String>>> {
|
||||||
// Byte-safe: never panics on non-UTF-8 arguments.
|
// Byte-safe: non-UTF-8 arguments keep their bytes (reversible escape
|
||||||
ArgsIterator::new(ghostty_os::args::collect_lossy().into_iter().skip(1))
|
// encoding, see `ghostty_os::osbytes`).
|
||||||
|
ArgsIterator::new(
|
||||||
|
ghostty_os::args::collect_config_strings()
|
||||||
|
.into_iter()
|
||||||
|
.skip(1),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An iterator valid for arg parsing from a slice.
|
/// An iterator valid for arg parsing from a slice.
|
||||||
@@ -791,7 +807,8 @@ impl<R: BufRead> LineIterator<R> {
|
|||||||
|
|
||||||
self.line += 1;
|
self.line += 1;
|
||||||
|
|
||||||
let line = String::from_utf8_lossy(&buf);
|
// Raw bytes survive via the reversible escape encoding.
|
||||||
|
let line = ghostty_os::osbytes::bytes_to_config_string(&buf);
|
||||||
// Trim any whitespace (including CR) around it
|
// Trim any whitespace (including CR) around it
|
||||||
let trim = line.trim_matches(|c| c == ' ' || c == '\t' || c == '\r');
|
let trim = line.trim_matches(|c| c == ' ' || c == '\t' || c == '\r');
|
||||||
|
|
||||||
@@ -1533,4 +1550,42 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(parse_zig_string_literal("\"\\q\""), None);
|
assert_eq!(parse_zig_string_literal("\"\\q\""), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zig_string_literal_keeps_raw_bytes() {
|
||||||
|
// `\xFF` is not valid UTF-8 and must stay the single byte 0xFF.
|
||||||
|
assert_eq!(
|
||||||
|
parse_zig_string_literal_bytes("\"a\\xFFb\"").unwrap(),
|
||||||
|
vec![b'a', 0xff, b'b']
|
||||||
|
);
|
||||||
|
let s = parse_zig_string_literal("\"a\\xFFb\"").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ghostty_os::osbytes::config_str_to_bytes(&s),
|
||||||
|
vec![b'a', 0xff, b'b']
|
||||||
|
);
|
||||||
|
assert!(!s.contains('\u{fffd}'));
|
||||||
|
// Valid multi-byte sequences spelled with escapes still decode.
|
||||||
|
assert_eq!(
|
||||||
|
parse_zig_string_literal("\"\\xC3\\xA9\"").unwrap(),
|
||||||
|
"\u{e9}"
|
||||||
|
);
|
||||||
|
// Raw (unescaped) invalid input bytes survive the escape encoding.
|
||||||
|
let raw = ghostty_os::osbytes::bytes_to_config_string(b"\"x\xfey\"");
|
||||||
|
assert_eq!(
|
||||||
|
parse_zig_string_literal_bytes(&raw).unwrap(),
|
||||||
|
vec![b'x', 0xfe, b'y']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_iterator_keeps_invalid_utf8_bytes() {
|
||||||
|
let data: &[u8] = b"title = a\xffb\n";
|
||||||
|
let mut it = LineIterator::new(data);
|
||||||
|
let line = it.next().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ghostty_os::osbytes::config_str_to_bytes(&line),
|
||||||
|
b"--title=a\xffb".to_vec()
|
||||||
|
);
|
||||||
|
assert!(!line.contains('\u{fffd}'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3877,7 +3877,7 @@ impl Config {
|
|||||||
///
|
///
|
||||||
/// `path` must be resolved and absolute.
|
/// `path` must be resolved and absolute.
|
||||||
pub fn load_file(&mut self, path: &str) -> Result<(), Error> {
|
pub fn load_file(&mut self, path: &str) -> Result<(), Error> {
|
||||||
assert!(std::path::Path::new(path).is_absolute());
|
assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute());
|
||||||
let file = match crate::file_load::open(path) {
|
let file = match crate::file_load::open(path) {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(crate::file_load::OpenFileError::NotAFile) => {
|
Err(crate::file_load::OpenFileError::NotAFile) => {
|
||||||
@@ -3916,9 +3916,9 @@ impl Config {
|
|||||||
}
|
}
|
||||||
let mut iter = cli_args::LineIterator::with_path(reader, path);
|
let mut iter = cli_args::LineIterator::with_path(reader, path);
|
||||||
self.load_iter(&mut iter)?;
|
self.load_iter(&mut iter)?;
|
||||||
let dir = std::path::Path::new(path)
|
let dir = ghostty_os::osbytes::config_str_to_path(path)
|
||||||
.parent()
|
.parent()
|
||||||
.map(|p| p.to_string_lossy().into_owned())
|
.map(ghostty_os::osbytes::path_to_config_string)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
self.expand_paths(&dir)?;
|
self.expand_paths(&dir)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -4067,7 +4067,7 @@ impl Config {
|
|||||||
// a command to execute.
|
// a command to execute.
|
||||||
let mut builder: Vec<String> = Vec::new();
|
let mut builder: Vec<String> = Vec::new();
|
||||||
for arg in args {
|
for arg in args {
|
||||||
let copy = arg.to_string_lossy().into_owned();
|
let copy = ghostty_os::osbytes::bytes_to_config_string(arg.to_bytes());
|
||||||
self.replay_steps.push(ReplayStep::Arg(copy.clone()));
|
self.replay_steps.push(ReplayStep::Arg(copy.clone()));
|
||||||
builder.push(copy);
|
builder.push(copy);
|
||||||
}
|
}
|
||||||
@@ -4126,7 +4126,7 @@ impl Config {
|
|||||||
// Any paths referenced from the CLI are relative to the current working
|
// Any paths referenced from the CLI are relative to the current working
|
||||||
// directory.
|
// directory.
|
||||||
let cwd = std::fs::canonicalize(".")?;
|
let cwd = std::fs::canonicalize(".")?;
|
||||||
self.expand_paths(&cwd.to_string_lossy())?;
|
self.expand_paths(&ghostty_os::osbytes::path_to_config_string(&cwd))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4170,7 +4170,7 @@ impl Config {
|
|||||||
|
|
||||||
// All paths should already be absolute at this point because
|
// All paths should already be absolute at this point because
|
||||||
// they're fixed up after each load.
|
// they're fixed up after each load.
|
||||||
assert!(std::path::Path::new(&path).is_absolute());
|
assert!(ghostty_os::osbytes::config_str_to_path(&path).is_absolute());
|
||||||
|
|
||||||
// We must only load a unique file once
|
// We must only load a unique file once
|
||||||
if !loaded.insert(path.clone()) {
|
if !loaded.insert(path.clone()) {
|
||||||
@@ -4183,7 +4183,7 @@ impl Config {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let file = match std::fs::File::open(&path) {
|
let file = match std::fs::File::open(ghostty_os::osbytes::config_str_to_path(&path)) {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if err.kind() != std::io::ErrorKind::NotFound || !optional {
|
if err.kind() != std::io::ErrorKind::NotFound || !optional {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ pub fn legacy_default_app_support_path() -> Result<String, xdg::Error> {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn app_support_dir(sub_path: &str) -> Result<String, xdg::Error> {
|
fn app_support_dir(sub_path: &str) -> Result<String, xdg::Error> {
|
||||||
ghostty_os::macos::app_support_dir(sub_path)
|
ghostty_os::macos::app_support_dir(sub_path)
|
||||||
.map(|p| p.to_string_lossy().into_owned())
|
.map(|p| ghostty_os::osbytes::path_to_config_string(&p))
|
||||||
.map_err(|_| xdg::Error::AppleApiFailed)
|
.map_err(|_| xdg::Error::AppleApiFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,9 +159,10 @@ impl std::error::Error for OpenFileError {}
|
|||||||
/// if it exists and is non-empty. This also constrains the possible
|
/// if it exists and is non-empty. This also constrains the possible
|
||||||
/// errors to a smaller set that we can explicitly handle.
|
/// errors to a smaller set that we can explicitly handle.
|
||||||
pub fn open(path: &str) -> Result<File, OpenFileError> {
|
pub fn open(path: &str) -> Result<File, OpenFileError> {
|
||||||
assert!(std::path::Path::new(path).is_absolute());
|
assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute());
|
||||||
|
|
||||||
let file = File::open(path).map_err(|err| {
|
// `path` may carry the reversible byte escape of non-UTF-8 names.
|
||||||
|
let file = File::open(ghostty_os::osbytes::config_str_to_os(path)).map_err(|err| {
|
||||||
if err.kind() == std::io::ErrorKind::NotFound {
|
if err.kind() == std::io::ErrorKind::NotFound {
|
||||||
OpenFileError::FileNotFound
|
OpenFileError::FileNotFound
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
fmt,
|
fmt,
|
||||||
path::{Component, Path as StdPath, PathBuf},
|
path::{Component, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use ghostty_os::osbytes::{config_str_to_path, path_to_config_string};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
c_value::CValue,
|
c_value::CValue,
|
||||||
cli_args::{ArgError, ConfigField},
|
cli_args::{ArgError, ConfigField},
|
||||||
@@ -32,7 +34,7 @@ impl Default for Path {
|
|||||||
|
|
||||||
/// Lexically resolve `base/path` like `std.fs.path.resolve`.
|
/// Lexically resolve `base/path` like `std.fs.path.resolve`.
|
||||||
fn resolve_lexical(base: &str, path: &str) -> String {
|
fn resolve_lexical(base: &str, path: &str) -> String {
|
||||||
let joined = PathBuf::from(base).join(path);
|
let joined = config_str_to_path(base).join(config_str_to_path(path));
|
||||||
let mut out = PathBuf::new();
|
let mut out = PathBuf::new();
|
||||||
for c in joined.components() {
|
for c in joined.components() {
|
||||||
match c {
|
match c {
|
||||||
@@ -43,7 +45,7 @@ fn resolve_lexical(base: &str, path: &str) -> String {
|
|||||||
other => out.push(other.as_os_str()),
|
other => out.push(other.as_os_str()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.to_string_lossy().into_owned()
|
path_to_config_string(&out)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Path {
|
impl Path {
|
||||||
@@ -109,12 +111,15 @@ impl Path {
|
|||||||
/// absolute path. Errors are added to `diags` if they occur; an `Err` is
|
/// absolute path. Errors are added to `diags` if they occur; an `Err` is
|
||||||
/// only returned if the base directory cannot be opened.
|
/// only returned if the base directory cannot be opened.
|
||||||
pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> {
|
pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> {
|
||||||
assert!(StdPath::new(base).is_absolute());
|
// Config strings carry non-UTF-8 bytes via a reversible escape
|
||||||
|
// encoding; filesystem calls use the decoded OS paths.
|
||||||
|
let base_path = config_str_to_path(base);
|
||||||
|
assert!(base_path.is_absolute());
|
||||||
|
|
||||||
let path = self.path().to_string();
|
let path = self.path().to_string();
|
||||||
|
|
||||||
// If it is already absolute we can ignore it.
|
// If it is already absolute we can ignore it.
|
||||||
if path.is_empty() || StdPath::new(&path).is_absolute() {
|
if path.is_empty() || config_str_to_path(&path).is_absolute() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,11 +148,11 @@ impl Path {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make sure the base directory can be opened.
|
// Make sure the base directory can be opened.
|
||||||
std::fs::read_dir(base)?;
|
std::fs::read_dir(&base_path)?;
|
||||||
|
|
||||||
let full = StdPath::new(base).join(&path);
|
let full = base_path.join(config_str_to_path(&path));
|
||||||
let abs = match std::fs::canonicalize(&full) {
|
let abs = match std::fs::canonicalize(&full) {
|
||||||
Ok(p) => p.to_string_lossy().into_owned(),
|
Ok(p) => path_to_config_string(&p),
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
// The file doesn't exist. Try to resolve the relative path
|
// The file doesn't exist. Try to resolve the relative path
|
||||||
// another way.
|
// another way.
|
||||||
@@ -374,4 +379,23 @@ mod tests {
|
|||||||
assert_eq!(p, Path::Optional("/abs/path".into()));
|
assert_eq!(p, Path::Optional("/abs/path".into()));
|
||||||
assert!(diags.is_empty());
|
assert!(diags.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn expand_symlink_to_non_utf8_target_round_trips() {
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
let tmp = ghostty_os::TempDir::init().unwrap();
|
||||||
|
let base = std::fs::canonicalize(tmp.path()).unwrap();
|
||||||
|
let target = base.join(std::ffi::OsStr::from_bytes(b"t\xffarget"));
|
||||||
|
std::fs::write(&target, b"x").unwrap();
|
||||||
|
std::os::unix::fs::symlink(&target, base.join("link")).unwrap();
|
||||||
|
|
||||||
|
let mut p = Path::Required("link".into());
|
||||||
|
let mut diags = DiagnosticList::default();
|
||||||
|
p.expand(&path_to_config_string(&base), &mut diags).unwrap();
|
||||||
|
assert_eq!(diags.items().len(), 0);
|
||||||
|
// The canonical (non-UTF-8) target is preserved byte for byte.
|
||||||
|
assert_eq!(config_str_to_path(p.path()), target);
|
||||||
|
assert!(std::fs::metadata(config_str_to_path(p.path())).is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,14 @@
|
|||||||
//! Zig reads the resources dir from global state; here it is passed
|
//! Zig reads the resources dir from global state; here it is passed
|
||||||
//! explicitly (`resources_dir`, the value of `ResourcesDir.app()`).
|
//! explicitly (`resources_dir`, the value of `ResourcesDir.app()`).
|
||||||
|
|
||||||
use std::{fs::File, path::Path};
|
use std::fs::File;
|
||||||
|
|
||||||
|
use ghostty_os::osbytes::{
|
||||||
|
config_str_to_os,
|
||||||
|
config_str_to_path,
|
||||||
|
os_to_config_string,
|
||||||
|
path_to_config_string,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::diagnostics::{Diagnostic, DiagnosticList};
|
use crate::diagnostics::{Diagnostic, DiagnosticList};
|
||||||
|
|
||||||
@@ -35,12 +42,9 @@ impl Location {
|
|||||||
.ok(),
|
.ok(),
|
||||||
Location::Resources => {
|
Location::Resources => {
|
||||||
let base = resources_dir?;
|
let base = resources_dir?;
|
||||||
Some(
|
Some(path_to_config_string(
|
||||||
Path::new(base)
|
&config_str_to_path(base).join("themes"),
|
||||||
.join("themes")
|
))
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,7 +165,7 @@ pub fn open(
|
|||||||
diags: &mut DiagnosticList,
|
diags: &mut DiagnosticList,
|
||||||
) -> Option<OpenedTheme> {
|
) -> Option<OpenedTheme> {
|
||||||
// Absolute themes are loaded a different path.
|
// Absolute themes are loaded a different path.
|
||||||
if Path::new(theme).is_absolute() {
|
if config_str_to_path(theme).is_absolute() {
|
||||||
let file = open_absolute(theme, diags)?;
|
let file = open_absolute(theme, diags)?;
|
||||||
if !check_regular(&file, theme, diags) {
|
if !check_regular(&file, theme, diags) {
|
||||||
return None;
|
return None;
|
||||||
@@ -172,9 +176,9 @@ pub fn open(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let basename = Path::new(theme)
|
let basename = config_str_to_path(theme)
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|s| s.to_string_lossy().into_owned())
|
.map(os_to_config_string)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if theme != basename {
|
if theme != basename {
|
||||||
diag(
|
diag(
|
||||||
@@ -190,8 +194,8 @@ pub fn open(
|
|||||||
// one that exists.
|
// one that exists.
|
||||||
let mut it = LocationIterator::new(resources_dir);
|
let mut it = LocationIterator::new(resources_dir);
|
||||||
while let Some((_, dir)) = it.next_location() {
|
while let Some((_, dir)) = it.next_location() {
|
||||||
let path = Path::new(&dir).join(theme).to_string_lossy().into_owned();
|
let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme)));
|
||||||
match File::open(&path) {
|
match File::open(config_str_to_path(&path)) {
|
||||||
Ok(file) => {
|
Ok(file) => {
|
||||||
if !check_regular(&file, theme, diags) {
|
if !check_regular(&file, theme, diags) {
|
||||||
return None;
|
return None;
|
||||||
@@ -215,7 +219,7 @@ pub fn open(
|
|||||||
// our iterator, reiterate over in order to build a better error message.
|
// our iterator, reiterate over in order to build a better error message.
|
||||||
it.reset();
|
it.reset();
|
||||||
while let Some((_, dir)) = it.next_location() {
|
while let Some((_, dir)) = it.next_location() {
|
||||||
let path = Path::new(&dir).join(theme).to_string_lossy().into_owned();
|
let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme)));
|
||||||
diag(
|
diag(
|
||||||
diags,
|
diags,
|
||||||
format!("theme \"{theme}\" not found, tried path \"{path}\""),
|
format!("theme \"{theme}\" not found, tried path \"{path}\""),
|
||||||
@@ -229,7 +233,7 @@ pub fn open(
|
|||||||
/// then messages will be appended to the given error list and None is
|
/// then messages will be appended to the given error list and None is
|
||||||
/// returned.
|
/// returned.
|
||||||
pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option<File> {
|
pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option<File> {
|
||||||
match File::open(theme) {
|
match File::open(config_str_to_path(theme)) {
|
||||||
Ok(f) => Some(f),
|
Ok(f) => Some(f),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if err.kind() == std::io::ErrorKind::NotFound {
|
if err.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
|||||||
@@ -5,9 +5,28 @@
|
|||||||
//! don't panic).
|
//! don't panic).
|
||||||
|
|
||||||
/// Returns an iterator over the command line arguments.
|
/// Returns an iterator over the command line arguments.
|
||||||
|
///
|
||||||
|
/// If [`set_argv`] was called (the embedded `ghostty_init`, Zig:
|
||||||
|
/// `std.os.argv = argv[0..argc]`) those arguments are returned instead of
|
||||||
|
/// the process arguments.
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
pub fn iterator() -> std::env::ArgsOs {
|
pub fn iterator() -> std::vec::IntoIter<std::ffi::OsString> {
|
||||||
std::env::args_os()
|
match ARGV_OVERRIDE.lock().ok().and_then(|g| g.clone()) {
|
||||||
|
Some(v) => v.into_iter(),
|
||||||
|
None => std::env::args_os().collect::<Vec<_>>().into_iter(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ARGV_OVERRIDE: std::sync::Mutex<Option<Vec<std::ffi::OsString>>> =
|
||||||
|
std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// Replace the process arguments seen by [`iterator`] (Zig's assignment to
|
||||||
|
/// `std.os.argv` in `ghostty_init`). Has no effect on macOS, where the
|
||||||
|
/// arguments always come from `NSProcessInfo` (as in Zig).
|
||||||
|
pub fn set_argv(args: Vec<std::ffi::OsString>) {
|
||||||
|
if let Ok(mut g) = ARGV_OVERRIDE.lock() {
|
||||||
|
*g = Some(args);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns an iterator over the command line arguments.
|
/// Returns an iterator over the command line arguments.
|
||||||
@@ -27,15 +46,25 @@ pub fn iterator() -> std::vec::IntoIter<std::ffi::OsString> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collect the process arguments as `String`s without ever panicking on
|
/// Collect the process arguments as raw OS strings (no conversion, never
|
||||||
/// non-UTF-8 bytes (invalid sequences become U+FFFD). The CLI/config layers
|
/// panics on non-UTF-8 bytes).
|
||||||
/// are string based; `+action` dispatch only depends on the ASCII prefix.
|
pub fn collect_os() -> Vec<std::ffi::OsString> {
|
||||||
pub fn collect_lossy() -> Vec<String> {
|
iterator().collect()
|
||||||
lossy(iterator())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lossy(it: impl Iterator<Item = std::ffi::OsString>) -> Vec<String> {
|
/// Collect the process arguments as raw byte vectors (Zig `[]const u8`).
|
||||||
it.map(|a| a.to_string_lossy().into_owned()).collect()
|
pub fn collect_bytes() -> Vec<Vec<u8>> {
|
||||||
|
iterator().map(|a| crate::osbytes::os_bytes(&a)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect the process arguments as `String`s using the reversible
|
||||||
|
/// byte-escape encoding of [`crate::osbytes`]: no bytes are lost, invalid
|
||||||
|
/// UTF-8 bytes are carried as private-use escapes that the config layer
|
||||||
|
/// decodes back to the original bytes.
|
||||||
|
pub fn collect_config_strings() -> Vec<String> {
|
||||||
|
iterator()
|
||||||
|
.map(|a| crate::osbytes::os_to_config_string(&a))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -50,13 +79,13 @@ mod tests {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn lossy_does_not_panic_on_non_utf8() {
|
fn config_strings_keep_non_utf8_bytes() {
|
||||||
use std::os::unix::ffi::OsStringExt;
|
use std::os::unix::ffi::OsStringExt;
|
||||||
let args = vec![
|
let a = std::ffi::OsString::from_vec(vec![b'+', b'x', 0xff]);
|
||||||
std::ffi::OsString::from("ghostty"),
|
let s = crate::osbytes::os_to_config_string(&a);
|
||||||
std::ffi::OsString::from_vec(vec![b'+', b'x', 0xff]),
|
assert_eq!(
|
||||||
];
|
crate::osbytes::config_str_to_bytes(&s),
|
||||||
let out = lossy(args.into_iter());
|
vec![b'+', b'x', 0xff]
|
||||||
assert_eq!(out, vec!["ghostty".to_owned(), "+x\u{fffd}".to_owned()]);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,14 @@ use std::io::Read;
|
|||||||
/// into a caller-provided buffer; callers there use path-sized buffers).
|
/// into a caller-provided buffer; callers there use path-sized buffers).
|
||||||
const READ_LIMIT: u64 = 4096;
|
const READ_LIMIT: u64 = 4096;
|
||||||
|
|
||||||
/// Returns the path to the cgroup for the given pid.
|
/// Returns the path to the cgroup for the given pid. Non-UTF-8 bytes are
|
||||||
|
/// carried via [`crate::osbytes`]; see [`current_os`] for the raw bytes.
|
||||||
pub fn current(pid: u32) -> Option<String> {
|
pub fn current(pid: u32) -> Option<String> {
|
||||||
|
current_os(pid).map(|p| crate::osbytes::os_to_config_string(&p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte-preserving [`current`].
|
||||||
|
pub fn current_os(pid: u32) -> Option<std::ffi::OsString> {
|
||||||
// Read our cgroup by opening /proc/<pid>/cgroup and reading the first
|
// Read our cgroup by opening /proc/<pid>/cgroup and reading the first
|
||||||
// line. The first line will look something like this:
|
// line. The first line will look something like this:
|
||||||
// 0::/user.slice/user-1000.slice/session-1.scope
|
// 0::/user.slice/user-1000.slice/session-1.scope
|
||||||
@@ -21,15 +27,15 @@ pub fn current(pid: u32) -> Option<String> {
|
|||||||
.iter()
|
.iter()
|
||||||
.rposition(|b| !matches!(b, b' ' | b'\r' | b'\n'))
|
.rposition(|b| !matches!(b, b' ' | b'\r' | b'\n'))
|
||||||
.map_or(0, |i| i + 1);
|
.map_or(0, |i| i + 1);
|
||||||
Some(String::from_utf8_lossy(&rest[..end]).into_owned())
|
Some(crate::osbytes::os_from_bytes(rest[..end].to_vec()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the final path component of `pid`'s cgroup equals `expected`.
|
/// True if the final path component of `pid`'s cgroup equals `expected`.
|
||||||
pub fn scope_matches(pid: u32, expected: &str) -> bool {
|
pub fn scope_matches(pid: u32, expected: &str) -> bool {
|
||||||
current(pid)
|
current_os(pid).is_some_and(|raw| {
|
||||||
.as_deref()
|
let raw = crate::osbytes::os_bytes(&raw);
|
||||||
.and_then(|raw| raw.rsplit_once('/'))
|
raw.rsplit(|&b| b == b'/').next() == Some(expected.as_bytes()) && raw.contains(&b'/')
|
||||||
.is_some_and(|(_, last)| last == expected)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll (every 25ms, at most 250ms) until `pid`'s cgroup ends in `expected`.
|
/// Poll (every 25ms, at most 250ms) until `pid`'s cgroup ends in `expected`.
|
||||||
@@ -61,4 +67,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(current(u32::MAX).is_none());
|
assert!(current(u32::MAX).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_matches_requires_slash_and_last_component() {
|
||||||
|
// Our own cgroup path always matches its own last component.
|
||||||
|
if let Some(p) = current(std::process::id())
|
||||||
|
&& let Some((_, last)) = p.rsplit_once('/')
|
||||||
|
{
|
||||||
|
assert!(scope_matches(std::process::id(), last));
|
||||||
|
}
|
||||||
|
assert!(!scope_matches(u32::MAX, "x"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -235,10 +235,24 @@ pub fn prepend_env(current: &str, value: &str) -> String {
|
|||||||
format!("{value}{DELIMITER}{current}")
|
format!("{value}{DELIMITER}{current}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the value of an environment variable, or None if not found
|
/// Gets the raw value of an environment variable, or None if not found.
|
||||||
/// (or not valid unicode).
|
/// A present value that is not valid UTF-8 is returned as-is (never treated
|
||||||
|
/// as absent).
|
||||||
|
pub fn getenv_os(key: &str) -> Option<OsString> {
|
||||||
|
std::env::var_os(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the raw value of an environment variable. Returns None if not found
|
||||||
|
/// or the value is empty.
|
||||||
|
pub fn getenv_not_empty_os(key: &str) -> Option<OsString> {
|
||||||
|
getenv_os(key).filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the value of an environment variable, or None if not found. A
|
||||||
|
/// non-UTF-8 value is still present: its invalid bytes are carried through
|
||||||
|
/// the reversible [`crate::osbytes`] encoding.
|
||||||
pub fn getenv(key: &str) -> Option<String> {
|
pub fn getenv(key: &str) -> Option<String> {
|
||||||
std::env::var(key).ok()
|
getenv_os(key).map(|v| crate::osbytes::os_to_config_string(&v))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the value of an environment variable. Returns None if not found or
|
/// Gets the value of an environment variable. Returns None if not found or
|
||||||
@@ -294,6 +308,19 @@ mod tests {
|
|||||||
assert_eq!(append_env("a:b", "foo"), format!("a:b{DELIMITER}foo"));
|
assert_eq!(append_env("a:b", "foo"), format!("a:b{DELIMITER}foo"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn getenv_non_utf8_is_present() {
|
||||||
|
let v = OsString::from_vec(vec![b'a', 0xff]);
|
||||||
|
temp_env::with_var("GHOSTTY_RS_T3", Some(&v), || {
|
||||||
|
assert_eq!(getenv_os("GHOSTTY_RS_T3").unwrap(), v);
|
||||||
|
assert_eq!(getenv_not_empty_os("GHOSTTY_RS_T3").unwrap(), v);
|
||||||
|
let s = getenv("GHOSTTY_RS_T3").unwrap();
|
||||||
|
assert_eq!(crate::osbytes::config_str_to_bytes(&s), vec![b'a', 0xff]);
|
||||||
|
assert!(getenv_not_empty("GHOSTTY_RS_T3").is_some());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn os_variants_preserve_bytes() {
|
fn os_variants_preserve_bytes() {
|
||||||
|
|||||||
@@ -70,19 +70,41 @@ pub fn restore_max_files(lim: Rlimit) {
|
|||||||
let _ = setrlimit(Resource::Nofile, lim);
|
let _ = setrlimit(Resource::Nofile, lim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the recommended path for temporary files.
|
/// Resource limit placeholder on platforms without `rlimit` (Zig:
|
||||||
pub fn alloc_tmp_dir() -> Option<String> {
|
/// `rlimit = if (@hasDecl(posix.system, "rlimit")) posix.rlimit else struct {}`).
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct Rlimit;
|
||||||
|
|
||||||
|
/// Unsupported-platform fallback: returns `None` like Zig.
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
pub fn fix_max_files() -> Option<Rlimit> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unsupported-platform fallback: no-op like Zig.
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
pub fn restore_max_files(_lim: Rlimit) {}
|
||||||
|
|
||||||
|
/// Return the recommended path for temporary files (byte-preserving).
|
||||||
|
pub fn alloc_tmp_dir_os() -> Option<std::path::PathBuf> {
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
// TODO: what is a good fallback path on windows? (same as Zig)
|
// TODO: what is a good fallback path on windows? (same as Zig)
|
||||||
return std::env::var_os("TMP").map(|v| v.to_string_lossy().into_owned());
|
return std::env::var_os("TMP").map(Into::into);
|
||||||
}
|
}
|
||||||
if let Some(v) = std::env::var_os("TMPDIR") {
|
if let Some(v) = std::env::var_os("TMPDIR") {
|
||||||
return Some(v.to_string_lossy().into_owned());
|
return Some(v.into());
|
||||||
}
|
}
|
||||||
if let Some(v) = std::env::var_os("TMP") {
|
if let Some(v) = std::env::var_os("TMP") {
|
||||||
return Some(v.to_string_lossy().into_owned());
|
return Some(v.into());
|
||||||
}
|
}
|
||||||
Some("/tmp".to_owned())
|
Some("/tmp".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the recommended path for temporary files. Non-UTF-8 bytes are
|
||||||
|
/// carried via [`crate::osbytes`]; see [`alloc_tmp_dir_os`].
|
||||||
|
pub fn alloc_tmp_dir() -> Option<String> {
|
||||||
|
alloc_tmp_dir_os().map(|p| crate::osbytes::os_to_config_string(p.as_os_str()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, unix))]
|
||||||
@@ -103,6 +125,18 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tmp_dir_non_utf8() {
|
||||||
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||||
|
let v = std::ffi::OsString::from_vec(b"/tmp/\xff".to_vec());
|
||||||
|
temp_env::with_vars([("TMPDIR", Some(v.clone())), ("TMP", None)], || {
|
||||||
|
assert_eq!(
|
||||||
|
alloc_tmp_dir_os().unwrap().as_os_str().as_bytes(),
|
||||||
|
b"/tmp/\xff"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn fix_and_restore_max_files() {
|
fn fix_and_restore_max_files() {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use crate::osbytes;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use crate::passwd;
|
use crate::passwd;
|
||||||
|
|
||||||
@@ -24,16 +26,29 @@ const MAX_PATH_BYTES: usize = 4096;
|
|||||||
|
|
||||||
/// Determine the home directory for the currently executing user. This
|
/// Determine the home directory for the currently executing user. This
|
||||||
/// is generally an expensive process so the value should be cached.
|
/// is generally an expensive process so the value should be cached.
|
||||||
|
///
|
||||||
|
/// Non-UTF-8 bytes are preserved through [`crate::osbytes`]; use
|
||||||
|
/// [`home_os`] for the raw OS string.
|
||||||
pub fn home() -> Result<Option<String>, Error> {
|
pub fn home() -> Result<Option<String>, Error> {
|
||||||
home_with_capacity(MAX_PATH_BYTES)
|
home_with_capacity(MAX_PATH_BYTES)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Byte-preserving [`home`].
|
||||||
|
pub fn home_os() -> Result<Option<OsString>, Error> {
|
||||||
|
home_os_with_capacity(MAX_PATH_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
/// Like [`home`] but emulates the fixed output buffer of the Zig version:
|
/// Like [`home`] but emulates the fixed output buffer of the Zig version:
|
||||||
/// errors with `BufferTooSmall` if the result is longer than `cap` bytes.
|
/// errors with `BufferTooSmall` if the result is longer than `cap` bytes.
|
||||||
#[cfg(unix)]
|
|
||||||
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
||||||
let check = |s: String| {
|
Ok(home_os_with_capacity(cap)?.map(|h| osbytes::os_to_config_string(&h)))
|
||||||
if cap < s.len() {
|
}
|
||||||
|
|
||||||
|
/// Byte-preserving [`home_with_capacity`].
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn home_os_with_capacity(cap: usize) -> Result<Option<OsString>, Error> {
|
||||||
|
let check = |s: OsString| {
|
||||||
|
if cap < osbytes::os_bytes(&s).len() {
|
||||||
Err(Error::BufferTooSmall)
|
Err(Error::BufferTooSmall)
|
||||||
} else {
|
} else {
|
||||||
Ok(Some(s))
|
Ok(Some(s))
|
||||||
@@ -42,7 +57,7 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
|||||||
|
|
||||||
// First: if we have a HOME env var, then we use that.
|
// First: if we have a HOME env var, then we use that.
|
||||||
if let Some(result) = std::env::var_os("HOME") {
|
if let Some(result) = std::env::var_os("HOME") {
|
||||||
return check(result.to_string_lossy().into_owned());
|
return check(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On macOS: [NSFileManager defaultManager].homeDirectoryForCurrentUser.path
|
// On macOS: [NSFileManager defaultManager].homeDirectoryForCurrentUser.path
|
||||||
@@ -53,12 +68,12 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
|||||||
let manager = NSFileManager::defaultManager();
|
let manager = NSFileManager::defaultManager();
|
||||||
let home_url = manager.homeDirectoryForCurrentUser();
|
let home_url = manager.homeDirectoryForCurrentUser();
|
||||||
if let Some(path) = home_url.path() {
|
if let Some(path) = home_url.path() {
|
||||||
return check(path.to_string());
|
return check(OsString::from(path.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We try passwd. This doesn't work on multi-user mac but we try it anyways.
|
// We try passwd. This doesn't work on multi-user mac but we try it anyways.
|
||||||
if let Ok(pw) = passwd::get()
|
if let Ok(pw) = passwd::get_os()
|
||||||
&& let Some(result) = pw.home
|
&& let Some(result) = pw.home
|
||||||
{
|
{
|
||||||
return check(result);
|
return check(result);
|
||||||
@@ -73,8 +88,7 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
|||||||
if let Ok(run) = run
|
if let Ok(run) = run
|
||||||
&& run.status.success()
|
&& run.status.success()
|
||||||
{
|
{
|
||||||
let out = String::from_utf8_lossy(&run.stdout);
|
return check(osbytes::os_from_bytes(trim_space(&run.stdout).to_vec()));
|
||||||
return check(trim_space(&out).to_owned());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -83,12 +97,10 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
|||||||
/// Windows: `%HOMEDRIVE%%HOMEPATH%` (`homeWindows`). Returns `None` if
|
/// Windows: `%HOMEDRIVE%%HOMEPATH%` (`homeWindows`). Returns `None` if
|
||||||
/// either variable is missing.
|
/// either variable is missing.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
pub fn home_os_with_capacity(cap: usize) -> Result<Option<OsString>, Error> {
|
||||||
home_windows(
|
let drive = std::env::var_os("HOMEDRIVE").map(|v| v.to_string_lossy().into_owned());
|
||||||
std::env::var("HOMEDRIVE").ok().as_deref(),
|
let path = std::env::var_os("HOMEPATH").map(|v| v.to_string_lossy().into_owned());
|
||||||
std::env::var("HOMEPATH").ok().as_deref(),
|
Ok(home_windows(drive.as_deref(), path.as_deref(), cap)?.map(OsString::from))
|
||||||
cap,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure part of [`home_with_capacity`] on Windows.
|
/// Pure part of [`home_with_capacity`] on Windows.
|
||||||
@@ -108,8 +120,14 @@ fn home_windows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn trim_space(input: &str) -> &str {
|
fn trim_space(input: &[u8]) -> &[u8] {
|
||||||
input.trim_matches([' ', '\n', '\t'])
|
let is_ws = |b: &u8| matches!(b, b' ' | b'\n' | b'\t');
|
||||||
|
let start = input.iter().position(|b| !is_ws(b)).unwrap_or(input.len());
|
||||||
|
let end = input
|
||||||
|
.iter()
|
||||||
|
.rposition(|b| !is_ws(b))
|
||||||
|
.map_or(start, |i| i + 1);
|
||||||
|
&input[start..end]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expands a path that starts with a tilde (~) to the home directory of
|
/// Expands a path that starts with a tilde (~) to the home directory of
|
||||||
@@ -121,31 +139,41 @@ pub fn expand_home(path: &str) -> Result<String, ExpandError> {
|
|||||||
expand_home_with_capacity(path, MAX_PATH_BYTES)
|
expand_home_with_capacity(path, MAX_PATH_BYTES)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Byte-preserving [`expand_home`].
|
||||||
|
pub fn expand_home_os(path: &OsStr) -> Result<OsString, ExpandError> {
|
||||||
|
expand_home_os_with_capacity(path, MAX_PATH_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
/// Like [`expand_home`] with an explicit output capacity (emulating the
|
/// Like [`expand_home`] with an explicit output capacity (emulating the
|
||||||
/// caller-provided buffer of the Zig version).
|
/// caller-provided buffer of the Zig version).
|
||||||
|
pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, ExpandError> {
|
||||||
|
expand_home_os_with_capacity(&osbytes::config_str_to_os(path), cap)
|
||||||
|
.map(|p| osbytes::os_to_config_string(&p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows: `~/` is not an idiom generally used on Windows.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn expand_home_with_capacity(path: &str, _cap: usize) -> Result<String, ExpandError> {
|
pub fn expand_home_os_with_capacity(path: &OsStr, _cap: usize) -> Result<OsString, ExpandError> {
|
||||||
// `~/` is not an idiom generally used on Windows
|
|
||||||
Ok(path.to_owned())
|
Ok(path.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// See the Windows variant above.
|
/// See the Windows variant above.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, ExpandError> {
|
pub fn expand_home_os_with_capacity(path: &OsStr, cap: usize) -> Result<OsString, ExpandError> {
|
||||||
if !path.starts_with("~/") {
|
let bytes = osbytes::os_bytes(path);
|
||||||
|
if !bytes.starts_with(b"~/") {
|
||||||
return Ok(path.to_owned());
|
return Ok(path.to_owned());
|
||||||
}
|
}
|
||||||
let Ok(Some(home_dir)) = home_with_capacity(cap) else {
|
let Ok(Some(home_dir)) = home_os_with_capacity(cap) else {
|
||||||
return Err(ExpandError::HomeDetectionFailed);
|
return Err(ExpandError::HomeDetectionFailed);
|
||||||
};
|
};
|
||||||
let rest = &path[1..]; // Skip the ~
|
let mut out = osbytes::os_bytes(&home_dir);
|
||||||
let expanded_len = home_dir.len() + rest.len();
|
let rest = &bytes[1..]; // Skip the ~
|
||||||
|
if out.len() + rest.len() > cap {
|
||||||
if expanded_len > cap {
|
|
||||||
return Err(ExpandError::BufferTooSmall);
|
return Err(ExpandError::BufferTooSmall);
|
||||||
}
|
}
|
||||||
|
out.extend_from_slice(rest);
|
||||||
Ok(format!("{home_dir}{rest}"))
|
Ok(osbytes::os_from_bytes(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -211,6 +239,20 @@ mod tests_unix {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_preserves_non_utf8_env() {
|
||||||
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||||
|
let h = OsString::from_vec(b"/home/\xffu".to_vec());
|
||||||
|
temp_env::with_var("HOME", Some(&h), || {
|
||||||
|
assert_eq!(home_os().unwrap().unwrap(), h);
|
||||||
|
let e = expand_home_os(OsStr::from_bytes(b"~/x\xfe")).unwrap();
|
||||||
|
assert_eq!(e.as_bytes(), b"/home/\xffu/x\xfe");
|
||||||
|
// The String API round-trips through the escape encoding.
|
||||||
|
let s = expand_home("~/y").unwrap();
|
||||||
|
assert_eq!(osbytes::config_str_to_bytes(&s), b"/home/\xffu/y");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn home_prefers_env() {
|
fn home_prefers_env() {
|
||||||
temp_env::with_var("HOME", Some("/some/home"), || {
|
temp_env::with_var("HOME", Some("/some/home"), || {
|
||||||
|
|||||||
@@ -79,26 +79,49 @@ pub fn tr(msgid: &str) -> String {
|
|||||||
/// a POSIX-compliant value.
|
/// a POSIX-compliant value.
|
||||||
///
|
///
|
||||||
/// The Zig version is a thin layer over gnulib's
|
/// The Zig version is a thin layer over gnulib's
|
||||||
/// `_libintl_locale_name_canonicalize` (only linked when building libintl
|
/// `_libintl_locale_name_canonicalize` (the Mac OS X flavour of
|
||||||
/// from source, i.e. macOS). That function isn't available here, so this is
|
/// `gl_locale_name_canonicalize`), after fixing up some `zh` locales. This
|
||||||
/// a pure-Rust approximation of the cases that matter: BCP-47 style tags
|
/// ports that function exactly, including its legacy-name, language-tag and
|
||||||
/// have `-` replaced with `_` and the Chinese script tags are mapped to
|
/// script tables ([`crate::i18n_canon_table`]). Encodings are NOT parsed out
|
||||||
/// their POSIX region equivalents. Encodings are NOT parsed out (like the
|
/// (like the original, `en_US.UTF-8` becomes `en_US.UTF_8`).
|
||||||
/// original, `en_US.UTF-8` becomes `en_US.UTF_8`).
|
|
||||||
pub fn canonicalize_locale(locale: &str) -> String {
|
pub fn canonicalize_locale(locale: &str) -> String {
|
||||||
// Fix zh locales
|
// Fix zh locales
|
||||||
if let Some(fixed) = fix_zh_locale(locale) {
|
if let Some(fixed) = fix_zh_locale(locale) {
|
||||||
return fixed.to_owned();
|
return fixed.to_owned();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emulate gnulib's mapping of bare zh script tags.
|
libintl_locale_name_canonicalize(locale)
|
||||||
match locale {
|
}
|
||||||
"zh-Hans" => return "zh_CN".to_owned(),
|
|
||||||
"zh-Hant" => return "zh_TW".to_owned(),
|
/// `gl_locale_name_canonicalize` (gnulib `localename-unsafe.c`).
|
||||||
_ => {}
|
fn libintl_locale_name_canonicalize(name: &str) -> String {
|
||||||
|
use crate::i18n_canon_table::{LANGTAG_TABLE, LEGACY_TABLE, SCRIPT_TABLE};
|
||||||
|
|
||||||
|
// Step 1: Convert using legacy_table.
|
||||||
|
if name.as_bytes().first().is_some_and(u8::is_ascii_uppercase)
|
||||||
|
&& let Some((_, unixy)) = LEGACY_TABLE.iter().find(|(l, _)| *l == name)
|
||||||
|
{
|
||||||
|
return (*unixy).to_owned();
|
||||||
}
|
}
|
||||||
|
|
||||||
locale.replace('-', "_")
|
// Step 2: Convert using langtag_table and script_table.
|
||||||
|
if name.len() == 7 && name.as_bytes()[2] == b'-' {
|
||||||
|
if let Some((_, unixy)) = LANGTAG_TABLE.iter().find(|(l, _)| *l == name) {
|
||||||
|
return (*unixy).to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
|
// `name.get(3..)` is None only when byte 3 is not a char boundary,
|
||||||
|
// in which case no script entry can match.
|
||||||
|
if let Some(script) = name.get(3..)
|
||||||
|
&& let Some((_, unixy)) = SCRIPT_TABLE.iter().find(|(s, _)| *s == script)
|
||||||
|
{
|
||||||
|
// name[2] = '@'; strcpy (name + 3, unixy)
|
||||||
|
return format!("{}@{unixy}", &name[..2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Convert new-style dash to Unix underscore.
|
||||||
|
name.replace('-', "_")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles some zh locales canonicalization because internal libintl
|
/// Handles some zh locales canonicalization because internal libintl
|
||||||
@@ -183,6 +206,37 @@ mod tests {
|
|||||||
assert_eq!("en_US.UTF_8", canonicalize_locale("en_US.UTF-8"));
|
assert_eq!("en_US.UTF_8", canonicalize_locale("en_US.UTF-8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonicalize_locale_libintl_tables() {
|
||||||
|
// langtag_table
|
||||||
|
assert_eq!("pa_IN", canonicalize_locale("pa-Guru"));
|
||||||
|
assert_eq!("pa_PK", canonicalize_locale("pa-Arab"));
|
||||||
|
assert_eq!("az", canonicalize_locale("az-Latn"));
|
||||||
|
assert_eq!("sr", canonicalize_locale("sr-Cyrl"));
|
||||||
|
assert_eq!("kk", canonicalize_locale("kk-Cyrl"));
|
||||||
|
assert_eq!("ga", canonicalize_locale("ga-dots"));
|
||||||
|
// 8 bytes long: the `strlen == 7` guard skips the tables (upstream quirk).
|
||||||
|
assert_eq!("yue_Hans", canonicalize_locale("yue-Hans"));
|
||||||
|
// script_table (name[2] = '@')
|
||||||
|
assert_eq!("sr@latin", canonicalize_locale("sr-Latn"));
|
||||||
|
assert_eq!("uz@arabic", canonicalize_locale("uz-Arab"));
|
||||||
|
assert_eq!("mn@mongolian", canonicalize_locale("mn-Mong"));
|
||||||
|
// legacy_table (only for names starting uppercase)
|
||||||
|
assert_eq!("pt_BR", canonicalize_locale("Brazilian Portuguese"));
|
||||||
|
assert_eq!("zh_TW", canonicalize_locale("Traditional Chinese"));
|
||||||
|
assert_eq!("de", canonicalize_locale("German"));
|
||||||
|
assert_eq!("yi", canonicalize_locale("Yiddish"));
|
||||||
|
assert_eq!("english", canonicalize_locale("english"));
|
||||||
|
// dash fallback
|
||||||
|
assert_eq!("pt_BR", canonicalize_locale("pt-BR"));
|
||||||
|
assert_eq!("en_US", canonicalize_locale("en-US"));
|
||||||
|
// non-ASCII input of length 7 must not panic
|
||||||
|
assert_eq!(
|
||||||
|
"\u{e9}\u{e9}\u{e9}x",
|
||||||
|
canonicalize_locale("\u{e9}\u{e9}\u{e9}x")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn locales_map_and_static_locale() {
|
fn locales_map_and_static_locale() {
|
||||||
assert!(is_supported_locale("zh_CN"));
|
assert!(is_supported_locale("zh_CN"));
|
||||||
|
|||||||
@@ -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;
|
pub mod file_type;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub mod flatpak;
|
pub mod flatpak;
|
||||||
|
pub mod osbytes;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub mod flatpak {
|
pub mod flatpak {
|
||||||
//! Flatpak does not exist on Windows.
|
//! Flatpak does not exist on Windows.
|
||||||
@@ -45,6 +46,8 @@ pub mod homedir;
|
|||||||
pub mod hostname;
|
pub mod hostname;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
mod i18n_canon_table;
|
||||||
pub mod i18n_locales;
|
pub mod i18n_locales;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod kernel_info;
|
pub mod kernel_info;
|
||||||
@@ -73,10 +76,23 @@ pub mod passwd {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Byte-preserving entry (same shape as the Unix one).
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct EntryOs {
|
||||||
|
pub shell: Option<std::ffi::OsString>,
|
||||||
|
pub home: Option<std::ffi::OsString>,
|
||||||
|
pub name: Option<std::ffi::OsString>,
|
||||||
|
}
|
||||||
|
|
||||||
/// No passwd database on Windows: always an empty entry.
|
/// No passwd database on Windows: always an empty entry.
|
||||||
pub fn get() -> std::io::Result<Entry> {
|
pub fn get() -> std::io::Result<Entry> {
|
||||||
Ok(Entry::default())
|
Ok(Entry::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// See [`get`].
|
||||||
|
pub fn get_os() -> std::io::Result<EntryOs> {
|
||||||
|
Ok(EntryOs::default())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod path;
|
pub mod path;
|
||||||
@@ -123,13 +139,13 @@ pub use env::{
|
|||||||
get_env_map,
|
get_env_map,
|
||||||
getenv,
|
getenv,
|
||||||
getenv_not_empty,
|
getenv_not_empty,
|
||||||
|
getenv_not_empty_os,
|
||||||
|
getenv_os,
|
||||||
prepend_env,
|
prepend_env,
|
||||||
prepend_env_os,
|
prepend_env_os,
|
||||||
};
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use file::alloc_tmp_dir;
|
pub use file::{Rlimit, alloc_tmp_dir, alloc_tmp_dir_os, fix_max_files, restore_max_files};
|
||||||
#[cfg(unix)]
|
|
||||||
pub use file::{Rlimit, fix_max_files, restore_max_files};
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub use flatpak::FlatpakHostCommand;
|
pub use flatpak::FlatpakHostCommand;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
|||||||
@@ -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"))]
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
use crate::flatpak::is_flatpak;
|
use crate::flatpak::is_flatpak;
|
||||||
|
use crate::osbytes;
|
||||||
|
|
||||||
/// Entry that is retrieved from the passwd API. This only contains the fields
|
/// Entry that is retrieved from the passwd API. This only contains the fields
|
||||||
/// we care about.
|
/// we care about.
|
||||||
@@ -10,19 +13,45 @@ pub struct Entry {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the passwd entry for the currently executing user.
|
/// Byte-preserving [`Entry`] (Zig slices are arbitrary bytes).
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct EntryOs {
|
||||||
|
pub shell: Option<OsString>,
|
||||||
|
pub home: Option<OsString>,
|
||||||
|
pub name: Option<OsString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<EntryOs> for Entry {
|
||||||
|
fn from(e: EntryOs) -> Self {
|
||||||
|
let f = |v: Option<OsString>| v.map(|v| osbytes::os_to_config_string(&v));
|
||||||
|
Entry {
|
||||||
|
shell: f(e.shell),
|
||||||
|
home: f(e.home),
|
||||||
|
name: f(e.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the passwd entry for the currently executing user. Non-UTF-8 bytes
|
||||||
|
/// are carried via the reversible [`crate::osbytes`] encoding; see
|
||||||
|
/// [`get_os`] for the raw bytes.
|
||||||
pub fn get() -> std::io::Result<Entry> {
|
pub fn get() -> std::io::Result<Entry> {
|
||||||
|
get_os().map(Entry::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte-preserving [`get`].
|
||||||
|
pub fn get_os() -> std::io::Result<EntryOs> {
|
||||||
let uid = nix::unistd::getuid();
|
let uid = nix::unistd::getuid();
|
||||||
let user = match nix::unistd::User::from_uid(uid) {
|
let user = match nix::unistd::User::from_uid(uid) {
|
||||||
Ok(Some(user)) => user,
|
Ok(Some(user)) => user,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
// Future: let's check if a better shell is available like zsh
|
// Future: let's check if a better shell is available like zsh
|
||||||
log::warn!("no pw entry to detect default shell, will default to 'sh'");
|
log::warn!("no pw entry to detect default shell, will default to 'sh'");
|
||||||
return Ok(Entry::default());
|
return Ok(EntryOs::default());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("error retrieving pw entry code={e}");
|
log::warn!("error retrieving pw entry code={e}");
|
||||||
return Ok(Entry::default());
|
return Ok(EntryOs::default());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,10 +64,10 @@ pub fn get() -> std::io::Result<Entry> {
|
|||||||
return host_entry(&user.name);
|
return host_entry(&user.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Entry {
|
Ok(EntryOs {
|
||||||
shell: Some(user.shell.to_string_lossy().into_owned()),
|
shell: Some(user.shell.into_os_string()),
|
||||||
home: Some(user.dir.to_string_lossy().into_owned()),
|
home: Some(user.dir.into_os_string()),
|
||||||
name: Some(user.name),
|
name: Some(OsString::from(user.name)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +78,7 @@ pub fn get() -> std::io::Result<Entry> {
|
|||||||
/// `Pty`) depends on this crate, so a minimal openpt/ptsname pair is opened
|
/// `Pty`) depends on this crate, so a minimal openpt/ptsname pair is opened
|
||||||
/// here with rustix instead. Like Zig, only `shell` and `home` are filled in.
|
/// here with rustix instead. Like Zig, only `shell` and `home` are filled in.
|
||||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
fn host_entry(name: &str) -> std::io::Result<Entry> {
|
fn host_entry(name: &str) -> std::io::Result<EntryOs> {
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
|
||||||
use rustix::{
|
use rustix::{
|
||||||
@@ -112,17 +141,20 @@ fn host_entry(name: &str) -> std::io::Result<Entry> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(parse_getent(&String::from_utf8_lossy(&output)))
|
Ok(parse_getent(&output))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
|
||||||
/// Shell and home are the last two `:`-separated fields of the output.
|
/// Shell and home are the last two `:`-separated fields of the output.
|
||||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
fn parse_getent(output: &str) -> Entry {
|
fn parse_getent(output: &[u8]) -> EntryOs {
|
||||||
let mut it = output.trim_end_matches([' ', '\r', '\n']).rsplit(':');
|
let end = output
|
||||||
let shell = it.next().map(str::to_owned);
|
.iter()
|
||||||
let home = it.next().map(str::to_owned);
|
.rposition(|b| !matches!(b, b' ' | b'\r' | b'\n'))
|
||||||
Entry {
|
.map_or(0, |i| i + 1);
|
||||||
|
let mut it = output[..end].rsplit(|&b| b == b':');
|
||||||
|
let shell = it.next().map(|b| osbytes::os_from_bytes(b.to_vec()));
|
||||||
|
let home = it.next().map(|b| osbytes::os_from_bytes(b.to_vec()));
|
||||||
|
EntryOs {
|
||||||
shell,
|
shell,
|
||||||
home,
|
home,
|
||||||
name: None,
|
name: None,
|
||||||
@@ -136,12 +168,24 @@ mod tests {
|
|||||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_getent_output() {
|
fn parse_getent_output() {
|
||||||
let e = parse_getent("mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n");
|
let e = parse_getent(b"mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n");
|
||||||
assert_eq!(e.shell.as_deref(), Some("/bin/zsh"));
|
assert_eq!(e.shell.as_deref(), Some(std::ffi::OsStr::new("/bin/zsh")));
|
||||||
assert_eq!(e.home.as_deref(), Some("/home/mitchell"));
|
assert_eq!(
|
||||||
|
e.home.as_deref(),
|
||||||
|
Some(std::ffi::OsStr::new("/home/mitchell"))
|
||||||
|
);
|
||||||
assert_eq!(e.name, None);
|
assert_eq!(e.name, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
|
#[test]
|
||||||
|
fn parse_getent_keeps_non_utf8() {
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
let e = parse_getent(b"u:x:1:1::/home/\xff:/bin/\xfesh\n");
|
||||||
|
assert_eq!(e.shell.unwrap().as_bytes(), b"/bin/\xfesh");
|
||||||
|
assert_eq!(e.home.unwrap().as_bytes(), b"/home/\xff");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_entry() {
|
fn get_entry() {
|
||||||
// We should be able to get an entry
|
// We should be able to get an entry
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::io;
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::{ffi::OsStr, io, path::PathBuf};
|
||||||
|
|
||||||
/// `std.fs.path.delimiter` / `sep`.
|
/// `std.fs.path.delimiter` / `sep`.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -25,28 +25,44 @@ pub enum Error {
|
|||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search for "cmd" in the PATH and return the absolute path.
|
/// Search for "cmd" in the PATH and return the absolute path. Non-UTF-8
|
||||||
|
/// bytes are carried via [`crate::osbytes`]; see [`expand_os`].
|
||||||
pub fn expand(cmd: &str) -> Result<Option<String>, Error> {
|
pub fn expand(cmd: &str) -> Result<Option<String>, Error> {
|
||||||
|
let cmd = crate::osbytes::config_str_to_os(cmd);
|
||||||
|
Ok(expand_os(&cmd)?.map(|p| crate::osbytes::os_to_config_string(p.as_os_str())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte-preserving [`expand`]: `$PATH` and the command are handled as raw
|
||||||
|
/// bytes, so executables in directories with non-UTF-8 names are found.
|
||||||
|
pub fn expand_os(cmd: &OsStr) -> Result<Option<PathBuf>, Error> {
|
||||||
|
let cmd_bytes = crate::osbytes::os_bytes(cmd);
|
||||||
|
|
||||||
// If the command already contains a slash, then we return it as-is
|
// If the command already contains a slash, then we return it as-is
|
||||||
// because it is assumed to be absolute or relative.
|
// because it is assumed to be absolute or relative.
|
||||||
if cmd.contains('/') {
|
if cmd_bytes.contains(&b'/') {
|
||||||
return Ok(Some(cmd.to_owned()));
|
return Ok(Some(PathBuf::from(cmd)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(path_var) = std::env::var_os("PATH") else {
|
let Some(path_var) = std::env::var_os("PATH") else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let path_var = path_var.to_string_lossy();
|
let path_var = crate::osbytes::os_bytes(&path_var);
|
||||||
|
|
||||||
let mut seen_eacces = false;
|
let mut seen_eacces = false;
|
||||||
for search_path in path_var.split(DELIMITER).filter(|s| !s.is_empty()) {
|
for search_path in path_var
|
||||||
|
.split(|&b| b == DELIMITER as u8)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
// We need enough space in our path buffer to store this
|
// We need enough space in our path buffer to store this
|
||||||
let path_len = search_path.len() + cmd.len() + 1;
|
let path_len = search_path.len() + cmd_bytes.len() + 1;
|
||||||
if MAX_PATH_BYTES < path_len {
|
if MAX_PATH_BYTES < path_len {
|
||||||
return Err(Error::PathTooLong);
|
return Err(Error::PathTooLong);
|
||||||
}
|
}
|
||||||
|
|
||||||
let full_path = format!("{search_path}{SEP}{cmd}");
|
let mut full = search_path.to_vec();
|
||||||
|
full.push(SEP as u8);
|
||||||
|
full.extend_from_slice(&cmd_bytes);
|
||||||
|
let full_path = PathBuf::from(crate::osbytes::os_from_bytes(full));
|
||||||
|
|
||||||
// Stat it
|
// Stat it
|
||||||
let f = match std::fs::File::open(&full_path) {
|
let f = match std::fs::File::open(&full_path) {
|
||||||
@@ -127,4 +143,23 @@ mod tests {
|
|||||||
assert!(expand("adir").unwrap().is_none());
|
assert!(expand("adir").unwrap().is_none());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn expand_finds_executable_in_non_utf8_dir() {
|
||||||
|
use std::os::unix::{
|
||||||
|
ffi::{OsStrExt, OsStringExt},
|
||||||
|
fs::PermissionsExt,
|
||||||
|
};
|
||||||
|
let td = crate::TempDir::init().unwrap();
|
||||||
|
let dir = td.path().join(OsStr::from_bytes(b"d\xffir"));
|
||||||
|
std::fs::create_dir(&dir).unwrap();
|
||||||
|
let exe = dir.join("mytool");
|
||||||
|
std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
|
||||||
|
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
let pv = std::ffi::OsString::from_vec(dir.as_os_str().as_bytes().to_vec());
|
||||||
|
temp_env::with_var("PATH", Some(&pv), || {
|
||||||
|
assert_eq!(expand_os(OsStr::new("mytool")).unwrap(), Some(exe.clone()));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
use std::{io, path::Path};
|
use std::{
|
||||||
|
ffi::OsString,
|
||||||
|
io,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::path_join;
|
use crate::osbytes::{config_str_to_os, os_to_config_string};
|
||||||
|
|
||||||
|
fn enc(p: &Path) -> String {
|
||||||
|
os_to_config_string(p.as_os_str())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct ResourcesDir {
|
pub struct ResourcesDir {
|
||||||
@@ -25,6 +33,17 @@ impl ResourcesDir {
|
|||||||
pub fn host(&self) -> Option<&str> {
|
pub fn host(&self) -> Option<&str> {
|
||||||
self.host_path.as_deref().or(self.app_path.as_deref())
|
self.host_path.as_deref().or(self.app_path.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Byte-preserving [`ResourcesDir::app`]: the string fields carry
|
||||||
|
/// non-UTF-8 bytes via [`crate::osbytes`], this decodes them.
|
||||||
|
pub fn app_os(&self) -> Option<PathBuf> {
|
||||||
|
self.app().map(|p| PathBuf::from(config_str_to_os(p)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte-preserving [`ResourcesDir::host`].
|
||||||
|
pub fn host_os(&self) -> Option<PathBuf> {
|
||||||
|
self.host().map(|p| PathBuf::from(config_str_to_os(p)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sentinels we look for in the path to know we've found the resources
|
/// Sentinels we look for in the path to know we've found the resources
|
||||||
@@ -50,7 +69,7 @@ const DEV_SENTINELS: [&str; 1] = ["terminfo/x/xterm-ghostty"];
|
|||||||
/// Zig's `builtin.mode == .Debug` is mapped to `cfg!(debug_assertions)`.
|
/// Zig's `builtin.mode == .Debug` is mapped to `cfg!(debug_assertions)`.
|
||||||
pub fn resources_dir() -> io::Result<ResourcesDir> {
|
pub fn resources_dir() -> io::Result<ResourcesDir> {
|
||||||
let exe = std::env::current_exe().ok();
|
let exe = std::env::current_exe().ok();
|
||||||
let env = std::env::var("GHOSTTY_RESOURCES_DIR").ok();
|
let env = std::env::var_os("GHOSTTY_RESOURCES_DIR");
|
||||||
Ok(resources_dir_impl(
|
Ok(resources_dir_impl(
|
||||||
cfg!(debug_assertions),
|
cfg!(debug_assertions),
|
||||||
exe.as_deref(),
|
exe.as_deref(),
|
||||||
@@ -58,7 +77,7 @@ pub fn resources_dir() -> io::Result<ResourcesDir> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> ResourcesDir {
|
fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<OsString>) -> ResourcesDir {
|
||||||
// Use the GHOSTTY_RESOURCES_DIR environment variable in release builds.
|
// Use the GHOSTTY_RESOURCES_DIR environment variable in release builds.
|
||||||
//
|
//
|
||||||
// In debug builds we try using terminfo detection first instead, since
|
// In debug builds we try using terminfo detection first instead, since
|
||||||
@@ -67,7 +86,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
|||||||
// freshly built ones under zig-out/share/ghostty.
|
// freshly built ones under zig-out/share/ghostty.
|
||||||
if !debug && let Some(dir) = env.as_ref().filter(|d| !d.is_empty()) {
|
if !debug && let Some(dir) = env.as_ref().filter(|d| !d.is_empty()) {
|
||||||
return ResourcesDir {
|
return ResourcesDir {
|
||||||
app_path: Some(dir.clone()),
|
app_path: Some(os_to_config_string(dir)),
|
||||||
host_path: None,
|
host_path: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -82,14 +101,12 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
|||||||
// bundle as we expect it.
|
// bundle as we expect it.
|
||||||
while let Some(dir) = exe.parent() {
|
while let Some(dir) = exe.parent() {
|
||||||
exe = dir;
|
exe = dir;
|
||||||
let dir_str = dir.to_string_lossy();
|
|
||||||
|
|
||||||
// On MacOS, we look for the app bundle path.
|
// On MacOS, we look for the app bundle path.
|
||||||
#[cfg(target_vendor = "apple")]
|
#[cfg(target_vendor = "apple")]
|
||||||
for sentinel in SENTINELS {
|
for sentinel in SENTINELS {
|
||||||
if let Some(v) = maybe_dir(&dir_str, "Contents/Resources", sentinel) {
|
if let Some(v) = maybe_dir_os(dir, "Contents/Resources", sentinel) {
|
||||||
return ResourcesDir {
|
return ResourcesDir {
|
||||||
app_path: Some(path_join(&[&v, "ghostty"])),
|
app_path: Some(enc(&v.join("ghostty"))),
|
||||||
host_path: None,
|
host_path: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -97,9 +114,9 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
|||||||
|
|
||||||
// We look for a /usr/share style path.
|
// We look for a /usr/share style path.
|
||||||
for sentinel in SENTINELS {
|
for sentinel in SENTINELS {
|
||||||
if let Some(v) = maybe_dir(&dir_str, "share", sentinel) {
|
if let Some(v) = maybe_dir_os(dir, "share", sentinel) {
|
||||||
return ResourcesDir {
|
return ResourcesDir {
|
||||||
app_path: Some(path_join(&[&v, "ghostty"])),
|
app_path: Some(enc(&v.join("ghostty"))),
|
||||||
host_path: None,
|
host_path: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -110,7 +127,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
|||||||
// fallback and use the provided resources dir.
|
// fallback and use the provided resources dir.
|
||||||
if debug && let Some(dir) = env.filter(|d| !d.is_empty()) {
|
if debug && let Some(dir) = env.filter(|d| !d.is_empty()) {
|
||||||
return ResourcesDir {
|
return ResourcesDir {
|
||||||
app_path: Some(dir),
|
app_path: Some(os_to_config_string(&dir)),
|
||||||
host_path: None,
|
host_path: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -122,7 +139,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
|||||||
// lives inside this workspace.
|
// lives inside this workspace.
|
||||||
if debug && let Some(dir) = dev_resources_dir(exe_start) {
|
if debug && let Some(dir) = dev_resources_dir(exe_start) {
|
||||||
return ResourcesDir {
|
return ResourcesDir {
|
||||||
app_path: Some(dir),
|
app_path: Some(enc(&dir)),
|
||||||
host_path: None,
|
host_path: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -137,7 +154,7 @@ fn workspace_root() -> std::path::PathBuf {
|
|||||||
|
|
||||||
/// Returns `<workspace>/resources` if `exe` is inside the workspace this
|
/// Returns `<workspace>/resources` if `exe` is inside the workspace this
|
||||||
/// crate was built from and the directory contains our sentinel.
|
/// crate was built from and the directory contains our sentinel.
|
||||||
fn dev_resources_dir(exe: &Path) -> Option<String> {
|
fn dev_resources_dir(exe: &Path) -> Option<PathBuf> {
|
||||||
let root = workspace_root().canonicalize().ok()?;
|
let root = workspace_root().canonicalize().ok()?;
|
||||||
let exe = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
|
let exe = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
|
||||||
if !exe.starts_with(&root) {
|
if !exe.starts_with(&root) {
|
||||||
@@ -148,7 +165,15 @@ fn dev_resources_dir(exe: &Path) -> Option<String> {
|
|||||||
.iter()
|
.iter()
|
||||||
.chain(DEV_SENTINELS.iter())
|
.chain(DEV_SENTINELS.iter())
|
||||||
.any(|s| res.join(s).exists())
|
.any(|s| res.join(s).exists())
|
||||||
.then(|| res.to_string_lossy().into_owned())
|
.then_some(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte-preserving [`maybe_dir`].
|
||||||
|
pub fn maybe_dir_os(base: &Path, sub: &str, suffix: &str) -> Option<PathBuf> {
|
||||||
|
let dir = base.join(sub);
|
||||||
|
// Folder doesn't exist. If a different error happens its okay
|
||||||
|
// we just ignore it and move on.
|
||||||
|
dir.join(suffix).exists().then_some(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Little helper to check if the "base/sub/suffix" directory exists and
|
/// Little helper to check if the "base/sub/suffix" directory exists and
|
||||||
@@ -204,7 +229,7 @@ mod tests {
|
|||||||
fn env_in_release_wins_and_in_debug_is_fallback() {
|
fn env_in_release_wins_and_in_debug_is_fallback() {
|
||||||
let td = make_tree();
|
let td = make_tree();
|
||||||
let exe = td.path().join("bin/ghostty");
|
let exe = td.path().join("bin/ghostty");
|
||||||
let env = Some("/env/res".to_owned());
|
let env = Some(OsString::from("/env/res"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources_dir_impl(false, Some(&exe), env.clone()).app(),
|
resources_dir_impl(false, Some(&exe), env.clone()).app(),
|
||||||
Some("/env/res")
|
Some("/env/res")
|
||||||
@@ -220,7 +245,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// Empty env is ignored.
|
// Empty env is ignored.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources_dir_impl(false, None, Some(String::new())).app(),
|
resources_dir_impl(false, None, Some(OsString::new())).app(),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -238,6 +263,23 @@ mod tests {
|
|||||||
assert_eq!(r.app(), None);
|
assert_eq!(r.app(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_utf8_env_dir_is_honored_and_exe_dir_detected() {
|
||||||
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||||
|
let env = OsString::from_vec(b"/env/\xffres".to_vec());
|
||||||
|
let r = resources_dir_impl(false, None, Some(env.clone()));
|
||||||
|
assert_eq!(r.app_os().unwrap().as_os_str(), env.as_os_str());
|
||||||
|
|
||||||
|
// Executable lives under a directory with a non-UTF-8 name.
|
||||||
|
let td = crate::TempDir::init().unwrap();
|
||||||
|
let root = td.path().join(std::ffi::OsStr::from_bytes(b"r\xfeoot"));
|
||||||
|
std::fs::create_dir_all(root.join("share/terminfo/x")).unwrap();
|
||||||
|
std::fs::write(root.join("share/terminfo/x/xterm-ghostty"), b"").unwrap();
|
||||||
|
std::fs::create_dir_all(root.join("bin")).unwrap();
|
||||||
|
let r = resources_dir_impl(true, Some(&root.join("bin/ghostty")), None);
|
||||||
|
assert_eq!(r.app_os().unwrap(), root.join("share/ghostty"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn maybe_dir_checks_suffix() {
|
fn maybe_dir_checks_suffix() {
|
||||||
let td = make_tree();
|
let td = make_tree();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::{
|
|||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::file::alloc_tmp_dir;
|
use crate::file::alloc_tmp_dir_os;
|
||||||
|
|
||||||
// The amount of random bytes to get to determine our filename.
|
// The amount of random bytes to get to determine our filename.
|
||||||
const RANDOM_BYTES: usize = 16;
|
const RANDOM_BYTES: usize = 16;
|
||||||
@@ -89,8 +89,8 @@ fn fill_random(buf: &mut [u8]) -> io::Result<()> {
|
|||||||
impl TempDir {
|
impl TempDir {
|
||||||
/// Create the temporary directory.
|
/// Create the temporary directory.
|
||||||
pub fn init() -> io::Result<TempDir> {
|
pub fn init() -> io::Result<TempDir> {
|
||||||
let parent = match alloc_tmp_dir() {
|
let parent = match alloc_tmp_dir_os() {
|
||||||
Some(p) => PathBuf::from(p),
|
Some(p) => p,
|
||||||
None => std::env::current_dir()?,
|
None => std::env::current_dir()?,
|
||||||
};
|
};
|
||||||
// Verify the parent can be opened (Zig opens it as a Dir).
|
// Verify the parent can be opened (Zig opens it as a Dir).
|
||||||
|
|||||||
@@ -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