Files
ghostty-rs/crates/ghostty-config/src/file_load.rs
T
ddidderr 672e8a4ae1 refactor(config): parse config values from raw bytes like Zig
Zig parses config values (cli/args.zig, Config.zig field parsers,
config/string.zig) from raw byte slices, and each field type decides
whether it requires UTF-8 (InvalidValue otherwise) or stores bytes. The
port escape-encoded raw bytes into Strings at the line reader and let
fields parse the escaped text, so fields could observe encoding
artifacts; successive audits kept finding such leaks (RV5-RV8).

Field parsers now receive the original bytes: text fields validate UTF-8
where Zig does, byte/path fields store bytes (EncodedOs/OsString). This
resolves all 9 RV8 findings (docs/rewrite/notes/review-rv8-codex.md),
each with a regression test: Windows path conversion fails with
BadPathName instead of substituting U+FFFD; Windows process launch uses
strict UTF-8 like Zig's Command; the WTF-8 decoder accepts paired-
surrogate encodings like Zig; literal U+FDD0 Unicode key triggers;
selection-word-chars and clipboard-codepoint-map reject invalid UTF-8;
font style / codepoint-map family names use original bytes; C title
actions forward bytes; D-Bus new-window/Flatpak conversions match Zig.
Design, sweep and validation: docs/rewrite/notes/final-bytes-refactor.md.

Also moves the bundled iTerm2-Color-Schemes license out of
resources/themes/ so +list-themes no longer lists it as a theme
(output is now byte-identical to Ghostty 1.3.1).

Done by Codex (gpt-6-astra), reviewed and verified by the orchestrator.

Test Plan:
- cargo test --workspace --no-fail-fast (3428 passed, exit 0)
- just check-macos (both), just check-windows, just check-wasm
- +show-config --default --docs, +list-keybinds, +list-actions --docs,
  +list-themes --plain compared with /usr/bin/ghostty
2026-10-12 11:57:01 +02:00

270 lines
9.0 KiB
Rust

//! Default config file locations and opening (port of `config/file_load.zig`).
//!
//! On macOS the Application Support paths are used in addition to XDG; the
//! helpers that only need `open` + two candidate paths are platform
//! independent (and unit tested everywhere), the Application Support lookup
//! itself uses `NSFileManager` and is macOS-only.
use std::fs::File;
use ghostty_os::{osbytes::EncodedOs, xdg};
/// Default path for the XDG home configuration file.
pub fn default_xdg_path() -> Result<EncodedOs, xdg::Error> {
xdg::config(&xdg::Options {
subdir: Some("ghostty/config.ghostty"),
home: None,
})
}
/// Ghostty <1.3.0 default path for the XDG home configuration file.
pub fn legacy_default_xdg_path() -> Result<EncodedOs, xdg::Error> {
xdg::config(&xdg::Options {
subdir: Some("ghostty/config"),
home: None,
})
}
/// Preferred default path for the XDG home configuration file.
pub fn preferred_xdg_path() -> Result<EncodedOs, xdg::Error> {
// If the XDG path exists, use that.
let xdg_path = default_xdg_path()?;
if open(&xdg_path).is_ok() {
return Ok(xdg_path);
}
// Try the legacy path
let legacy_xdg_path = legacy_default_xdg_path()?;
if open(&legacy_xdg_path).is_ok() {
return Ok(legacy_xdg_path);
}
// Legacy path and XDG path both don't exist. Return the new one.
Ok(xdg_path)
}
/// Default path for the macOS Application Support configuration file.
#[cfg(target_os = "macos")]
pub fn default_app_support_path() -> Result<EncodedOs, xdg::Error> {
app_support_dir("config.ghostty")
}
/// Ghostty <1.3.0 default path for the macOS Application Support
/// configuration file.
#[cfg(target_os = "macos")]
pub fn legacy_default_app_support_path() -> Result<EncodedOs, xdg::Error> {
app_support_dir("config")
}
#[cfg(target_os = "macos")]
fn app_support_dir(sub_path: &str) -> Result<EncodedOs, xdg::Error> {
ghostty_os::macos::app_support_dir(sub_path)
.map(|p| EncodedOs::from_path(&p))
.map_err(|_| xdg::Error::AppleApiFailed)
}
/// Preferred default path for the macOS Application Support configuration
/// file.
#[cfg(target_os = "macos")]
pub fn preferred_app_support_path() -> Result<EncodedOs, xdg::Error> {
Ok(preferred_of(
default_app_support_path()?,
legacy_default_app_support_path()?,
))
}
/// Pick between a new path and its legacy path: the new one if it exists,
/// else the legacy one if it exists, else the new one.
#[cfg(any(target_os = "macos", test))]
fn preferred_of(new: EncodedOs, legacy: EncodedOs) -> EncodedOs {
// If the new path exists, use that.
if open(&new).is_ok() {
return new;
}
// Try the legacy path
if open(&legacy).is_ok() {
return legacy;
}
// Legacy path and new path both don't exist. Return the new one.
new
}
/// macOS: prefer the Application Support file if it exists, else the XDG
/// one if it exists, else Application Support.
#[cfg(any(target_os = "macos", test))]
fn preferred_macos(
app_support_path: EncodedOs,
xdg_path: &dyn Fn() -> Result<EncodedOs, xdg::Error>,
) -> Result<EncodedOs, xdg::Error> {
// macOS prefers the Application Support directory if it exists.
if open(&app_support_path).is_ok() {
return Ok(app_support_path);
}
// Try the XDG path if it exists
let xdg_path = xdg_path()?;
if open(&xdg_path).is_ok() {
return Ok(xdg_path);
}
// If neither file exists, use app support
Ok(app_support_path)
}
/// Returns the path to the preferred default configuration file.
/// This is the file where users should place their configuration.
///
/// This doesn't create or populate the file with any default
/// contents; downstream callers must handle this.
#[cfg(target_os = "macos")]
pub fn preferred_default_file_path() -> Result<EncodedOs, xdg::Error> {
preferred_macos(preferred_app_support_path()?, &preferred_xdg_path)
}
/// Returns the path to the preferred default configuration file.
/// This is the file where users should place their configuration.
///
/// This doesn't create or populate the file with any default
/// contents; downstream callers must handle this.
#[cfg(not(target_os = "macos"))]
pub fn preferred_default_file_path() -> Result<EncodedOs, xdg::Error> {
// All other platforms use XDG only
preferred_xdg_path()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenFileError {
FileNotFound,
FileIsEmpty,
FileOpenFailed,
NotAFile,
}
impl std::fmt::Display for OpenFileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
OpenFileError::FileNotFound => "FileNotFound",
OpenFileError::FileIsEmpty => "FileIsEmpty",
OpenFileError::FileOpenFailed => "FileOpenFailed",
OpenFileError::NotAFile => "NotAFile",
})
}
}
impl std::error::Error for OpenFileError {}
/// Opens the file at the given path and returns the file handle
/// if it exists and is non-empty. This also constrains the possible
/// errors to a smaller set that we can explicitly handle.
pub fn open(path: &EncodedOs) -> Result<File, OpenFileError> {
let real = path.to_path().map_err(|err| {
log::warn!("invalid config path: {err}");
OpenFileError::FileOpenFailed
})?;
assert!(real.is_absolute());
let file = File::open(&real).map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
OpenFileError::FileNotFound
} else {
log::warn!(
"unexpected file open error path={} err={err}",
real.display()
);
OpenFileError::FileOpenFailed
}
})?;
let meta = file.metadata().map_err(|err| {
log::warn!("error getting file stat path={} err={err}", real.display());
OpenFileError::FileOpenFailed
})?;
if !meta.is_file() {
return Err(OpenFileError::NotAFile);
}
if meta.len() == 0 {
return Err(OpenFileError::FileIsEmpty);
}
Ok(file)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn e(s: &str) -> EncodedOs {
EncodedOs::from_encoded(s)
}
#[test]
fn open_missing_dir_and_empty() {
assert_eq!(
open(&e("/nonexistent/ghostty/file")).unwrap_err(),
OpenFileError::FileNotFound
);
assert_eq!(open(&e("/tmp")).unwrap_err(), OpenFileError::NotAFile);
let p = std::env::temp_dir().join(format!("ghostty-file-load-{}", std::process::id()));
std::fs::write(&p, "").unwrap();
assert_eq!(
open(&EncodedOs::from_path(&p)).unwrap_err(),
OpenFileError::FileIsEmpty
);
std::fs::write(&p, "a=b").unwrap();
assert!(open(&EncodedOs::from_path(&p)).is_ok());
std::fs::remove_file(&p).unwrap();
}
#[test]
fn preferred_of_prefers_existing_new_then_legacy() {
let dir = std::env::temp_dir().join(format!("ghostty-pref-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let new = EncodedOs::from_path(&dir.join("config.ghostty"));
let legacy = EncodedOs::from_path(&dir.join("config"));
let _ = std::fs::remove_file(new.to_path().unwrap());
let _ = std::fs::remove_file(legacy.to_path().unwrap());
// Neither exists: the new one.
assert_eq!(preferred_of(new.clone(), legacy.clone()), new);
// Only legacy exists: the legacy one.
std::fs::write(legacy.to_path().unwrap(), "a=b").unwrap();
assert_eq!(preferred_of(new.clone(), legacy.clone()), legacy);
// Both exist: the new one.
std::fs::write(new.to_path().unwrap(), "a=b").unwrap();
assert_eq!(preferred_of(new.clone(), legacy.clone()), new);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn preferred_macos_order() {
let dir = std::env::temp_dir().join(format!("ghostty-pref-mac-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let app = EncodedOs::from_path(&dir.join("app"));
let xdg_p = EncodedOs::from_path(&dir.join("xdg"));
let _ = std::fs::remove_file(app.to_path().unwrap());
let _ = std::fs::remove_file(xdg_p.to_path().unwrap());
let xdg_fn = || Ok(xdg_p.clone());
// Neither exists: app support.
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app);
// XDG exists only: XDG.
std::fs::write(xdg_p.to_path().unwrap(), "a=b").unwrap();
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), xdg_p);
// App support exists: app support wins.
std::fs::write(app.to_path().unwrap(), "a=b").unwrap();
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn default_paths_end_with_expected_names() {
if let (Ok(a), Ok(b)) = (default_xdg_path(), legacy_default_xdg_path()) {
assert!(a.ends_with_plain("ghostty/config.ghostty"));
assert!(b.ends_with_plain("ghostty/config"));
}
}
}