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
808 lines
34 KiB
Rust
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);
|
|
}
|
|
}
|