Files
ghostty-rs/crates/ghostty-os/src/osbytes.rs
T
ddidderr 654acd55e6 fix: +show-face u21/UTF-8 semantics, Zig output buffering, isAbsolute
Resolves RV14 (docs/rewrite/notes/review-rv14-codex.md, final-Z17.md):
- +show-face --cp parses as u21 (new U21 field type): out-of-range
  values give an invalid-value diagnostic and exit 1 instead of a panic;
  --string accepts raw bytes and fails with error.InvalidUtf8 after the
  codepoint lookup like Zig.
- CLI actions write through ZigWriter, a model of Zig 0.15's buffered
  File.writer (fixed buffer, drain on overflow, explicit flush), so
  stdout/stderr interleave in Zig's flush order (+show-face, help,
  version, validate-config, show-config, list-*, ssh-cache, crash-report,
  edit-config, new-window).
- Zig 0.15.2 std.fs.path.isAbsolute port (Windows: \theme and /theme are
  absolute) replaces Path::is_absolute at every Zig isAbsolute site.
- io_uring is available iff a 256-entry ring can be created, like
  libxev; the reactor falls back to IORING_OP_TIMEOUT without EXT_ARG.
- Windows basename (`λ:theme`) verified against 0.15.2 (test added).

Test Plan:
- cargo test --workspace --no-fail-fast
- diff +show-face outputs (pipe) against /usr/bin/ghostty
2026-10-12 15:15:18 +02:00

871 lines
29 KiB
Rust

//! Byte-preserving conversions between OS strings and Rust `String`s.
//!
//! Zig treats paths, arguments and environment values as byte slices. Rust
//! Configuration and CLI parsers receive original bytes. Byte-valued fields
//! use [`EncodedOs`] for storage; legacy string-based OS plumbing and config
//! output formatting use its reversible encoding, a bijection on byte strings:
//!
//! * the escape character is the Unicode noncharacter `U+FDD0`;
//! * 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. Lossless on every platform: Unix returns
/// the bytes, Windows returns WTF-8 (unpaired UTF-16 surrogates are encoded
/// as 3-byte sequences, like Zig's Windows argument/environment handling).
#[cfg(unix)]
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
use std::os::unix::ffi::OsStrExt;
s.as_bytes().to_vec()
}
#[cfg(windows)]
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
use std::os::windows::ffi::OsStrExt;
wide_to_wtf8(&s.encode_wide().collect::<Vec<u16>>())
}
#[cfg(not(any(unix, windows)))]
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
s.to_str().map_or_else(Vec::new, |s| s.as_bytes().to_vec())
}
/// `std.fs.path.isAbsolutePosix` (Zig 0.15.2).
#[must_use]
pub fn zig_is_absolute_posix(path: &[u8]) -> bool {
path.first() == Some(&b'/')
}
/// `std.fs.path.isAbsoluteWindows` (Zig 0.15.2): any leading separator (a
/// rooted or UNC path) or `<byte>:` followed by a separator is absolute.
#[must_use]
pub fn zig_is_absolute_windows(path: &[u8]) -> bool {
match path {
[] => false,
[b'/' | b'\\', ..] => true,
[_, b':', b'/' | b'\\', ..] => true,
_ => false,
}
}
/// `std.fs.path.isAbsolute` for the host platform. Rust's
/// `Path::is_absolute` differs on Windows (it requires a prefix, so `\theme`
/// and `/theme` are not absolute there).
#[must_use]
pub fn zig_is_absolute(path: &[u8]) -> bool {
if cfg!(windows) {
zig_is_absolute_windows(path)
} else {
zig_is_absolute_posix(path)
}
}
/// Build an OS string from raw bytes (WTF-8 on Windows; bytes that are not
/// well-formed WTF-8 become U+FFFD). Filesystem and process operations must
/// use their checked conversions instead.
#[cfg(unix)]
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
use std::os::unix::ffi::OsStringExt;
OsString::from_vec(b)
}
#[cfg(windows)]
#[allow(clippy::needless_pass_by_value)] // same signature as the Unix variant
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
use std::os::windows::ffi::OsStringExt;
OsString::from_wide(&wtf8_to_wide(&b))
}
#[cfg(not(any(unix, windows)))]
#[allow(clippy::needless_pass_by_value)]
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
OsString::from(String::from_utf8_lossy(&b).into_owned())
}
/// Encode UTF-16 code units (possibly with unpaired surrogates) as WTF-8.
/// Platform independent so the Windows logic is testable everywhere.
pub fn wide_to_wtf8(wide: &[u16]) -> Vec<u8> {
let mut out = Vec::with_capacity(wide.len());
for r in char::decode_utf16(wide.iter().copied()) {
match r {
Ok(c) => {
let mut buf = [0u8; 4];
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
Err(e) => {
let u = u32::from(e.unpaired_surrogate());
out.push(0xE0 | (u >> 12) as u8);
out.push(0x80 | ((u >> 6) & 0x3F) as u8);
out.push(0x80 | (u & 0x3F) as u8);
}
}
}
out
}
/// Decode WTF-8 into UTF-16 code units. Surrogate code points encoded as
/// 3-byte sequences are kept as lone surrogates; anything not well-formed
/// WTF-8 yields U+FFFD for each offending byte.
pub fn wtf8_to_wide(b: &[u8]) -> Vec<u16> {
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
let cont = |j: usize| {
b.get(j)
.filter(|c| (**c & 0xC0) == 0x80)
.map(|c| u32::from(*c & 0x3F))
};
while i < b.len() {
let lead = b[i];
let (cp, len) = match lead {
0x00..=0x7F => (u32::from(lead), 1),
0xC2..=0xDF => match cont(i + 1) {
Some(c1) => ((u32::from(lead & 0x1F) << 6) | c1, 2),
None => (0xFFFD, 1),
},
0xE0..=0xEF => match (cont(i + 1), cont(i + 2)) {
(Some(c1), Some(c2)) if !(lead == 0xE0 && b[i + 1] < 0xA0) => {
((u32::from(lead & 0x0F) << 12) | (c1 << 6) | c2, 3)
}
_ => (0xFFFD, 1),
},
0xF0..=0xF4 => match (cont(i + 1), cont(i + 2), cont(i + 3)) {
(Some(c1), Some(c2), Some(c3)) => {
let cp = (u32::from(lead & 0x07) << 18) | (c1 << 12) | (c2 << 6) | c3;
if (0x1_0000..=0x10_FFFF).contains(&cp) {
(cp, 4)
} else {
(0xFFFD, 1)
}
}
_ => (0xFFFD, 1),
},
_ => (0xFFFD, 1),
};
i += len;
if cp >= 0x1_0000 {
let c = cp - 0x1_0000;
out.push(0xD800 | (c >> 10) as u16);
out.push(0xDC00 | (c & 0x3FF) as u16);
} else {
out.push(cp as u16);
}
}
out
}
/// The bytes are not well-formed WTF-8 (Zig: `error.InvalidWtf8` /
/// `error.InvalidUtf8` from `std.unicode.utf8ToUtf16Le*`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid wtf-8")]
pub struct InvalidWtf8;
/// Strict [`wtf8_to_wide`]: malformed input is an error instead of U+FFFD.
/// Lone surrogates encoded as 3-byte sequences are accepted (WTF-8). This is
/// the conversion used by Windows filesystem paths; process launch requires UTF-8.
pub fn try_wtf8_to_wide(b: &[u8]) -> Result<Vec<u16>, InvalidWtf8> {
let mut result = Vec::with_capacity(b.len());
let mut rest = b;
while !rest.is_empty() {
match std::str::from_utf8(rest) {
Ok(text) => {
result.extend(text.encode_utf16());
break;
}
Err(error) => {
let (valid, tail) = rest.split_at(error.valid_up_to());
// valid_up_to is guaranteed to be a valid UTF-8 prefix.
result.extend(
std::str::from_utf8(valid)
.map_err(|_| InvalidWtf8)?
.encode_utf16(),
);
// Zig's WTF-8 validation accepts both lone and paired surrogate encodings.
if tail.len() < 3
|| tail[0] != 0xed
|| !(0xa0..=0xbf).contains(&tail[1])
|| !(0x80..=0xbf).contains(&tail[2])
{
return Err(InvalidWtf8);
}
result.push(0xd000 | (u16::from(tail[1] & 0x3f) << 6) | u16::from(tail[2] & 0x3f));
rest = &tail[3..];
}
}
}
Ok(result)
}
/// Strict UTF-8 conversion used by Zig's Windows Command (including its environment).
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid utf-8")]
pub struct InvalidUtf8;
pub fn try_utf8_to_wide(bytes: &[u8]) -> Result<Vec<u16>, InvalidUtf8> {
Ok(std::str::from_utf8(bytes)
.map_err(|_| InvalidUtf8)?
.encode_utf16()
.collect())
}
/// Zig's Windows filesystem conversion has a different error and accepts WTF-8.
pub fn windows_path_wide(bytes: &[u8]) -> std::io::Result<Vec<u16>> {
try_wtf8_to_wide(bytes)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "BadPathName"))
}
/// OS string to reversible `String` (see the module docs). Lossless on every
/// platform (Windows goes through WTF-8).
pub fn os_to_config_string(s: &OsStr) -> String {
bytes_to_config_string(&os_bytes(s))
}
/// Encode a string that is already valid UTF-8 (plain text, e.g. a URL). The
/// only thing that needs escaping is a literal escape character, so decoding
/// stays a bijection on every platform.
pub fn valid_str_to_config_string(s: &str) -> String {
bytes_to_config_string(s.as_bytes())
}
/// Decode a config string for a UI-text boundary that requires UTF-8 (window
/// titles, CSS font names, toolkit namespaces): the original bytes, with
/// anything that is not valid UTF-8 replaced by U+FFFD. A literal `U+FDD0`
/// in the text is restored (not shown as the doubled escape).
pub fn config_str_to_text(s: &str) -> String {
String::from_utf8_lossy(&config_str_to_bytes(s)).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))
}
/// An OS string (path, environment value, home directory, ...) carried as a
/// `String` through the reversible escape encoding of this module.
///
/// This is deliberately a distinct type: it cannot be used as text or passed
/// to filesystem/exec APIs without an explicit [`EncodedOs::to_os`] /
/// [`EncodedOs::to_path`] decode, and plain text (URLs, regex matches, ...)
/// never becomes an `EncodedOs` except through [`EncodedOs::from_plain_text`],
/// which escapes instead of decoding.
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct EncodedOs(String);
impl EncodedOs {
/// Empty value.
#[must_use]
pub const fn new() -> Self {
EncodedOs(String::new())
}
/// Encode an OS string.
#[must_use]
pub fn from_os(s: &OsStr) -> Self {
EncodedOs(os_to_config_string(s))
}
/// Encode a path.
#[must_use]
pub fn from_path(p: &std::path::Path) -> Self {
Self::from_os(p.as_os_str())
}
/// `std.fs.path.isAbsolute` on the decoded bytes.
#[must_use]
pub fn is_zig_absolute(&self) -> bool {
zig_is_absolute(&self.to_bytes())
}
/// Encode raw bytes.
#[must_use]
pub fn from_bytes(b: &[u8]) -> Self {
EncodedOs(bytes_to_config_string(b))
}
/// Adopt a string already in the internal escape encoding. Config and CLI
/// input must use `from_bytes`; plain Rust text must use `from_plain_text`.
#[must_use]
pub fn from_encoded(s: impl Into<String>) -> Self {
EncodedOs(s.into())
}
/// Wrap plain text (not previously encoded): a literal escape character
/// is escaped so decoding returns exactly the same text.
#[must_use]
pub fn from_plain_text(s: &str) -> Self {
EncodedOs(valid_str_to_config_string(s))
}
/// Internal encoded text for storage/output formatting. Decode before
/// writing config output or passing it to OS APIs and field parsers.
#[must_use]
pub fn as_encoded(&self) -> &str {
&self.0
}
/// Consume into the encoded string.
#[must_use]
pub fn into_encoded(self) -> String {
self.0
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Whether the decoded value ends with the given plain suffix.
#[must_use]
pub fn ends_with_plain(&self, suffix: &str) -> bool {
self.0.ends_with(suffix)
}
/// Decode to the raw bytes.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
config_str_to_bytes(&self.0)
}
/// Render bytes for a UI or log boundary which requires Unicode text.
pub fn to_text_lossy(&self) -> String {
String::from_utf8_lossy(&self.to_bytes()).into_owned()
}
/// Decode to an OS string.
#[must_use]
pub fn to_os(&self) -> OsString {
config_str_to_os(&self.0)
}
/// Decode to UTF-16 code units (WTF-16: lone surrogates round-trip).
/// This is the Windows API form; it is platform independent so the
/// Windows logic is testable everywhere.
#[must_use]
pub fn to_wide(&self) -> Vec<u16> {
wtf8_to_wide(&self.to_bytes())
}
/// Strict [`EncodedOs::to_wide`] for WTF-8 data: malformed WTF-8 is
/// an error (Zig's `utf8ToUtf16Le*` fail) instead of becoming U+FFFD.
pub fn try_to_wide(&self) -> Result<Vec<u16>, InvalidWtf8> {
try_wtf8_to_wide(&self.to_bytes())
}
/// Windows process inputs use strict UTF-8, unlike filesystem paths.
pub fn try_to_utf16(&self) -> Result<Vec<u16>, InvalidUtf8> {
try_utf8_to_wide(&self.to_bytes())
}
pub fn try_to_utf16_nul(&self) -> Result<Vec<u16>, InvalidUtf8> {
let mut wide = self.try_to_utf16()?;
wide.push(0);
Ok(wide)
}
pub fn try_to_utf8_os(&self) -> Result<OsString, InvalidUtf8> {
let bytes = self.to_bytes();
Ok(OsString::from(
std::str::from_utf8(&bytes).map_err(|_| InvalidUtf8)?,
))
}
/// [`EncodedOs::try_to_wide`] plus a trailing NUL.
pub fn try_to_wide_nul(&self) -> Result<Vec<u16>, InvalidWtf8> {
let mut w = self.try_to_wide()?;
w.push(0);
Ok(w)
}
/// Strict [`EncodedOs::to_os`]: on Windows malformed WTF-8 is an error;
/// elsewhere every byte string is a valid OS string.
pub fn try_to_os(&self) -> Result<OsString, InvalidWtf8> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStringExt;
Ok(OsString::from_wide(&self.try_to_wide()?))
}
#[cfg(not(windows))]
{
Ok(self.to_os())
}
}
/// [`EncodedOs::to_wide`] plus a trailing NUL.
#[must_use]
pub fn to_wide_nul(&self) -> Vec<u16> {
let mut w = self.to_wide();
w.push(0);
w
}
/// Encode UTF-16 code units (possibly with lone surrogates).
#[must_use]
pub fn from_wide(w: &[u16]) -> Self {
Self::from_bytes(&wide_to_wtf8(w))
}
/// Decode to a filesystem path, rejecting malformed Windows WTF-8 as BadPathName.
pub fn to_path(&self) -> std::io::Result<std::path::PathBuf> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStringExt;
Ok(OsString::from_wide(&windows_path_wide(&self.to_bytes())?).into())
}
#[cfg(not(windows))]
{
Ok(self.to_os().into())
}
}
/// [`EncodedOs::to_path`] as a syscall sees it: Zig hands paths to the
/// OS as NUL-terminated C strings, so an interior NUL ends the path
/// (Rust's `std::fs` would refuse the whole path instead).
pub fn to_syscall_path(&self) -> std::io::Result<std::path::PathBuf> {
let bytes = self.to_bytes();
match bytes.iter().position(|&b| b == 0) {
Some(end) => EncodedOs::from_bytes(&bytes[..end]).to_path(),
None => self.to_path(),
}
}
/// Whether the decoded value starts with the given plain prefix.
#[must_use]
pub fn starts_with_plain(&self, prefix: &str) -> bool {
// Valid UTF-8 prefixes (without the escape char) encode to themselves.
self.0.starts_with(prefix)
}
/// Join plain-text components like `std.fs.path.join` (empty components
/// skipped, exactly one separator in between). Separators are ASCII and
/// never part of an escape, so joining the encoded text is exact.
#[cfg(not(target_arch = "wasm32"))]
#[must_use]
pub fn join_plain(&self, parts: &[&str]) -> Self {
let esc: Vec<String> = parts
.iter()
.map(|p| valid_str_to_config_string(p))
.collect();
let mut all: Vec<&str> = Vec::with_capacity(parts.len() + 1);
all.push(&self.0);
all.extend(esc.iter().map(String::as_str));
EncodedOs(crate::path_join(&all))
}
/// Join another encoded path (same rules as [`EncodedOs::join_plain`]).
#[cfg(not(target_arch = "wasm32"))]
#[must_use]
pub fn join(&self, other: &EncodedOs) -> Self {
EncodedOs(crate::path_join(&[&self.0, &other.0]))
}
}
/// Compares the *encoded* form with a literal (for plain ASCII/UTF-8 text
/// without the escape character the two are identical).
impl PartialEq<str> for EncodedOs {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for EncodedOs {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
/// Plain UTF-8 constructors: string literals never carry escape-encoded bytes.
impl From<&str> for EncodedOs {
fn from(s: &str) -> Self {
Self::from_plain_text(s)
}
}
impl From<String> for EncodedOs {
fn from(s: String) -> Self {
Self::from_plain_text(&s)
}
}
impl From<&OsStr> for EncodedOs {
fn from(s: &OsStr) -> Self {
Self::from_os(s)
}
}
impl From<OsString> for EncodedOs {
fn from(s: OsString) -> Self {
Self::from_os(&s)
}
}
impl From<&std::path::Path> for EncodedOs {
fn from(p: &std::path::Path) -> Self {
Self::from_path(p)
}
}
impl From<std::path::PathBuf> for EncodedOs {
fn from(p: std::path::PathBuf) -> Self {
Self::from_path(&p)
}
}
/// 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::io::Result<std::path::PathBuf> {
EncodedOs::from_encoded(s).to_path()
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn syscall_path_ends_at_the_first_nul() {
let e = EncodedOs::from_bytes(b"/a/b\0c");
assert_eq!(
e.to_syscall_path().unwrap(),
std::path::PathBuf::from("/a/b")
);
let e = EncodedOs::from_bytes(b"/a/\xffb");
assert_eq!(e.to_syscall_path().unwrap(), e.to_path().unwrap());
assert_eq!(
EncodedOs::from_bytes(b"\0x").to_syscall_path().unwrap(),
std::path::PathBuf::new()
);
}
#[test]
fn windows_paths_fail_with_bad_path_name_instead_of_replacement() {
let error = windows_path_wide(b"C:\\bad\xff").unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(error.to_string(), "BadPathName");
assert_eq!(
windows_path_wide("C:\\bad\u{fffd}".as_bytes())
.unwrap()
.last(),
Some(&0xfffd)
);
assert_eq!(windows_path_wide(b"\xed\xa0\x80").unwrap(), [0xd800]);
}
#[test]
fn wtf8_accepts_separately_encoded_surrogate_pairs_like_zig() {
assert_eq!(
try_wtf8_to_wide(b"\xed\xa0\xbd\xed\xb8\x80"),
Ok(vec![0xd83d, 0xde00])
);
assert_eq!(
windows_path_wide(b"x\xed\xa0\xbd\xed\xb8\x80y").unwrap(),
[0x78, 0xd83d, 0xde00, 0x79]
);
assert_eq!(try_wtf8_to_wide(b"\xed\xa0\xbd\xff"), Err(InvalidWtf8));
}
#[test]
fn windows_command_inputs_require_strict_utf8() {
for bytes in [
b"\xff".as_slice(),
b"\xed\xa0\x80",
b"\xed\xa0\xbd\xed\xb8\x80",
] {
let value = EncodedOs::from_bytes(bytes);
assert_eq!(value.try_to_utf16(), Err(InvalidUtf8));
assert_eq!(value.try_to_utf16_nul(), Err(InvalidUtf8));
assert_eq!(value.try_to_utf8_os(), Err(InvalidUtf8));
}
assert_eq!(
try_utf8_to_wide("😀\u{fdd0}".as_bytes()).unwrap(),
[0xd83d, 0xde00, 0xfdd0]
);
}
#[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 valid_str_encoder_escapes_literal_escape_char() {
// Shared logic of the non-Unix encoder: U+FDD0 U+00FF must not be
// mistaken for the escape of byte 0xFF.
let s = "a\u{fdd0}\u{ff}";
let e = valid_str_to_config_string(s);
assert_eq!(e, "a\u{fdd0}\u{fdd0}\u{ff}");
assert_eq!(config_str_to_bytes(&e), s.as_bytes());
assert_eq!(valid_str_to_config_string("plain"), "plain");
}
#[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);
}
}
}
#[test]
fn wtf8_round_trips_unpaired_surrogates() {
// "a", lone high, "b", valid pair (U+1F600), lone low.
let wide = [0x61u16, 0xD800, 0x62, 0xD83D, 0xDE00, 0xDC00];
let b = wide_to_wtf8(&wide);
assert_eq!(&b[..1], b"a");
assert_eq!(&b[1..4], &[0xED, 0xA0, 0x80]);
assert_eq!(wtf8_to_wide(&b), wide);
// Through the reversible string encoding too.
let s = bytes_to_config_string(&b);
assert_eq!(wtf8_to_wide(&config_str_to_bytes(&s)), wide);
}
#[test]
fn config_str_to_text_restores_literal_escape_char() {
assert_eq!(
config_str_to_text(&valid_str_to_config_string("a\u{fdd0}b")),
"a\u{fdd0}b"
);
assert_eq!(
config_str_to_text(&bytes_to_config_string(b"a\xffb")),
"a\u{fffd}b"
);
}
#[test]
fn strict_wtf8_rejects_malformed_input() {
assert_eq!(try_wtf8_to_wide(b"a\xffb"), Err(InvalidWtf8));
assert_eq!(try_wtf8_to_wide(b"\xe0\x80\x80"), Err(InvalidWtf8));
assert_eq!(try_wtf8_to_wide(b"\xc3"), Err(InvalidWtf8));
// A real U+FFFD and lone surrogates are well-formed.
assert_eq!(try_wtf8_to_wide("\u{FFFD}".as_bytes()), Ok(vec![0xFFFD]));
assert_eq!(try_wtf8_to_wide(b"\xed\xa0\x80"), Ok(vec![0xD800]));
assert_eq!(
try_wtf8_to_wide("\u{e9}\u{1F600}".as_bytes()),
Ok(vec![0xE9, 0xD83D, 0xDE00])
);
let bad = EncodedOs::from_bytes(b"C:\\\xff");
assert_eq!(bad.try_to_wide(), Err(InvalidWtf8));
assert_eq!(bad.try_to_wide_nul(), Err(InvalidWtf8));
// The lossy form still substitutes (non-launch uses).
assert_eq!(bad.to_wide().last(), Some(&0xFFFD));
let ok = EncodedOs::from_plain_text("C:\\x");
assert_eq!(ok.try_to_wide_nul().unwrap().last(), Some(&0));
}
#[test]
fn wtf8_invalid_bytes_become_replacement() {
assert_eq!(wtf8_to_wide(b"a\xffb"), vec![0x61, 0xFFFD, 0x62]);
assert_eq!(wtf8_to_wide(b"\xe0\x80\x80"), vec![0xFFFD; 3]);
assert_eq!(
wtf8_to_wide("\u{e9}\u{1F600}".as_bytes()),
vec![0xE9, 0xD83D, 0xDE00]
);
}
#[test]
fn encoded_os_plain_text_is_not_decoded() {
let plain = "http://x/\u{fdd0}\u{ff}";
let e = EncodedOs::from_plain_text(plain);
assert_eq!(e.to_bytes(), plain.as_bytes());
// Adopting the same text as "already encoded" would decode to 0xFF.
assert_ne!(EncodedOs::from_encoded(plain).to_bytes(), plain.as_bytes());
}
#[test]
fn encoded_os_wide_round_trips_lone_surrogates() {
let wide = [0x43u16, 0x3A, 0x5C, 0xD800, 0x61, 0xD83D, 0xDE00, 0xDFFF];
let e = EncodedOs::from_wide(&wide);
assert_eq!(e.to_wide(), wide);
assert_eq!(*e.to_wide_nul().last().unwrap(), 0);
// A literal escape char in a Windows name is still preserved.
let w2: Vec<u16> = "a\u{fdd0}\u{ff}".encode_utf16().collect();
assert_eq!(EncodedOs::from_wide(&w2).to_wide(), w2);
}
#[cfg(unix)]
#[test]
fn encoded_os_unix_round_trip() {
let o = os_from_bytes(vec![b'/', 0xff, b'x']);
let e = EncodedOs::from_os(&o);
assert_eq!(e.to_os(), o);
assert_eq!(e.to_path().unwrap(), std::path::PathBuf::from(&o));
assert_eq!(e.join_plain(&["a", "", "b"]).to_bytes(), b"/\xffx/a/b");
}
#[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);
}
}
#[cfg(test)]
mod zig_absolute_tests {
use super::*;
#[test]
fn windows_is_absolute_matches_zig_0_15_2() {
for (p, want) in [
("", false),
("/", true),
("//server/file", true),
("\\\\server", true),
("\\theme", true),
("/theme", true),
("c", false),
("c:", false),
("c:\\", true),
("c:/", true),
("1:/x", true),
("C:cwd/another", false),
("directory\\directory", false),
] {
assert_eq!(zig_is_absolute_windows(p.as_bytes()), want, "{p}");
}
}
#[test]
fn posix_is_absolute_matches_zig_0_15_2() {
assert!(!zig_is_absolute_posix(b""));
assert!(zig_is_absolute_posix(b"/home/foo"));
assert!(!zig_is_absolute_posix(b"bar/"));
assert!(!zig_is_absolute_posix(b"\\theme"));
}
}