Files
benilla-octo/crates/benilla-app/src/local_state.rs
T
gromandClaude Opus 5 e716709bca app+ui: the nine file and credential verbs, fenced and keyed
Built on grom's explicit say-so, after a round where they were withheld
and the reason recorded. Both halves of that decision stay in the doc,
because the reasoning is what makes the second half a decision rather
than a drift.

ReadCustomFile/ImportFile (one operation, two upstream names),
WriteCustomFile/ExportFile, CustomFileExists, ExecuteCustomLuaFile,
StartItemExport, EncryptPassword, EncryptedServerLogin.

**The fence is app-side and it is not a `..` scan.**
`local_state::custom_file_path` admits one relative name whose every
component is an ordinary name, inside `benilla-config/CustomFiles/`, and
refuses a leaf that is already a symlink. Each escape a string check
misses has its own rule and its own test row: an absolute path (`join`
*replaces* the base rather than appending), a Windows prefix, a `..`
nested inside a longer path, and a planted symlink — the one no amount
of sanitising the string will catch. A subdirectory rather than the
config root, because a verb that could create `config.toml` or
`autologin.toml` by name would let an addon rewrite the client's own
settings; that is a different power from "keep a file of your own".

The VM side holds three closures and no paths, so no spelling of a
`filename` reaches outside that directory.

**The credential pair's deviation is forced, not chosen.** Both are
registered by nampower under "Registering *Glue* Lua functions" and this
client has no glue Lua VM at all — login, character-select and
character-create are Rust. `EncryptPassword` is pure and works the same
anywhere. `EncryptedServerLogin` cannot mean "submit the login form" in
the only VM there is, so it stores the credential for the next login:
`enabled` on, the plaintext field cleared, the blob kept, and
`AutoLogin::effective_password` reads it back at the refill.

What that protects is an `autologin.toml` or a SavedVariables file read
by someone else or pasted into a forum: the blob is authenticated and
keyed on 32 random bytes in a different file, mode 0600. What it does
not protect is anything running as this user, which can read the key as
easily as the blob — there is no user secret in the chain and there
cannot be, because the point is logging in without typing one. Both
halves are at the module.

Encrypt-then-MAC with HMAC-SHA1 as the PRF for both halves, from the two
primitives the tree already had (sha1 moves from a dev-dependency to a
real one). The HMAC is tested against RFC 2202's own vectors, because a
hand-written one still round-trips with itself when it is wrong — and a
first draft of the key reader did exactly that kind of silent wrong: it
returned raw bytes on creation and hex text on read, and the caller
guessed between them with "32 bytes and not all hex digits". It guessed
wrong the moment a random key came out all-hex. Returning one thing is
the fix, and the note is at the function.

**The existing plaintext path is untouched.** Storing the password in
plaintext was grom's own explicit choice when the alternatives were a
keyring or nothing, so this is additive and nothing migrates by itself.
The machinery to make the Remember Password tick-box write a blob
instead is now all here — one line — and that is his call.

Base64 was missing from the whole tree; it is in `benilla-ui`'s
`script/encoding.rs` with RFC 4648's own vectors, and
`C_EncodingUtil.EncodeBase64` will want the same one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-24 11:30:49 -06:00

808 lines
34 KiB
Rust

//! Where benilla keeps local state: one folder, `benilla-config/`, never inside the install, and
//! this module is the only place that computes a path into it; each path fn's doc says what it
//! holds. The 1.12 client is portable the same way, its `WTF/` and `Cache/` in its own folder.
//!
//! Resolution, in order, the same shape as [`benilla_formats::wow_data`]:
//! 1. `$BENILLA_HOME`.
//! 2. `<project folder>/benilla-config/`, dev builds only: a shipped binary must not carry the
//! build machine's source tree.
//! 3. `<exe dir>/benilla-config/`.
//!
//! With `$WOW_CAPTURE` set every path resolves to `None`: a capture neither reads nor writes
//! player state.
use std::io::Write;
use std::path::{Path, PathBuf};
/// The folder's name: not `benilla`, which beside the binary is the executable itself, and not
/// `WTF`, which a real install already uses.
const STATE_DIR: &str = "benilla-config";
/// The state folder, or `None` when persistence is off (a capture run, or no executable path).
/// It may not exist yet: [`write_atomic`] creates it, and a reader treats a missing file as
/// defaults.
pub(crate) fn home() -> Option<PathBuf> {
if std::env::var_os("WOW_CAPTURE").is_some() {
return None; // hermetic: captures neither read nor write player state
}
if let Some(over) = std::env::var_os("BENILLA_HOME") {
return Some(PathBuf::from(over));
}
// 2 · the project folder, dev builds only.
if let Some(root) = dev_project_root() {
return Some(root.join(STATE_DIR));
}
// 3 · beside the binary; a dev build never reaches here.
std::env::current_exe()
.ok()
.and_then(|e| e.parent().map(Path::to_path_buf))
.map(|dir| dir.join(STATE_DIR))
}
/// The project folder a dev build keeps its state in: the primary checkout, whichever worktree
/// built the binary, so every worktree shares one settings folder. Anything unexpected falls back
/// to the crate root's grandparent rather than `None`.
fn dev_project_root() -> Option<PathBuf> {
let here = crate::run_mode::dev_source_dir()?.ancestors().nth(2)?;
let dot_git = here.join(".git");
// A linked worktree: `.git` is a file pointing at `<primary>/.git/worktrees/<name>`.
if dot_git.is_file() {
if let Some(gitdir) = std::fs::read_to_string(&dot_git).ok().and_then(|s| {
s.trim()
.strip_prefix("gitdir:")
.map(|p| p.trim().to_owned())
}) {
// …/.git/worktrees/<name> → …/.git → the primary checkout.
if let Some(primary) = Path::new(&gitdir)
.ancestors()
.nth(2)
.and_then(|d| d.parent())
{
if primary.is_dir() {
return Some(primary.to_path_buf());
}
}
}
}
Some(here.to_path_buf())
}
/// `benilla-config/config.toml`: the CVar overrides, the `Config.wtf` analog.
pub(crate) fn config_path() -> Option<PathBuf> {
home().map(|h| h.join("config.toml"))
}
/// `benilla-config/macros/account.txt`: the account-wide macro tab (indices 1..=18).
pub(crate) fn macros_account_path() -> Option<PathBuf> {
home().map(|h| h.join("macros/account.txt"))
}
/// `benilla-config/macros/<realm>-<character>.txt`: the per-character macro tab (19..=36), the
/// reference's `WTF/Account/<ACC>/<REALM>/<CHAR>/macros-cache.txt` flattened into one folder.
pub(crate) fn macros_character_path(realm: &str, character: &str) -> Option<PathBuf> {
let key = format!("{}-{}", file_token(realm), file_token(character));
home().map(|h| h.join("macros").join(format!("{key}.txt")))
}
/// `benilla-config/bindings/account.txt`: the account-wide key bindings (the `bindings-cache.wtf`
/// analog), a diff against the defaults.
pub(crate) fn bindings_account_path() -> Option<PathBuf> {
home().map(|h| h.join("bindings/account.txt"))
}
/// `benilla-config/bindings/<realm>-<character>.txt`: the character-specific binding set. Its
/// existence is the "character specific key bindings" state; going back to general deletes it.
pub(crate) fn bindings_character_path(realm: &str, character: &str) -> Option<PathBuf> {
let key = format!("{}-{}", file_token(realm), file_token(character));
home().map(|h| h.join("bindings").join(format!("{key}.txt")))
}
/// `benilla-config/saved-variables.lua`: the flat channel our FrameXML saves through
/// `RegisterForSave` (the `SavedVariables.lua` analog), written whole at logout, run at UI load.
pub(crate) fn saved_variables_path() -> Option<PathBuf> {
home().map(|h| h.join("saved-variables.lua"))
}
/// `benilla-config/addons/<Realm>-<Character>.txt`: the AddOn enable state, per character in the
/// reference's `AddOns.txt` format (`<AddOnName>: enabled|disabled`), which it writes at the tail
/// of its UI shutdown (`0x490bd0`). An addon absent from the file is enabled.
pub(crate) fn addons_state_path(realm: &str, character: &str) -> Option<PathBuf> {
let key = format!("{}-{}", file_token(realm), file_token(character));
home().map(|h| h.join("addons").join(format!("{key}.txt")))
}
/// `benilla-config/saved/`: per-addon saved variables, account scope, one `<Addon>.lua` per addon
/// declaring `## SavedVariables` (the reference's `WTF/Account/<ACC>/SavedVariables/<Addon>.lua`).
pub(crate) fn addon_saved_account_dir() -> Option<PathBuf> {
home().map(|h| h.join("saved"))
}
/// `benilla-config/saved/<Realm>-<Character>/`: per-addon saved variables, character scope
/// (`## SavedVariablesPerCharacter`), loaded after the account file so it wins.
pub(crate) fn addon_saved_character_dir(realm: &str, character: &str) -> Option<PathBuf> {
let key = format!("{}-{}", file_token(realm), file_token(character));
home().map(|h| h.join("saved").join(key))
}
/// `benilla-config/camera/<realm>-<character>.txt`: the third-person camera pose (the
/// `<Char>/camera-settings.txt` analog), two lines in the reference's keys and order.
pub(crate) fn camera_character_path(realm: &str, character: &str) -> Option<PathBuf> {
let key = format!("{}-{}", file_token(realm), file_token(character));
home().map(|h| h.join("camera").join(format!("{key}.txt")))
}
/// `benilla-config/account`: the account name the login screen remembers
/// (`GetSavedAccountName`/`SetSavedAccountName`; the reference's is `Config.wtf`'s `accountName`).
pub(crate) fn saved_account_path() -> Option<PathBuf> {
home().map(|h| h.join("account"))
}
/// `benilla-config/autologin.toml`: the auto-login account and what the character screen
/// remembers per realm (last character, custom row order, the reorder lock).
///
/// Its own file rather than a key in `config.toml`, because **it holds the account password in
/// plaintext** and is written with owner-only permissions ([`write_atomic_private`]) — mixing it
/// into the settings file would quietly make that file secret too, and `config.toml` is the one
/// a player is most likely to paste into a bug report.
pub(crate) fn autologin_path() -> Option<PathBuf> {
home().map(|h| h.join("autologin.toml"))
}
/// `benilla-config/chat/<realm>-<character>.txt`: the chat windows' tint, alpha, font size and
/// lock, the four the reference keeps in its per-character `chat-cache.txt`.
pub(crate) fn chat_character_path(realm: &str, character: &str) -> Option<PathBuf> {
Some(home()?.join("chat").join(format!(
"{}-{}.txt",
file_token(realm),
file_token(character)
)))
}
/// `benilla-config/layout/<realm>-<character>.txt`: the layout cache, the geometry of every
/// user-placed frame, as the reference's per-character `layout-cache.txt`; [`crate::ui_layout`]
/// owns the shape.
pub(crate) fn layout_character_path(realm: &str, character: &str) -> Option<PathBuf> {
Some(home()?.join("layout").join(format!(
"{}-{}.txt",
file_token(realm),
file_token(character)
)))
}
/// `benilla-config/cache/<realm>.tsv`: the player, creature and pet names the server has answered,
/// one file for the reference's `WDB/namecache.wdb`, `creaturecache.wdb` and `petnamecache.wdb`.
///
/// Realm-scoped, where the reference's is not: guids, creature entries and pet numbers are
/// realm-local, so one shared file would serve a realm another realm's names.
pub(crate) fn name_cache_path(realm: &str) -> Option<PathBuf> {
Some(
home()?
.join("cache")
.join(format!("{}.tsv", file_token(realm))),
)
}
/// `benilla-config/cache/<realm>-items.jsonl`: the item templates the server has answered, the
/// reference's `WDB/itemcache.wdb`.
///
/// Realm-scoped for the same reason as [`name_cache_path`]: another server may hold a different
/// template under the same entry id, and a custom realm very often does.
///
/// One JSON record per line, appended, because the whole-map rewrite this replaced cost 50 ms of
/// main thread per save at ten thousand templates — a visible stutter every ten seconds in any
/// window that asks about items in bulk ([`crate::item_persist`]).
pub(crate) fn item_cache_path(realm: &str) -> Option<PathBuf> {
Some(
home()?
.join("cache")
.join(format!("{}-items.jsonl", file_token(realm))),
)
}
/// The whole-map `<realm>-items.json` written by builds before the append-only format. Read once
/// at login and never written again, so an upgrade keeps the templates it already paid for.
pub(crate) fn legacy_item_cache_path(realm: &str) -> Option<PathBuf> {
Some(
home()?
.join("cache")
.join(format!("{}-items.json", file_token(realm))),
)
}
/// `benilla-config/cache/lua/`: compiled Lua chunks ([`crate::ui_script::chunk_cache`]).
/// Disposable — deleting it costs one slow UI load.
pub(crate) fn lua_cache_dir() -> Option<PathBuf> {
home().map(|h| h.join("cache").join("lua"))
}
/// `benilla-config/CustomFiles/`: the one directory nampower's file family may read, write and
/// execute in ([`custom_file_path`] is the only way to name something inside it).
///
/// **Why a directory of its own, and why this one.** nampower writes relative to the client's
/// working directory — the install root — which is read-only here, so `benilla-config` is the
/// analogue (the same reasoning `logs_dir` carries). A *subdirectory* rather than the config root
/// because these files are written by addon Lua: a verb that could create `config.toml` or
/// `autologin.toml` by name would let an addon rewrite the client's own settings, which is a
/// different power from "keep a file of your own".
pub(crate) fn custom_files_dir() -> Option<PathBuf> {
home().map(|h| h.join("CustomFiles"))
}
/// One name inside [`custom_files_dir`], or `None` for a name that would escape it.
///
/// The fence is **not** "reject `..`" — that is the check that keeps being almost right. It is:
/// the name must be a single relative path whose every component is an ordinary name, and the
/// resolved parent must still be the custom-files directory. Rejected, each for its own reason a
/// `..` scan would miss:
///
/// - **Absolute paths** (`/etc/passwd`, and on Windows `C:\…` or `\\server\share`), which join
/// *replaces* the base with rather than appending to.
/// - **Any `..` or `.` component**, including inside a nested path (`a/../../b`).
/// - **A root or prefix component anywhere**, which is the Windows `C:` case again.
/// - **An empty name**, which would resolve to the directory itself.
/// - **A name already holding a symlink**, checked with `symlink_metadata` on the final component:
/// a link planted in that directory is the one way a legal-looking name reaches outside it, and
/// it is the hole every "sanitise the string" fence leaves open. Only the final component is
/// checked, because every earlier one has to be a real directory this client created.
///
/// Nested names are allowed (`MyAddon/profile.txt`): an addon keeping several files wants a folder,
/// and the component rules already make a nested name no more dangerous than a flat one.
pub(crate) fn custom_file_path(name: &str) -> Option<PathBuf> {
use std::path::Component;
if name.is_empty() {
return None;
}
let rel = Path::new(name);
for c in rel.components() {
match c {
Component::Normal(_) => {}
// RootDir, Prefix, CurDir and ParentDir are all escapes or no-ops; none is a name.
_ => return None,
}
}
let dir = custom_files_dir()?;
let full = dir.join(rel);
// A symlink at the leaf is the one escape the component rules cannot see. `symlink_metadata`
// does not follow it, so this is a test of the entry itself and not of its target.
if full
.symlink_metadata()
.is_ok_and(|m| m.file_type().is_symlink())
{
return None;
}
Some(full)
}
/// `benilla-config/Logs/`: `WoWChatLog.txt` and `WoWCombatLog.txt`, the reference's names.
pub(crate) fn logs_dir() -> Option<PathBuf> {
home().map(|h| h.join("Logs"))
}
/// `benilla-config/Logs/benilla.log`: this session's own log, the lines that also went to stderr
/// ([`benilla_world::log_ring`]). The previous session's file is kept beside it as
/// `benilla-1.log`, and the one before that as `benilla-2.log`, so a player who notices something
/// odd and quits still has the run it happened in.
pub(crate) fn session_log_path() -> Option<PathBuf> {
logs_dir().map(|d| d.join("benilla.log"))
}
/// Shift the kept session logs down one and return where this session should write. `benilla-2`
/// is dropped, `benilla-1` becomes `benilla-2`, `benilla.log` becomes `benilla-1`. Rename
/// failures are ignored: a missing older file is the normal case, and a log that cannot rotate
/// is still worth writing.
pub(crate) fn rotate_session_logs() -> Option<PathBuf> {
let path = session_log_path()?;
let dir = path.parent()?;
let nth = |n: u32| dir.join(format!("benilla-{n}.log"));
let _ = std::fs::remove_file(nth(2));
let _ = std::fs::rename(nth(1), nth(2));
let _ = std::fs::rename(&path, nth(1));
Some(path)
}
/// `benilla-config/shots.txt`: the framing instrument's camera poses (`/shot`, dev builds).
pub(crate) fn shots_path() -> Option<PathBuf> {
home().map(|h| h.join("shots.txt"))
}
/// `benilla-config/Screenshots/`: where the print-screen key writes, each image once, not through
/// [`write_atomic`].
///
/// Deviation: the reference writes `Screenshots\\` inside the install, which benilla never writes
/// to; the folder keeps the reference's name.
pub(crate) fn screenshots_dir() -> Option<PathBuf> {
home().map(|h| h.join("Screenshots"))
}
/// `benilla-config/Diagnostics/`: the stuck-thread sampler's profiles ([`crate::perf::stall`]) and
/// the FPS journal; `None` on a capture run like every path here, so a capture has no sampler.
pub(crate) fn diagnostics_dir() -> Option<PathBuf> {
home().map(|h| h.join("Diagnostics"))
}
/// `benilla-config/Diagnostics/fps-journal.csv`: the FPS journal's rows while the `fpsJournal`
/// CVar is on; a capture names its own path through `WOW_FPS_JOURNAL`.
pub(crate) fn fps_journal_path() -> Option<PathBuf> {
diagnostics_dir().map(|d| d.join("fps-journal.csv"))
}
/// A realm or character name as one path component: anything not a letter or digit becomes `_`.
/// Letters of any script stay, as in the reference's raw-name folders: vmangos accepts non-Latin
/// names (`StrictPlayerNames = 0`) and a character name is letters only, so no two share a file.
fn file_token(s: &str) -> String {
let t: String = s
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
if t.is_empty() {
"unknown".into()
} else {
t
}
}
/// Write a state file atomically, through `<path>.tmp` and a rename, so a crash leaves the old
/// file intact.
pub(crate) fn write_atomic(path: &Path, contents: &str) -> std::io::Result<()> {
write_atomic_bytes(path, contents.as_bytes())
}
/// [`write_atomic`] for bytes: the saved-variables files carry Lua byte strings as they are.
pub(crate) fn write_atomic_bytes(path: &Path, contents: &[u8]) -> std::io::Result<()> {
write_one(path, contents, Durability::Flushed)
}
/// [`write_atomic_bytes`] without the flush to the platter, for the one write that happens in
/// bulk: the rename still publishes the whole file or none of it, and what is given up is only a
/// power loss or kernel crash in the seconds after, not a crash of ours.
///
/// It is here because the flush dominated a `/reload`. Each `fsync` costs a disk round trip, and
/// the saved-variables write is one file per addon: **60 files, 327 ms flushed against 6 ms
/// unflushed** on this project's own btrfs home, which is 305 ms of the single blocked frame the
/// rebuild already is (the player's 2026-10-18 log: `ReloadUI — ending the UI session` at +0 ms,
/// the last `wrote …/saved/*.lua` at +332 ms). The reference writes these files with plain
/// buffered CRT writes and a `.bak` rotation it ignores the result of, so this is still the
/// stronger guarantee of the two.
pub(crate) fn write_atomic_bytes_bulk(path: &Path, contents: &[u8]) -> std::io::Result<()> {
write_one(path, contents, Durability::Renamed)
}
/// Whether [`write_one`] waits for the bytes to reach the disk before the rename.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Durability {
/// `fsync` before the rename: a power loss after it cannot lose the write.
Flushed,
/// Rename only: atomic against a crash of ours, not against a power loss.
Renamed,
}
/// The shared body of the atomic writes: temp file beside the target, then a rename over it.
fn write_one(path: &Path, contents: &[u8], durability: Durability) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
{
let mut f = std::fs::File::create(&tmp)?;
f.write_all(contents)?;
if durability == Durability::Flushed {
f.sync_all()?;
}
}
std::fs::rename(&tmp, path)
}
/// [`write_atomic`] for a file only the owner may read: the auto-login store holds a password in
/// plaintext, so it is created `0600` and the mode is reasserted on every write (a file that
/// already existed world-readable is tightened, not left as found).
///
/// The temp file is created with the restricted mode *before* any bytes reach it, so the secret
/// is never briefly readable under the default umask. Permissions are a Unix concept; elsewhere
/// this is a plain [`write_atomic`] and the doc comment on [`autologin_path`] is the only
/// protection, which is why that path's own warning says plaintext rather than protected.
pub(crate) fn write_atomic_private(path: &Path, contents: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
{
#[cfg(unix)]
let mut f = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)?
};
#[cfg(not(unix))]
let mut f = std::fs::File::create(&tmp)?;
f.write_all(contents.as_bytes())?;
f.sync_all()?;
}
// `.mode()` only applies to a file this call created; an existing tmp keeps its own.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
std::fs::rename(&tmp, path)
}
/// Test plumbing for the persistence env vars: `set_var` is process-global, so every such test
/// takes [`ENV_LOCK`] and scopes its overrides in [`EnvGuard`]s.
#[cfg(test)]
pub(crate) mod test_env {
pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub(crate) struct EnvGuard(&'static str, Option<std::ffi::OsString>);
impl EnvGuard {
pub(crate) fn set(key: &'static str, value: &str) -> Self {
let old = std::env::var_os(key);
std::env::set_var(key, value);
Self(key, old)
}
pub(crate) fn unset(key: &'static str) -> Self {
let old = std::env::var_os(key);
std::env::remove_var(key);
Self(key, old)
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.1 {
Some(v) => std::env::set_var(self.0, v),
None => std::env::remove_var(self.0),
}
}
}
}
#[cfg(test)]
mod tests {
use super::test_env::{EnvGuard, ENV_LOCK};
use super::*;
/// Every per-character file keys on this token; ASCII names map unchanged.
#[test]
fn distinct_names_never_share_a_token() {
assert_ne!(file_token("Вася"), file_token("Петя"));
assert_ne!(file_token("Zoë"), file_token("Zoé"));
assert_eq!(file_token("Zoë"), "Zoë");
assert_eq!(file_token("Onehunter"), "Onehunter");
assert_eq!(file_token("Hydraxian Waterlords"), "Hydraxian_Waterlords");
assert_eq!(
file_token("a/b\\c:d"),
"a_b_c_d",
"separators never survive"
);
assert_eq!(
file_token("realm-name"),
"realm_name",
"the key's own `-` stays unforgeable"
);
}
/// The layout under the override, the one step of [`home`] whose answer a test can state.
#[test]
fn the_home_law_override_then_the_residents_and_hermetic_captures() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = std::env::temp_dir().join(format!("benilla-ls-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
// 1 · the explicit override.
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::set("BENILLA_HOME", tmp.join(STATE_DIR).to_str().unwrap());
assert_eq!(home(), Some(tmp.join(STATE_DIR)));
assert_eq!(config_path(), Some(tmp.join("benilla-config/config.toml")));
assert_eq!(
macros_account_path(),
Some(tmp.join("benilla-config/macros/account.txt"))
);
assert_eq!(
saved_variables_path(),
Some(tmp.join("benilla-config/saved-variables.lua"))
);
assert_eq!(
macros_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla-config/macros/Hydraxian_Waterlords-Probeone.txt"))
);
assert_eq!(
macros_character_path("../evil", "a/b"),
Some(tmp.join("benilla-config/macros/___evil-a_b.txt")),
"no name can escape the folder"
);
assert_eq!(
camera_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla-config/camera/Hydraxian_Waterlords-Probeone.txt"))
);
assert_eq!(
chat_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla-config/chat/Hydraxian_Waterlords-Probeone.txt"))
);
assert_eq!(
layout_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla-config/layout/Hydraxian_Waterlords-Probeone.txt"))
);
assert_eq!(
saved_account_path(),
Some(tmp.join("benilla-config/account"))
);
assert_eq!(shots_path(), Some(tmp.join("benilla-config/shots.txt")));
assert_eq!(
screenshots_dir(),
Some(tmp.join("benilla-config/Screenshots"))
);
// 0 · a capture run resolves nothing, even with an override set.
let _c2 = EnvGuard::set("WOW_CAPTURE", "ui-options");
assert_eq!(home(), None);
assert_eq!(
saved_account_path(),
None,
"a capture reads no saved account"
);
assert_eq!(shots_path(), None);
assert_eq!(
screenshots_dir(),
None,
"a capture writes no player screenshots"
);
assert_eq!(
chat_character_path("Hydraxian Waterlords", "Probeone"),
None,
"a capture reads no player's chat look"
);
assert_eq!(
layout_character_path("Hydraxian Waterlords", "Probeone"),
None,
"a capture reads no player's window layout"
);
std::fs::remove_dir_all(&tmp).ok();
}
/// A missing or wrong `$WOW_DATA` does not move or lose the state folder.
#[test]
fn the_state_folder_no_longer_depends_on_finding_the_install() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::unset("BENILLA_HOME");
let _d = EnvGuard::set("WOW_DATA", "/nonexistent/benilla-test/Data");
let h = home().expect("a broken install path must not cost the player their config");
assert!(
!h.starts_with("/nonexistent"),
"home() still reads $WOW_DATA: {}",
h.display()
);
assert!(h.ends_with(STATE_DIR), "{}", h.display());
}
/// A player build resolves beside the binary; a dev build to the primary checkout, even from a
/// linked worktree (whose `.git` is a file).
#[test]
fn the_state_folder_lands_where_the_build_says() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::unset("BENILLA_HOME");
let h = home().expect("home() always resolves outside a capture");
assert!(h.ends_with(STATE_DIR), "{}", h.display());
let Some(here) = crate::run_mode::dev_source_dir().and_then(|d| d.ancestors().nth(2))
else {
// Player build: beside the binary.
let exe_dir = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
assert_eq!(h, exe_dir.join(STATE_DIR));
return;
};
if here.join(".git").is_file() {
assert_ne!(
h,
here.join(STATE_DIR),
"a linked worktree must not get its own settings folder — the worktrees share one"
);
assert!(
h.parent().unwrap().join(".git").is_dir(),
"resolved to {}, which is not a primary checkout",
h.display()
);
} else {
assert_eq!(h, here.join(STATE_DIR));
}
}
#[test]
fn write_atomic_creates_dirs_and_replaces_whole_files() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = std::env::temp_dir().join(format!("benilla-wa-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let path = tmp.join("nested/config.toml");
write_atomic(&path, "a = \"1\"\n").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "a = \"1\"\n");
write_atomic(&path, "a = \"2\"\n").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "a = \"2\"\n");
assert!(!path.with_extension("tmp").exists(), "tmp renamed away");
std::fs::remove_dir_all(&tmp).ok();
}
/// Each session's log steps down one slot, and the oldest falls off the end.
#[test]
fn the_session_log_keeps_the_two_runs_before_it() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = std::env::temp_dir().join(format!("benilla-logrot-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let _home = test_env::EnvGuard::set("BENILLA_HOME", tmp.to_str().expect("utf-8"));
let _cap = test_env::EnvGuard::unset("WOW_CAPTURE");
let logs = logs_dir().expect("a state folder");
std::fs::create_dir_all(&logs).expect("the Logs folder");
for run in ["first", "second", "third"] {
let path = rotate_session_logs().expect("a log path");
std::fs::write(&path, run).expect("write the session's log");
}
assert_eq!(
std::fs::read_to_string(logs.join("benilla.log")).unwrap(),
"third"
);
assert_eq!(
std::fs::read_to_string(logs.join("benilla-1.log")).unwrap(),
"second"
);
assert_eq!(
std::fs::read_to_string(logs.join("benilla-2.log")).unwrap(),
"first"
);
// A fourth run drops the oldest.
let path = rotate_session_logs().expect("a log path");
std::fs::write(&path, "fourth").expect("write");
assert_eq!(
std::fs::read_to_string(logs.join("benilla-2.log")).unwrap(),
"second"
);
std::fs::remove_dir_all(&tmp).ok();
}
/// The bulk write gives up the flush, not the atomicity: the same bytes land, the temp file
/// is renamed away, and a replacement is still whole or absent.
#[test]
fn the_bulk_write_is_the_same_write_without_the_flush() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = std::env::temp_dir().join(format!("benilla-wab-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let (bulk, flushed) = (tmp.join("saved/A.lua"), tmp.join("saved/B.lua"));
// A Lua byte string as the saved files carry it, embedded NUL and all.
let body: &[u8] = b"A = \"x\\0y\"\n";
write_atomic_bytes_bulk(&bulk, body).unwrap();
write_atomic_bytes(&flushed, body).unwrap();
assert_eq!(std::fs::read(&bulk).unwrap(), body);
assert_eq!(
std::fs::read(&bulk).unwrap(),
std::fs::read(&flushed).unwrap(),
"the flush changes durability, not content"
);
assert!(!bulk.with_extension("tmp").exists(), "tmp renamed away");
write_atomic_bytes_bulk(&bulk, b"A = nil\n").unwrap();
assert_eq!(std::fs::read(&bulk).unwrap(), b"A = nil\n");
std::fs::remove_dir_all(&tmp).ok();
}
/// The auto-login store holds a password in plaintext, so the claim that it is owner-only
/// is worth asserting rather than trusting the `.mode()` call to have meant it.
#[cfg(unix)]
#[test]
fn a_private_write_is_owner_only_even_over_a_world_readable_file() {
use std::os::unix::fs::PermissionsExt;
let tmp = std::env::temp_dir().join(format!("benilla-priv-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let path = tmp.join("secret.toml");
write_atomic_private(&path, "password = \"hunter2\"\n").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"a fresh secret is owner-only, whatever the umask"
);
// A file that already existed world-readable — say, written by an older build — is
// tightened by the next write, not left as found.
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
write_atomic_private(&path, "password = \"hunter3\"\n").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"the rename carries the tmp file's own mode over"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"password = \"hunter3\"\n"
);
std::fs::remove_dir_all(&tmp).ok();
}
}
#[cfg(test)]
mod custom_file_tests {
use super::*;
/// Every escape the fence has to refuse, and the nested name it has to allow.
///
/// The list is the point: a `..` scan alone passes `/etc/passwd` and `C:\x` (which `join`
/// replaces the base with rather than appending to), and a string check of any kind passes a
/// planted symlink. Each row names which rule catches it.
#[test]
fn the_fence_refuses_every_escape_and_allows_a_nested_name() {
let _lock = test_env::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = std::env::temp_dir().join(format!("benilla-fence-{}", std::process::id()));
let _guard = test_env::EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
// `home()` short-circuits to `None` under a capture, which would make every assertion
// below vacuously pass.
let _capture = test_env::EnvGuard::unset("WOW_CAPTURE");
let dir = custom_files_dir().expect("a config home");
for (name, why) in [
("", "empty: resolves to the directory itself"),
("..", "the parent"),
("../config.toml", "the parent, then a file we own"),
("a/../../config.toml", "a `..` inside a nested path"),
("./x", "a `.` component is not a name"),
("/etc/passwd", "absolute: `join` replaces the base"),
("/", "the root alone"),
] {
assert!(
custom_file_path(name).is_none(),
"{name:?} must be refused — {why}"
);
}
// Windows' own two shapes. `Path` parses these as Prefix/RootDir only on Windows, so the
// assertion is that they are refused *wherever* this runs: on Unix they are refused as
// ordinary names that simply do not escape, and on Windows by the prefix rule.
for name in ["C:\\Windows\\system.ini", "\\\\server\\share\\x"] {
let got = custom_file_path(name);
assert!(
got.is_none() || got.as_ref().is_some_and(|p| p.starts_with(&dir)),
"{name:?} resolved outside the fence: {got:?}"
);
}
// A flat name and a nested one both land inside, which is the whole point of the fence
// existing rather than the family being refused.
for name in ["profile.txt", "MyAddon/profile.txt"] {
let p = custom_file_path(name).expect(name);
assert!(p.starts_with(&dir), "{name:?} -> {p:?}");
}
// A symlink at the leaf is the one escape the component rules cannot see.
#[cfg(unix)]
{
std::fs::create_dir_all(&dir).unwrap();
let link = dir.join("escape.txt");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink("/etc/passwd", &link).unwrap();
assert!(
custom_file_path("escape.txt").is_none(),
"a planted symlink must be refused by the entry's own type, not by its target"
);
let _ = std::fs::remove_file(&link);
}
let _ = std::fs::remove_dir_all(&tmp);
}
}