From e716709bcad84b601620a4eff768afe58237cd9d Mon Sep 17 00:00:00 2001 From: grom Date: Sat, 24 Oct 2026 11:30:49 -0600 Subject: [PATCH] app+ui: the nine file and credential verbs, fenced and keyed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/Cargo.toml | 4 +- crates/benilla-app/src/autologin.rs | 33 + crates/benilla-app/src/credential.rs | 382 ++++++++++++ crates/benilla-app/src/lib.rs | 1 + crates/benilla-app/src/local_state.rs | 127 ++++ crates/benilla-app/src/login/mod.rs | 9 +- crates/benilla-app/src/ui_script/lifecycle.rs | 160 +++++ crates/benilla-app/src/ui_script/mod.rs | 44 ++ .../benilla-ui/src/script/compat/nampower.rs | 565 ++++++++++++++++++ crates/benilla-ui/src/script/encoding.rs | 146 +++++ crates/benilla-ui/src/script/mod.rs | 10 +- crates/benilla-ui/src/script/model.rs | 42 ++ crates/benilla-ui/src/script/spellbook.rs | 16 + .../src/script/tests/reference_surface.rs | 9 + docs/ADDON_COMPAT.md | 71 ++- 15 files changed, 1599 insertions(+), 20 deletions(-) create mode 100644 crates/benilla-app/src/credential.rs create mode 100644 crates/benilla-ui/src/script/encoding.rs diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index bfebd185..88671cf7 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -79,6 +79,9 @@ rtrb = "0.3" mint = "0.5" # The gossip greeting's weighted draw, re-rolled per open as in the reference. rand = "0.8" +# HMAC-SHA1 for `credential.rs`, the one place a password is kept as a blob rather than as +# itself. Was a dev-dependency only (the fake realmd's SRP6 half); now a real one. +sha1.workspace = true # Wayland clipboard over the standard `wl_data_device`; arboard's speaks `wlr-data-control`, # which GNOME does not implement. @@ -118,4 +121,3 @@ windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_ [dev-dependencies] benilla-srp = { path = "../benilla-srp" } num-bigint = "0.4.6" -sha1.workspace = true diff --git a/crates/benilla-app/src/autologin.rs b/crates/benilla-app/src/autologin.rs index 29da5159..bdc4f6af 100644 --- a/crates/benilla-app/src/autologin.rs +++ b/crates/benilla-app/src/autologin.rs @@ -55,6 +55,15 @@ pub(crate) struct AutoLogin { pub(crate) account: String, /// **Plaintext.** Empty means no auto-login, whatever `enabled` says — see [`Self::armed`]. pub(crate) password: String, + /// The same credential as a blob, written by nampower's `EncryptedServerLogin` + /// (`crate::credential`). When present it **wins** over [`Self::password`], and the login + /// screen's refill is the only reader — [`Self::effective_password`] is that one read. + /// + /// Additive on purpose: the plaintext field stays exactly as it was, because storing the + /// password in plaintext was the player's own explicit choice and swapping it out from under + /// them is not what "implement these verbs" asked for. An addon (or a future tick-box) opts + /// in by writing this one; nothing migrates by itself. + pub(crate) password_encrypted: Option, /// Per-realm memory, keyed by the realm's name as the realm list gives it. pub(crate) realms: BTreeMap, } @@ -87,6 +96,30 @@ impl Default for RealmMemory { } impl AutoLogin { + /// The password to refill the login box with: the blob decrypted when there is one, else the + /// plaintext field. + /// + /// A blob that does not decrypt — made on another machine, made before the key file was + /// replaced, or tampered with — answers **empty**, not the plaintext field: falling back + /// would refill a stale password the player has since replaced with the blob, and the box + /// being empty is the signal that something needs retyping. + pub(crate) fn effective_password(&self) -> String { + match self.password_encrypted.as_deref() { + Some(blob) => crate::credential::decrypt(blob).unwrap_or_default(), + None => self.password.clone(), + } + } + + /// Store a credential as a blob, nampower's `EncryptedServerLogin`. The plaintext field is + /// cleared, so the two never both hold a secret. + pub(crate) fn store_encrypted(&mut self, account: &str, blob: &str) { + self.enabled = true; + self.account = account.to_string(); + self.password.clear(); + self.password_encrypted = Some(blob.to_string()); + self.save(); + } + /// The store at [`local_state::autologin_path`]; defaults when absent, unreadable or /// malformed. A parse failure is warned about once rather than swallowed, because the file /// is hand-editable and a typo would otherwise look like the feature silently turning off. diff --git a/crates/benilla-app/src/credential.rs b/crates/benilla-app/src/credential.rs new file mode 100644 index 00000000..1de07111 --- /dev/null +++ b/crates/benilla-app/src/credential.rs @@ -0,0 +1,382 @@ +//! nampower's `EncryptPassword`/`EncryptedServerLogin` pair: a password kept on disk as a blob +//! rather than as itself. +//! +//! **What this protects, and what it does not.** The threat it answers is the one that makes the +//! pair worth having at all: an addon's SavedVariables file, or a config file, read by someone +//! else or pasted into a forum post. The blob alone is useless — it is authenticated and keyed on +//! 32 random bytes in `benilla-config/credential.key`, mode `0600`, which is a *different file*. +//! It is **not** protection against anything running as this user: that process can read the key +//! file as easily as the blob. There is no user secret in the chain and there cannot be, because +//! the point of the pair is logging in without typing one. +//! +//! **Not wire-compatible with nampower's own blobs**, and it cannot be: that DLL's scheme is not +//! documented and not in its string pool, and a blob is per-machine configuration anyway — a key +//! file generated here would not decrypt one made there whatever the cipher. So a +//! `NampowerSettings` blob copied from a real install will not decrypt here, and this module's +//! own blobs will not decrypt there. [`decrypt`] answers `None` for one, which the verb reports +//! as nil rather than as a wrong password. +//! +//! **The construction**, from the two primitives this tree already has (`sha1` and `rand`): +//! encrypt-then-MAC with HMAC-SHA1 as the PRF for both halves. `nonce(16) || ciphertext || +//! tag(20)`, keystream `HMAC(key, nonce || counter_be32)` in 20-byte blocks, tag +//! `HMAC(key, nonce || ciphertext)`. HMAC-SHA1 is not broken as a PRF or a MAC — SHA-1's +//! collision weakness is a different property from either — and rolling a cipher is not what is +//! happening here: this is a standard construction with standard parts. + +use bevy::prelude::warn; +use sha1::{Digest, Sha1}; + +use crate::local_state; + +/// HMAC-SHA1's block and output sizes. +const BLOCK: usize = 64; +const MAC: usize = 20; +/// The nonce, long enough that a fresh one per call never repeats in practice. +const NONCE: usize = 16; +/// The key file's size. 32 bytes is more entropy than SHA-1's output can distinguish, which is +/// the point: the key is not the weak link. +const KEY: usize = 32; + +/// HMAC-SHA1 (RFC 2104), written out because the tree has `sha1` and no `hmac`. +fn hmac(key: &[u8], msg: &[u8]) -> [u8; MAC] { + // A key longer than the block is hashed first; a shorter one is zero-padded. + let mut k = [0u8; BLOCK]; + if key.len() > BLOCK { + k[..MAC].copy_from_slice(&Sha1::digest(key)); + } else { + k[..key.len()].copy_from_slice(key); + } + let mut inner = Sha1::new(); + inner.update(k.iter().map(|b| b ^ 0x36).collect::>()); + inner.update(msg); + let inner = inner.finalize(); + let mut outer = Sha1::new(); + outer.update(k.iter().map(|b| b ^ 0x5c).collect::>()); + outer.update(inner); + outer.finalize().into() +} + +/// XOR `data` with the keystream for `nonce`, in place. The same call encrypts and decrypts. +fn apply_keystream(key: &[u8], nonce: &[u8], data: &mut [u8]) { + for (block, chunk) in data.chunks_mut(MAC).enumerate() { + let mut msg = Vec::with_capacity(nonce.len() + 4); + msg.extend_from_slice(nonce); + // Big-endian, so the counter's own bytes are stable across machines; it never matters for + // correctness here (one machine, one key) and it costs nothing to pin. + msg.extend_from_slice(&u32::try_from(block).unwrap_or(u32::MAX).to_be_bytes()); + let ks = hmac(key, &msg); + for (b, k) in chunk.iter_mut().zip(ks) { + *b ^= k; + } + } +} + +/// The machine-local key as its 32 raw bytes, created on first use. +/// +/// Stored as **hex text**, not raw bytes, for two reasons: the file is inspectable, and a stray +/// newline from an editor cannot silently change the key's length. Created with +/// [`local_state::write_atomic_private`], so it is `0600` **before any bytes reach it** — a key +/// briefly readable under the default umask is a key that was readable. +/// +/// A file that does not hold exactly `KEY` bytes of hex is **replaced**, not used: a truncated or +/// edited one would still round-trip with itself and would quietly weaken every blob made with +/// it. Replacing it orphans the old blobs, which `decrypt` reports as nil — the honest outcome, +/// and the warning says so. +/// +/// One function rather than a create/read pair, because the first shape of this had two: `key` +/// returned raw bytes on creation and hex text on read, and a caller had to guess which. It +/// guessed with "32 bytes and not all hex digits", and the round trip failed the moment a random +/// key came out all-hex. Returning one thing is the fix. +fn key_bytes() -> Option> { + use rand::RngCore as _; + + let decode = |text: &str| -> Option> { + let text = text.trim(); + (text.len() == KEY * 2) + .then(|| { + (0..KEY) + .map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).ok()) + .collect::>>() + }) + .flatten() + }; + + let path = local_state::home()?.join("credential.key"); + match std::fs::read_to_string(&path) { + Ok(text) => { + if let Some(bytes) = decode(&text) { + return Some(bytes); + } + warn!( + "credential: {} does not hold {KEY} bytes of hex — replacing it (blobs made with \ + the old key will no longer decrypt)", + path.display() + ); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + warn!("credential: cannot read {}: {e}", path.display()); + return None; + } + } + let mut fresh = vec![0u8; KEY]; + rand::thread_rng().fill_bytes(&mut fresh); + let hex: String = fresh.iter().map(|b| format!("{b:02x}")).collect(); + if let Err(e) = local_state::write_atomic_private(&path, &hex) { + warn!("credential: cannot create {}: {e}", path.display()); + return None; + } + Some(fresh) +} + +/// `EncryptPassword(password)`: the base64 blob, or `None` with no config home (a capture). +pub(crate) fn encrypt(password: &str) -> Option { + use rand::RngCore as _; + + let key = key_bytes()?; + let mut nonce = [0u8; NONCE]; + rand::thread_rng().fill_bytes(&mut nonce); + let mut body = password.as_bytes().to_vec(); + apply_keystream(&key, &nonce, &mut body); + // Encrypt-then-MAC: the tag covers the ciphertext and the nonce, so neither can be changed + // without the tag failing. The other order authenticates the plaintext and leaves the + // ciphertext malleable, which is the classic way to get this wrong. + let mut tagged = nonce.to_vec(); + tagged.extend_from_slice(&body); + let tag = hmac(&key, &tagged); + tagged.extend_from_slice(&tag); + Some(benilla_ui::script::encoding::base64_encode(&tagged)) +} + +/// The inverse. `None` for a malformed blob, a blob from another machine, or a tampered one — +/// all three are "this is not a credential I made", which is the only thing a caller can act on. +pub(crate) fn decrypt(blob: &str) -> Option { + let key = key_bytes()?; + let raw = benilla_ui::script::encoding::base64_decode(blob)?; + if raw.len() < NONCE + MAC { + return None; + } + let (head, tag) = raw.split_at(raw.len() - MAC); + // Checked before a single byte is decrypted, so a tampered blob never produces a plaintext at + // all — not even one that is then discarded. + if hmac(&key, head) != tag { + return None; + } + let (nonce, body) = head.split_at(NONCE); + let mut body = body.to_vec(); + apply_keystream(&key, nonce, &mut body); + String::from_utf8(body).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A scoped config home, so the key file is this test's own. + fn home( + tag: &str, + ) -> ( + std::path::PathBuf, + local_state::test_env::EnvGuard, + local_state::test_env::EnvGuard, + ) { + let tmp = std::env::temp_dir().join(format!("benilla-cred-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).expect("temp dir"); + let g = local_state::test_env::EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap()); + let c = local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); + (tmp, g, c) + } + + /// HMAC-SHA1 against RFC 2202's own vectors, because a hand-written HMAC is exactly the thing + /// that is plausibly wrong and silently so: a broken one still round-trips with itself. + #[test] + fn hmac_sha1_matches_the_rfc_vectors() { + let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + // Case 1. + assert_eq!( + hex(&hmac(&[0x0b; 20], b"Hi There")), + "b617318655057264e28bc0b6fb378c8ef146be00" + ); + // Case 2, a short ASCII key. + assert_eq!( + hex(&hmac(b"Jefe", b"what do ya want for nothing?")), + "effcdf6ae5eb2fa2d27416d5f184df9c259a7c79" + ); + // Case 3, a key and message of 0xaa/0xdd bytes. + assert_eq!( + hex(&hmac(&[0xaa; 20], &[0xdd; 50])), + "125d7342b9ac11cd91a39af48aa17b4f63f175d3" + ); + // Case 6: a key LONGER than the block, which is the branch that hashes it first. + assert_eq!( + hex(&hmac( + &[0xaa; 80], + b"Test Using Larger Than Block-Size Key - Hash Key First" + )), + "aa4ae5e15272d00e95705637ce8a3b55ed402112" + ); + } + + /// The round trip, and the three things that must not decrypt. + #[test] + fn a_password_round_trips_and_a_tampered_blob_does_not() { + let _lock = local_state::test_env::ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let (tmp, _g, _c) = home("rt"); + + for secret in [ + "hunter2", + "", + "a password with spaces and ünïcödé", + &"x".repeat(200), + ] { + let blob = encrypt(secret).expect("a config home"); + assert_ne!(blob, secret, "the blob is not the password"); + assert!(!blob.contains("hunter2"), "and does not contain it: {blob}"); + assert_eq!(decrypt(&blob).as_deref(), Some(secret)); + } + + // Two encryptions of one password differ: the nonce is fresh, so a blob does not leak + // "this is the same password as that one". + let (a, b) = (encrypt("same").unwrap(), encrypt("same").unwrap()); + assert_ne!(a, b); + assert_eq!(decrypt(&a), decrypt(&b)); + + // Tampering: a flipped ciphertext byte, a truncated blob, and something that is not + // base64 at all. Each is None — never a wrong plaintext. + let good = encrypt("hunter2").unwrap(); + let mut raw = benilla_ui::script::encoding::base64_decode(&good).unwrap(); + raw[NONCE] ^= 1; + assert_eq!( + decrypt(&benilla_ui::script::encoding::base64_encode(&raw)), + None, + "a flipped ciphertext byte fails the tag" + ); + assert_eq!(decrypt(&good[..good.len() - 8]), None, "truncated"); + assert_eq!(decrypt("not base64 at all!"), None); + assert_eq!(decrypt(""), None); + + // The key file exists, holds hex of the right length, and is 0600. + let key_path = tmp.join("credential.key"); + let text = std::fs::read_to_string(&key_path).expect("the key file"); + assert_eq!(text.trim().len(), KEY * 2); + assert!(text.trim().bytes().all(|b| b.is_ascii_hexdigit())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(&key_path).unwrap().permissions().mode(); + assert_eq!( + mode & 0o777, + 0o600, + "the key must not be readable by others" + ); + } + + let _ = std::fs::remove_dir_all(&tmp); + } + + /// A blob made under one key does not decrypt under another — which is both the per-machine + /// property and the reason a nampower blob from a real install answers nil here. + #[test] + fn a_blob_does_not_decrypt_under_a_different_key() { + let _lock = local_state::test_env::ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let blob = { + let (tmp, _g, _c) = home("k1"); + let b = encrypt("hunter2").unwrap(); + assert_eq!(decrypt(&b).as_deref(), Some("hunter2")); + let _ = std::fs::remove_dir_all(&tmp); + b + }; + let (tmp, _g, _c) = home("k2"); + assert_eq!( + decrypt(&blob), + None, + "a fresh key file means a fresh key, and the tag says so" + ); + let _ = std::fs::remove_dir_all(&tmp); + } + + /// A key file of the wrong length is replaced, not used: a truncated one would still + /// round-trip and would quietly weaken every blob made with it. + #[test] + fn a_short_key_file_is_replaced() { + let _lock = local_state::test_env::ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let (tmp, _g, _c) = home("short"); + let key_path = tmp.join("credential.key"); + std::fs::write(&key_path, "abcd").unwrap(); + let blob = encrypt("hunter2").expect("a replaced key still encrypts"); + assert_eq!(decrypt(&blob).as_deref(), Some("hunter2")); + assert_eq!( + std::fs::read_to_string(&key_path).unwrap().trim().len(), + KEY * 2 + ); + let _ = std::fs::remove_dir_all(&tmp); + } +} + +#[cfg(test)] +mod store_tests { + use super::*; + use crate::autologin::AutoLogin; + + /// `EncryptedServerLogin`'s end of the pair: the blob goes on disk, the plaintext does not, + /// and the login screen's refill reads the password back. + /// + /// This is the property the whole pair exists for — `autologin.toml` holding a blob instead + /// of a password — so it is asserted against the file's own bytes rather than against the + /// struct. + #[test] + fn storing_a_blob_keeps_the_password_out_of_the_config_file() { + let _lock = local_state::test_env::ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let tmp = std::env::temp_dir().join(format!("benilla-credstore-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).expect("temp dir"); + let _g = local_state::test_env::EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap()); + let _c = local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); + + let blob = encrypt("hunter2").expect("a config home"); + let mut auto = AutoLogin::default(); + auto.store_encrypted("MYACCOUNT", &blob); + + // The refill reads the password back through the one reader the screen uses. + assert_eq!(auto.effective_password(), "hunter2"); + assert!(auto.password.is_empty(), "the plaintext field is cleared"); + assert!(auto.enabled, "and the credential is armed"); + + // The file on disk holds the blob and not the password. This is the point. + let text = std::fs::read_to_string(tmp.join("autologin.toml")).expect("the store"); + assert!( + !text.contains("hunter2"), + "autologin.toml must not contain the password:\n{text}" + ); + assert!(text.contains(&blob), "it holds the blob instead"); + + // A blob that does not decrypt refills **empty**, not the stale plaintext: the box being + // empty is the signal that something needs retyping. + let mut stale = AutoLogin { + enabled: true, + password: "old-plaintext".into(), + password_encrypted: Some("bm90IGEgYmxvYg==".into()), + ..Default::default() + }; + assert_eq!( + stale.effective_password(), + "", + "a bad blob does not fall back to a password the player has since replaced" + ); + // With no blob at all the plaintext field is still what refills — the existing path, + // unchanged, which is the whole point of this being additive. + stale.password_encrypted = None; + assert_eq!(stale.effective_password(), "old-plaintext"); + + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 8ac25013..7b1b3174 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -44,6 +44,7 @@ mod combat_text; mod console; mod crash; mod creature_anim; +mod credential; mod cursor; mod cvars; mod death; diff --git a/crates/benilla-app/src/local_state.rs b/crates/benilla-app/src/local_state.rs index ea4c732a..6812b9b5 100644 --- a/crates/benilla-app/src/local_state.rs +++ b/crates/benilla-app/src/local_state.rs @@ -215,6 +215,65 @@ pub(crate) fn lua_cache_dir() -> Option { 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 { + 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 { + 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 { home().map(|h| h.join("Logs")) @@ -678,3 +737,71 @@ mod tests { 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); + } +} diff --git a/crates/benilla-app/src/login/mod.rs b/crates/benilla-app/src/login/mod.rs index 795cbeb4..f9960b8a 100644 --- a/crates/benilla-app/src/login/mod.rs +++ b/crates/benilla-app/src/login/mod.rs @@ -675,11 +675,11 @@ fn enter_login( // player still presses Login. Gated on the tick as well as on the text, so unticking stops // the fill in the same frame rather than waiting on the stored secret being cleared. let fill = if auto.enabled { - auto.password.as_str() + auto.effective_password() } else { - "" + String::new() }; - form.password.set_text(fill); + form.password.set_text(&fill); // Focus starts the caret solid (`set_text` no-ops on an unchanged name) and selects, so a // remembered name is typed over. let focus = form.focus; @@ -788,6 +788,9 @@ fn login_input( // has said they do not want the credential kept. if !auto.enabled { auto.password.clear(); + // Both forms, or unticking would leave the blob behind — and a blob is a + // credential however it is spelled. + auto.password_encrypted = None; } auto.save(); } diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index cc4b2fe6..5c6d1082 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -39,6 +39,9 @@ fn install_boot_vm(world: &mut World) { install_spell_catalog_probe(world, &mut script); install_item_icon_probe(world, &mut script); install_spell_modifier_probe(world, &mut script); + install_custom_files(&mut script); + // nampower's `EncryptPassword`: a pure function over the machine-local key file. + script.set_password_encrypt_probe(Box::new(crate::credential::encrypt)); seed_creature_info(world, &mut script); load_global_strings(world, &script); load_emote_tokens(world, &script); @@ -204,6 +207,73 @@ fn install_item_icon_probe(world: &World, script: &mut UiScript) { script.set_item_icon_probe(Box::new(move |display_id| icons.get(&display_id).cloned())); } +/// nampower's custom-file family, fenced to `benilla-config/CustomFiles/`. +/// +/// **The fence is here, not in the VM.** Every one of the seven verbs goes through +/// [`crate::local_state::custom_file_path`], which admits a single relative name of ordinary +/// components and refuses a leaf that is already a symlink — so no spelling of a `filename` +/// argument reaches outside that directory. The VM side holds three closures and no paths. +/// +/// Installed unconditionally, including on a VM with no config home at all: `custom_file_path` +/// answers `None` there (a hermetic capture), so every verb answers nil and nothing is created. +fn install_custom_files(script: &mut UiScript) { + use benilla_ui::script::{CustomFileAccess, CustomFileMode}; + + /// Open a fenced name for writing, creating the directory it sits in. + /// + /// The parent is created rather than required, because a nested name + /// (`MyAddon/profile.txt`) is allowed and an addon should not have to make its own folder + /// first. `create_dir_all` on the *parent* only — never on the full path, which would make a + /// directory where a file was asked for. + fn write_fenced(name: &str, bytes: &[u8], mode: CustomFileMode) -> bool { + use std::io::Write as _; + + let Some(path) = crate::local_state::custom_file_path(name) else { + warn!("custom-file: refusing the name {name:?} — outside benilla-config/CustomFiles"); + return false; + }; + let made = path + .parent() + .map_or(Ok(()), std::fs::create_dir_all) + .and_then(|()| { + std::fs::OpenOptions::new() + .create(true) + .write(true) + .append(mode == CustomFileMode::Append) + .truncate(mode == CustomFileMode::Truncate) + .open(&path) + }) + .and_then(|mut f| f.write_all(bytes)); + match made { + Ok(()) => true, + Err(e) => { + warn!("custom-file: writing {} failed: {e}", path.display()); + false + } + } + } + + script.set_custom_files(CustomFileAccess { + read: Box::new(|name| { + let path = crate::local_state::custom_file_path(name)?; + match std::fs::read(&path) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + warn!("custom-file: reading {} failed: {e}", path.display()); + None + } + } + }), + write: Box::new(write_fenced), + // `is_file`, not `exists`: a directory of that name is not a file this family can read, + // and answering true for one would make `ReadCustomFile` the thing that reports it. + exists: Box::new(|name| { + crate::local_state::custom_file_path(name).is_some_and(|p| p.is_file()) + }), + }); +} + /// nampower's `GetSpellModifiers(spellId, modifierType)`: the `(flat, percent)` pair /// `spell/mods.rs`'s own reader folds out of the two `i32[64][29]` talent-modifier tables the /// wire fills. @@ -1323,6 +1393,96 @@ mod tests { } } +#[cfg(test)] +mod custom_file_access_tests { + use super::*; + + /// The installed access really writes, reads back and refuses — through the verbs, against a + /// real directory. `local_state::custom_file_tests` pins the fence's own rules; what this + /// pins is that the three closures are wired to it at all, which no unit test on either side + /// can see. + #[test] + fn a_write_through_the_verb_lands_in_the_fenced_directory_and_reads_back() { + let _lock = crate::local_state::test_env::ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let tmp = std::env::temp_dir().join(format!("benilla-cf-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let _guard = crate::local_state::test_env::EnvGuard::set( + "BENILLA_HOME", + tmp.to_str().expect("utf-8 temp dir"), + ); + let _capture = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); + + let mut script = UiScript::new().expect("a VM"); + install_custom_files(&mut script); + + // A nested name, so the directory creation is exercised too. + assert_eq!( + script + .eval::(r#"return WriteCustomFile("MyAddon/profile.txt", "kept")"#) + .unwrap(), + 1 + ); + let on_disk = tmp.join("CustomFiles").join("MyAddon").join("profile.txt"); + assert_eq!( + std::fs::read_to_string(&on_disk).ok().as_deref(), + Some("kept"), + "the bytes are at {}", + on_disk.display() + ); + assert_eq!( + script + .eval::(r#"return ReadCustomFile("MyAddon/profile.txt")"#) + .unwrap(), + "kept" + ); + assert_eq!( + script + .eval::(r#"return CustomFileExists("MyAddon/profile.txt")"#) + .unwrap(), + 1 + ); + // Append, then the truncating default, both through the real file. + script + .eval::(r#"return WriteCustomFile("MyAddon/profile.txt", "-more", "a")"#) + .unwrap(); + assert_eq!( + std::fs::read_to_string(&on_disk).ok().as_deref(), + Some("kept-more") + ); + script + .eval::(r#"return ExportFile("MyAddon/profile.txt", "fresh")"#) + .unwrap(); + assert_eq!( + std::fs::read_to_string(&on_disk).ok().as_deref(), + Some("fresh") + ); + + // And the fence holds through the verb: nothing is written, and the name the escape aimed + // at is still absent. + assert!( + script + .eval::>(r#"return WriteCustomFile("../escaped.txt", "no")"#) + .unwrap() + .is_none(), + "a refused write answers nil" + ); + assert!( + !tmp.join("escaped.txt").exists(), + "and nothing was created outside CustomFiles" + ); + + // A directory is not a readable file, which is what `is_file` rather than `exists` is for. + assert!(script + .eval::>(r#"return CustomFileExists("MyAddon")"#) + .unwrap() + .is_none()); + + let _ = std::fs::remove_dir_all(&tmp); + } +} + #[cfg(test)] mod spell_modifier_probe_tests { use super::*; diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index a1e0276d..acb7b450 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -195,6 +195,46 @@ pub(crate) struct UiClock { pub(crate) ui_now: f64, } +/// Drain nampower's `EncryptedServerLogin` calls into the auto-login store. +/// +/// **The blob is decrypted here, and that is the validation.** A blob made on another machine, +/// one made before the key file was replaced, or one tampered with does not decrypt — and is +/// refused with a line in the log rather than stored, because a stored credential that cannot be +/// read back is a login box that silently refills empty. The VM side queues without the key, so +/// this is the only place that holds it. +/// +/// The decrypted password is **not** kept: the blob is what goes on disk, and the plaintext field +/// is cleared ([`crate::autologin::AutoLogin::store_encrypted`]). Decrypting here only proves it +/// can be done. +pub(crate) fn drain_credential_logins( + script: Option>, + auto: Option>, +) { + let Some(mut script) = script else { + return; + }; + let calls = script.take_credential_logins(); + if calls.is_empty() { + return; + } + let Some(mut auto) = auto else { + // No store (a capture): the calls are dropped rather than queued, so nothing accumulates. + warn!("credential: EncryptedServerLogin with no auto-login store — dropped"); + return; + }; + for (user, blob) in calls { + if crate::credential::decrypt(&blob).is_none() { + warn!( + "credential: refusing EncryptedServerLogin for {user:?} — the blob does not \ + decrypt with this machine's key (made elsewhere, or after the key was replaced)" + ); + continue; + } + info!("credential: storing the encrypted credential for {user:?}"); + auto.store_encrypted(&user, &blob); + } +} + /// [`crate::spell::CooldownLegs`] on the VM's `GetTime()` clock, the one conversion both feeds /// that carry it use — the spellbook's and the bags'. /// @@ -368,6 +408,10 @@ impl Plugin for UiScriptPlugin { Update, lifecycle::refresh_spell_modifier_probe.in_set(UiFeed), ) + // nampower's `EncryptedServerLogin`: a queued credential, written to the auto-login + // store. In the feed phase with every other drain; it writes a file, so it must not + // run twice for one call, which taking the queue guarantees. + .add_systems(Update, drain_credential_logins.in_set(UiFeed)) // `WOW_FLASH_CHURN=1` only; a no-op otherwise. After the tick, because what it // watches is the visibility the frame's handlers just produced — a reading taken // before them would miss the very edges it exists to count. diff --git a/crates/benilla-ui/src/script/compat/nampower.rs b/crates/benilla-ui/src/script/compat/nampower.rs index 5fe9bc6f..81f7c993 100644 --- a/crates/benilla-ui/src/script/compat/nampower.rs +++ b/crates/benilla-ui/src/script/compat/nampower.rs @@ -808,6 +808,274 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { lua.globals() .set("CombatLogFlush", lua.create_function(|_, ()| Ok(()))?)?; + // ── nampower's credential pair ──────────────────────────────────────────────────────────── + // + // `EncryptPassword(password)` and `EncryptedServerLogin(username, encryptedPasswordBase64String)`. + // Built on grom's explicit say-so; the scheme, and exactly what it does and does not protect, + // is `benilla-app`'s `credential.rs`. + // + // **Both are registered by nampower under "Registering *Glue* Lua functions"** — the login + // screen's VM — and this client has no glue Lua VM at all: login, character-select and + // character-create are Rust, and the only glue files read are read as *data* + // (`benilla-app`'s `autologin.rs` header sets that out at length). So there is no VM upstream + // would have defined these in, and they are defined here, in the only one there is. That + // changes what the second one can mean, and the deviation is stated at it rather than left to + // be discovered. + + // `EncryptPassword(password)` -> the base64 blob, or nil. + // + // A pure function: it works the same wherever it is defined, so there is nothing to deviate + // about. Two calls with one password answer **different** blobs (the nonce is fresh), which is + // a property worth having — a stored blob does not leak "this is the same password as that + // one" — and worth knowing, because a caller comparing two blobs for equality will be wrong. + // + // nil with no config home: there is nowhere to keep the key file, which is a hermetic capture + // and not a state a player reaches. + lua.globals().set( + "EncryptPassword", + lua.create_function(|lua, password: Option| { + let model = lua + .app_data_ref::() + .expect("model app_data"); + Ok(password.and_then(|p| { + model + .password_encrypt_probe + .as_ref() + .and_then(|probe| probe(&p)) + })) + })?, + )?; + + // `EncryptedServerLogin(username, blob)`. + // + // **Upstream logs in; this remembers.** That is the deviation, and it is forced rather than + // chosen: upstream's is a glue-screen verb that submits the login form, and in the only VM + // this client has you are already in the world — there is no form, and "log in" is not a + // thing that can happen. What the pair is *for* is a password kept on disk as a blob instead + // of as itself, and the nearest thing this client can do with that is store the credential + // for the next login, which is what `autologin.rs` drives. + // + // So: the username and the blob are queued, and `benilla-app`'s drain writes them into the + // auto-login store — `enabled` on, the plaintext field **cleared**, the blob kept. The next + // login screen refills from the blob through `AutoLogin::effective_password`. + // + // The blob is not validated here. It is validated at the drain, by decrypting it: a blob from + // another machine or a tampered one is refused there with a line in the log, because that is + // where the key is. Queueing it unconditionally keeps this side free of the key entirely. + // + // No return value, as upstream's has none: a login verb's answer is the screen's, and here + // the answer is the next launch. + lua.globals().set( + "EncryptedServerLogin", + lua.create_function(|lua, (user, blob): (Option, Option)| { + let (Some(user), Some(blob)) = (user, blob) else { + return Ok(()); + }; + let mut model = lua + .app_data_mut::() + .expect("model app_data"); + model.credential_logins.push((user, blob)); + Ok(()) + })?, + )?; + + // ── nampower's custom-file family ───────────────────────────────────────────────────────── + // + // Seven verbs that read, write and **execute** files: `WriteCustomFile`, `ReadCustomFile`, + // `CustomFileExists`, `ImportFile`, `ExportFile`, `ExecuteCustomLuaFile`, `StartItemExport`. + // Built on grom's explicit say-so, after a round where they were withheld and the reason + // recorded — `docs/ADDON_COMPAT.md` carries both halves of that decision. + // + // **Every one of them goes through one fence, and the fence is app-side.** + // `benilla-app`'s `local_state::custom_file_path` admits a single relative name whose every + // component is an ordinary name, inside `benilla-config/CustomFiles/`, and refuses a leaf that + // is already a symlink. nampower writes relative to the client's working directory — the + // install root, which is read-only here — so a directory under the config folder is the + // analogue, and 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", and it is not the one + // being granted. + // + // A refused name is **nil**, not an error: the caller cannot tell a refused name from an + // absent file, which is the right amount of information to hand back — the app logs which it + // was. + + // `ReadCustomFile(filename)` and SuperWoW's own spelling `ImportFile(filename)`, which is the + // same operation (upstream registers both; the usage strings differ only in the name). The + // file's bytes as a Lua string, nil for a missing or refused one. + // + // Bytes, not validated UTF-8: a Lua string is a byte string, an addon may have written + // anything, and `mlua`'s `create_string` takes bytes — so a file round-trips through + // `ExportFile`/`ImportFile` whatever is in it. + for name in ["ReadCustomFile", "ImportFile"] { + lua.globals().set( + name, + lua.create_function(|lua, file: Option| { + let bytes = { + let model = lua + .app_data_ref::() + .expect("model app_data"); + file.and_then(|f| model.custom_files.as_ref().and_then(|a| (a.read)(&f))) + }; + Ok(match bytes { + Some(b) => Value::String(lua.create_string(&b)?), + None => Value::Nil, + }) + })?, + )?; + } + + // `WriteCustomFile(filename, content, [mode])` and `ExportFile(filename, text)`. + // + // `mode` is the one difference: `"a"` or `"append"` adds to the file, anything else (and + // absent) replaces it. `ExportFile` has no mode argument in its own usage string, so it always + // replaces — which is also what an export means. + // + // Both answer `1` on success and nil on failure, so a caller can tell a refused name from a + // written file. Upstream's own return is undocumented; a verb that can fail and says nothing + // is a verb whose caller cannot report the failure, and these can fail for a reason the + // player can fix (a full disk, a name the fence refuses). + for (name, forced) in [ + ("WriteCustomFile", None), + ("ExportFile", Some(crate::script::CustomFileMode::Truncate)), + ] { + lua.globals().set( + name, + lua.create_function( + move |lua, + (file, content, mode): ( + Option, + Option, + Option, + )| { + let (Some(file), Some(content)) = (file, content) else { + return Ok(None::); + }; + let mode = forced.unwrap_or_else(|| { + match mode.as_deref().map(str::to_ascii_lowercase).as_deref() { + Some("a" | "append") => crate::script::CustomFileMode::Append, + _ => crate::script::CustomFileMode::Truncate, + } + }); + let model = lua + .app_data_ref::() + .expect("model app_data"); + let ok = model + .custom_files + .as_ref() + .is_some_and(|a| (a.write)(&file, &content.as_bytes(), mode)); + Ok(ok.then_some(1i64)) + }, + )?, + )?; + } + + // `CustomFileExists(filename)` -> `1` or nil. + // + // `1`-or-nil rather than a Lua boolean, which is the shape every nampower predicate with a + // documented return takes (`PlayerIsMoving`, `IsAuraHidden`) — upstream is not consistent + // about this and both test truthy, so the house style here is the tie-breaker. A refused name + // answers nil, same as an absent file: the caller is told "you cannot read this", which is + // true either way. + lua.globals().set( + "CustomFileExists", + lua.create_function(|lua, file: Option| { + let model = lua + .app_data_ref::() + .expect("model app_data"); + Ok(file + .and_then(|f| model.custom_files.as_ref().map(|a| (a.exists)(&f))) + .unwrap_or(false) + .then_some(1i64)) + })?, + )?; + + // `ExecuteCustomLuaFile(filename)`: load the file and run it as a chunk, in this VM, at global + // scope — the same thing loading an addon file does. + // + // **The one verb in this family that is not just storage**, which is why its failure mode is + // the loudest one available: a syntax error or a raise inside the chunk comes back as a Lua + // error, so the caller's `pcall` sees it and an unguarded call reports it through the usual + // error path. Returning nil instead would make a broken file indistinguishable from a missing + // one, and this is the file where that distinction is worth the most. + // + // The chunk name is `@`, so a traceback points at the file the player wrote rather + // than at a nameless chunk — the `@` prefix is Lua's own "this is a file name" marker. + lua.globals().set( + "ExecuteCustomLuaFile", + lua.create_function(|lua, file: Option| { + let Some(file) = file else { + return Ok(None::); + }; + let bytes = { + let model = lua + .app_data_ref::() + .expect("model app_data"); + model.custom_files.as_ref().and_then(|a| (a.read)(&file)) + }; + let Some(bytes) = bytes else { + return Ok(None); + }; + lua.load(&bytes[..]).set_name(format!("@{file}")).exec()?; + Ok(Some(1i64)) + })?, + )?; + + // `StartItemExport()`: write what the item cache holds to `ItemExport.txt`, tab-separated, + // one item per line with a header row. + // + // **A deviation worth stating.** Upstream's name says "start", and on the evidence of that + // word it sweeps item ids off the server — this exports the rows the cache has already seen. + // A sweep is a few thousand `CMSG_ITEM_QUERY_SINGLE`s at the realm, which is not something + // this client should do because a Lua call asked it to; and the rows a player's own cache + // holds are the ones their session actually touched, which is what an export is usually for. + // So: no packets, no "start", and the file is written before the call returns. `1` on + // success, nil if the file could not be written. + lua.globals().set( + "StartItemExport", + lua.create_function(|lua, ()| { + let mut out = String::from("itemId\tname\tquality\tclass\tsubclass\titemLevel\n"); + let model = lua + .app_data_ref::() + .expect("model app_data"); + let mut ids: Vec = model.item_templates.keys().copied().collect(); + ids.sort_unstable(); + for id in ids { + let Some(t) = model.item_templates.get(&id) else { + continue; + }; + // Tabs and newlines in a name would break the row shape; no 1.12 item has one, + // and a realm that invents one gets a space rather than a broken file. + let name: String = t + .name + .chars() + .map(|c| { + if c == '\t' || c == '\n' || c == '\r' { + ' ' + } else { + c + } + }) + .collect(); + out.push_str(&format!( + "{id}\t{name}\t{}\t{}\t{}\t{}\n", + t.quality, t.class, t.subclass, t.item_level + )); + } + Ok(model + .custom_files + .as_ref() + .is_some_and(|a| { + (a.write)( + "ItemExport.txt", + out.as_bytes(), + crate::script::CustomFileMode::Truncate, + ) + }) + .then_some(1i64)) + })?, + )?; + // `GetSpellNameAndRankForId(spellID)` -> name, rank. DoiteAuras' hardest-leaned-on nampower // call (18 sites): every aura it tracks by id resolves its label through this before falling // back to a 200-slot spellbook scan. Both values come off the same `Spell.dbc` display row @@ -2959,6 +3227,303 @@ mod tests { /// `GetSpellRecField`: the columns this client has, and **nil for the ones it does not** — /// asserted, because a plausible zero is worse than nothing here (DoiteAuras' Clearcasting + /// The custom-file family, through a handle that stands in for the app's fenced directory. + /// + /// The harness records every `(name, mode)` the verbs ask for, so what is asserted is **what + /// reaches the fence** — the VM side's whole job is to pass a name through unchanged and + /// report nil when the answer is nil. A test that wrote real files would be testing + /// `std::fs`; the fence itself is pinned in `benilla-app`'s `local_state::custom_file_tests`, + /// which is where the escapes live. + #[test] + fn the_custom_file_family_passes_names_through_and_reports_refusals_as_nil() { + use std::cell::RefCell; + use std::rc::Rc; + + let mut s = UiScript::new().unwrap(); + + // No handle at all — a hermetic capture, or a VM with no config home. Every verb is nil + // and nothing raises. + for call in [ + r#"ReadCustomFile("x")"#, + r#"ImportFile("x")"#, + r#"WriteCustomFile("x", "y")"#, + r#"ExportFile("x", "y")"#, + r#"CustomFileExists("x")"#, + r#"ExecuteCustomLuaFile("x")"#, + "StartItemExport()", + ] { + assert!( + s.eval::>(&format!("return {call}")) + .unwrap() + .is_none_or(|v| v.is_nil()), + "{call} with no handle" + ); + } + + // A handle holding one file, recording every ask. + type Log = Rc>>; + let log: Log = Rc::new(RefCell::new(Vec::new())); + let files: Rc>>> = Rc::new(RefCell::new( + std::collections::HashMap::from([("have.txt".to_string(), b"hello".to_vec())]), + )); + { + let (rl, rf) = (log.clone(), files.clone()); + let (wl, wf) = (log.clone(), files.clone()); + let (el, ef) = (log.clone(), files.clone()); + s.set_custom_files(crate::script::CustomFileAccess { + read: Box::new(move |name| { + rl.borrow_mut().push(format!("read {name}")); + // `refused` stands in for a name the fence rejects. + (name != "refused") + .then(|| rf.borrow().get(name).cloned()) + .flatten() + }), + write: Box::new(move |name, bytes, mode| { + wl.borrow_mut().push(format!("write {name} {mode:?}")); + if name == "refused" { + return false; + } + let mut map = wf.borrow_mut(); + let slot = map.entry(name.to_string()).or_default(); + if mode == crate::script::CustomFileMode::Truncate { + slot.clear(); + } + slot.extend_from_slice(bytes); + true + }), + exists: Box::new(move |name| { + el.borrow_mut().push(format!("exists {name}")); + name != "refused" && ef.borrow().contains_key(name) + }), + }); + } + + // Read, both spellings, one operation. + assert_eq!( + s.eval::(r#"return ReadCustomFile("have.txt")"#) + .unwrap(), + "hello" + ); + assert_eq!( + s.eval::(r#"return ImportFile("have.txt")"#) + .unwrap(), + "hello", + "SuperWoW's own name for the same read" + ); + assert!(s + .eval::>(r#"return ReadCustomFile("missing.txt")"#) + .unwrap() + .is_none()); + assert!( + s.eval::>(r#"return ReadCustomFile("refused")"#) + .unwrap() + .is_none(), + "a refused name is nil, indistinguishable from an absent file — which is the right \ + amount to tell the caller" + ); + + // Write: the default replaces, `"a"` appends, and `ExportFile` always replaces. + assert_eq!( + s.eval::(r#"return WriteCustomFile("note.txt", "one")"#) + .unwrap(), + 1 + ); + s.eval::(r#"return WriteCustomFile("note.txt", "two", "a")"#) + .unwrap(); + assert_eq!( + s.eval::(r#"return ReadCustomFile("note.txt")"#) + .unwrap(), + "onetwo", + "append added rather than replaced" + ); + s.eval::(r#"return ExportFile("note.txt", "three")"#) + .unwrap(); + assert_eq!( + s.eval::(r#"return ReadCustomFile("note.txt")"#) + .unwrap(), + "three", + "an export replaces — it has no mode argument" + ); + assert!( + s.eval::>(r#"return WriteCustomFile("refused", "x")"#) + .unwrap() + .is_none(), + "a refused write is nil, so the caller can report the failure" + ); + + // The modes that reached the fence, which is what this side is responsible for. + assert_eq!( + log.borrow() + .iter() + .filter(|l| l.starts_with("write note.txt")) + .cloned() + .collect::>(), + vec![ + "write note.txt Truncate".to_string(), + "write note.txt Append".to_string(), + "write note.txt Truncate".to_string(), + ] + ); + + // Exists: 1 or nil, never a boolean, and never true for a refused name. + assert_eq!( + s.eval::(r#"return CustomFileExists("have.txt")"#) + .unwrap(), + 1 + ); + assert!(s + .eval::(r#"return CustomFileExists("have.txt") == 1"#) + .unwrap()); + assert!(!s + .eval::(r#"return CustomFileExists("have.txt") == true"#) + .unwrap()); + for absent in ["missing.txt", "refused"] { + assert!( + s.eval::>(&format!(r#"return CustomFileExists("{absent}")"#)) + .unwrap() + .is_none(), + "{absent}" + ); + } + + // Binary round-trips: a Lua string is a byte string, and the read is bytes not UTF-8. + s.eval::(r#"return ExportFile("bin", "\000\255\001")"#) + .unwrap(); + assert_eq!( + s.eval::(r#"return string.len(ReadCustomFile("bin"))"#) + .unwrap(), + 3, + "an embedded NUL survives, which a C-string round trip would cut at one byte" + ); + } + + /// `ExecuteCustomLuaFile` is the one verb in the family that is not storage. A missing file is + /// nil; a **broken** file raises, so the caller can tell the two apart — which is the + /// distinction worth the most on the file a player edits by hand. + #[test] + fn executing_a_custom_lua_file_runs_it_and_a_broken_one_raises() { + use std::cell::RefCell; + use std::rc::Rc; + + let mut s = UiScript::new().unwrap(); + let files: Rc>>> = + Rc::new(RefCell::new(std::collections::HashMap::from([ + ("good.lua".to_string(), b"RAN = (RAN or 0) + 1".to_vec()), + ("broken.lua".to_string(), b"this is not lua".to_vec()), + ("raises.lua".to_string(), b"error('boom')".to_vec()), + ]))); + let rf = files.clone(); + s.set_custom_files(crate::script::CustomFileAccess { + read: Box::new(move |name| rf.borrow().get(name).cloned()), + write: Box::new(|_, _, _| true), + exists: Box::new(|_| true), + }); + + assert_eq!( + s.eval::(r#"return ExecuteCustomLuaFile("good.lua")"#) + .unwrap(), + 1 + ); + assert_eq!(s.eval::("return RAN").unwrap(), 1, "at global scope"); + // Twice, because an addon re-running its own config file is the normal case. + s.eval::(r#"return ExecuteCustomLuaFile("good.lua")"#) + .unwrap(); + assert_eq!(s.eval::("return RAN").unwrap(), 2); + + assert!( + s.eval::>(r#"return ExecuteCustomLuaFile("absent.lua")"#) + .unwrap() + .is_none(), + "a missing file is nil, not an error" + ); + for broken in ["broken.lua", "raises.lua"] { + assert!( + s.eval::>(&format!(r#"return ExecuteCustomLuaFile("{broken}")"#)) + .is_err(), + "{broken} must raise so a pcall sees it" + ); + } + // And the caller's own guard works, which is how an addon would really call it. + assert_eq!( + s.eval::( + r#"local ok = pcall(ExecuteCustomLuaFile, "raises.lua") + if ok then return "ran" else return "caught" end"# + ) + .unwrap(), + "caught" + ); + } + + /// `StartItemExport` writes the cache it has, tab-separated with a header — and the deviation + /// is in the name: upstream's "start" implies a server sweep, this exports what the session + /// already saw. Asserted on the file's own text, because the shape is the contract. + #[test] + fn the_item_export_writes_the_cache_it_has_and_nothing_else() { + use std::cell::RefCell; + use std::rc::Rc; + + let mut s = UiScript::new().unwrap(); + let written: Rc>> = Rc::new(RefCell::new(Vec::new())); + let w = written.clone(); + s.set_custom_files(crate::script::CustomFileAccess { + read: Box::new(|_| None), + write: Box::new(move |name, bytes, _| { + w.borrow_mut().push(( + name.to_string(), + String::from_utf8_lossy(bytes).into_owned(), + )); + true + }), + exists: Box::new(|_| false), + }); + + // An empty cache still writes: a header and nothing else, which is a file a reader can + // parse rather than an absent one they have to guess about. + assert_eq!(s.eval::("return StartItemExport()").unwrap(), 1); + assert_eq!( + written.borrow()[0], + ( + "ItemExport.txt".to_string(), + "itemId\tname\tquality\tclass\tsubclass\titemLevel\n".to_string() + ) + ); + + // Two items, ascending by id whatever order they were cached in, and a name carrying a + // tab is flattened rather than breaking the row. + for (id, name, quality, level) in [ + (13_444u32, "Major Fire Protection Potion", 1u32, 55u32), + (2_488, "Sword\tOf Tabs", 2, 20), + ] { + s.set_item_template( + id, + crate::script::ItemTemplateView { + name: name.into(), + quality, + item_level: level, + class: 4, + subclass: 1, + ..Default::default() + }, + ); + } + s.eval::("return StartItemExport()").unwrap(); + let text = written.borrow().last().unwrap().1.clone(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!( + lines, + vec![ + "itemId\tname\tquality\tclass\tsubclass\titemLevel", + "2488\tSword Of Tabs\t2\t4\t1\t20", + "13444\tMajor Fire Protection Potion\t1\t4\t1\t55", + ], + "ascending id, and the tab in a name became a space" + ); + assert!( + lines.iter().all(|l| l.split('\t').count() == 6), + "every row has exactly the header's column count" + ); + } + /// The five small registered verbs: the range row, the local mark, the two quest readers and /// the flush. Grouped in one test because each is a handful of lines and none has a consumer /// to transcribe — what is pinned is the shape, the unit and the "nothing to say" answer. diff --git a/crates/benilla-ui/src/script/encoding.rs b/crates/benilla-ui/src/script/encoding.rs new file mode 100644 index 00000000..512a71b2 --- /dev/null +++ b/crates/benilla-ui/src/script/encoding.rs @@ -0,0 +1,146 @@ +//! Base64, written here because nothing in the tree had it and two surfaces want it: nampower's +//! `EncryptPassword`, whose argument name is literally `encryptedPasswordBase64String`, and +//! ClassicAPI's `C_EncodingUtil.EncodeBase64`/`DecodeBase64`. +//! +//! Standard alphabet with `=` padding (RFC 4648 §4), which is what both of those mean by base64 — +//! not the URL-safe variant, and not the unpadded form. Decoding is strict about length and +//! alphabet but **tolerant of embedded whitespace**, because a blob that has been through a +//! SavedVariables file, a chat line or a forum post has usually been wrapped. + +/// RFC 4648 §4's alphabet, in value order. +const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Encode `bytes`, padded to a multiple of four with `=`. +pub fn base64_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + // The three input bytes as one 24-bit group, missing bytes reading zero. + let b = [ + u32::from(chunk[0]), + u32::from(chunk.get(1).copied().unwrap_or(0)), + u32::from(chunk.get(2).copied().unwrap_or(0)), + ]; + let group = b[0] << 16 | b[1] << 8 | b[2]; + for i in 0..4 { + // A sextet that exists only because of a zero-filled byte is padding, not data. + if i <= chunk.len() { + out.push(char::from( + ALPHABET[(group >> (18 - 6 * i) & 0x3f) as usize], + )); + } else { + out.push('='); + } + } + } + out +} + +/// One character's value, or `None` for anything outside the alphabet. +fn value(c: u8) -> Option { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return None, + }; + Some(u32::from(v)) +} + +/// Decode `text`, or `None` for anything that is not a well-formed padded base64 string. +/// +/// Whitespace anywhere is skipped first (see the module note). After that the body must be a +/// multiple of four with at most two trailing `=`, every other character in the alphabet, and the +/// unused low bits of a padded group zero — a non-zero tail means the string was truncated or +/// re-encoded, and accepting it would silently drop data. +pub fn base64_decode(text: &str) -> Option> { + let body: Vec = text.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + if !body.len().is_multiple_of(4) { + return None; + } + let pad = body.iter().rev().take_while(|&&b| b == b'=').count(); + if pad > 2 { + return None; + } + let data = &body[..body.len() - pad]; + let mut out = Vec::with_capacity(data.len() / 4 * 3); + for chunk in data.chunks(4) { + let mut group = 0u32; + for (i, &c) in chunk.iter().enumerate() { + group |= value(c)? << (18 - 6 * i); + } + // One sextet alone encodes no whole byte: `AAAA=` style groups of 1 are malformed. + let bytes = match chunk.len() { + 4 => 3, + 3 => 2, + 2 => 1, + _ => return None, + }; + for i in 0..bytes { + out.push((group >> (16 - 8 * i)) as u8); + } + // The bits below the last whole byte must be zero in a canonical encoding. + let used = 8 * bytes; + if used < 24 && group & ((1 << (24 - used)) - 1) != 0 { + return None; + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// RFC 4648 §10's own vectors, which is the point of using them: they pin the padding at + /// every remainder, and padding is where a hand-written codec goes wrong. + #[test] + fn the_rfc_vectors_round_trip() { + for (plain, encoded) in [ + ("", ""), + ("f", "Zg=="), + ("fo", "Zm8="), + ("foo", "Zm9v"), + ("foob", "Zm9vYg=="), + ("fooba", "Zm9vYmE="), + ("foobar", "Zm9vYmFy"), + ] { + assert_eq!(base64_encode(plain.as_bytes()), encoded, "encode {plain:?}"); + assert_eq!( + base64_decode(encoded).as_deref(), + Some(plain.as_bytes()), + "decode {encoded:?}" + ); + } + } + + /// Every byte value, so the `+`/`/` end of the alphabet is exercised rather than assumed. + #[test] + fn all_256_bytes_round_trip() { + let all: Vec = (0..=255u8).collect(); + let text = base64_encode(&all); + assert!(text.contains('+') && text.contains('/'), "got {text}"); + assert_eq!(base64_decode(&text).as_deref(), Some(all.as_slice())); + } + + /// Whitespace is skipped, because a blob that has been through a SavedVariables file or a + /// forum post has usually been wrapped. + #[test] + fn wrapped_text_decodes_and_malformed_text_does_not() { + assert_eq!( + base64_decode("Zm9v\n YmFy").as_deref(), + Some(&b"foobar"[..]) + ); + for bad in [ + "Zm9vYmF", // not a multiple of four + "Zm9v!mFy", // outside the alphabet + "Zg===", // three pads + "Zm9vYg=A", // data after the pad + "Zh==", // a non-zero tail under the padding: truncated or re-encoded + "Zm9vY===", // one data sextet in the group encodes no whole byte + ] { + assert!(base64_decode(bad).is_none(), "{bad:?} must not decode"); + } + } +} diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 2d125b02..9d3a376a 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -116,6 +116,7 @@ mod modelframe; mod net_stats; pub(crate) mod object; pub use object::frame_kind_from_tag; +pub mod encoding; mod party; mod pet; mod petition; @@ -241,15 +242,16 @@ pub use model::{ BlipOverrideKey, BlipOverrideRequest, ClassInfo, CreatureModelProbe, CreatureModelView, RaceInfo, }; +pub use model::{ + CustomFileAccess, CustomFileMode, PasswordEncryptProbe, PlayerCacheEntry, SelfCast, + SelfMovement, ServerClock, SpellModifierProbe, SpellRangeProbe, SpellRecFieldProbe, + SpellRecValue, SpellRecordProbe, +}; pub use model::{ FontProbe, ItemIconProbe, SpellDbcExtras, SpellIconProbe, SpellLookup, SpellMechanicProbe, SpellProbe, SpellUsable, TextureProbe, TextureSizeProbe, }; pub(crate) use model::{Model, PendingTimer}; -pub use model::{ - PlayerCacheEntry, SelfCast, SelfMovement, ServerClock, SpellModifierProbe, SpellRangeProbe, - SpellRecFieldProbe, SpellRecValue, SpellRecordProbe, -}; pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo}; pub use pet::{PetActionView, PetStats}; pub use pvp::{HonorState, InspectHonorData}; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 80786960..5951a83e 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -71,6 +71,40 @@ pub type SpellProbe = Box Option>; /// through (`benilla-app`'s `cursor.rs`). `None` where the row or its icon is absent. pub type ItemIconProbe = Box Option>; +/// nampower's `EncryptPassword(password)`: the blob, or `None` with no config home to keep a key +/// file in (a hermetic capture). The scheme is `benilla-app`'s `credential.rs`. +pub type PasswordEncryptProbe = Box Option>; + +/// What `WriteCustomFile`'s optional third argument asks for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CustomFileMode { + /// The default: the file is replaced. + Truncate, + /// `"a"`/`"append"`: the content is added to whatever is there. + Append, +} + +/// nampower's custom-file family, **fenced by the app to one directory** +/// (`benilla-app`'s `local_state::custom_file_path`). +/// +/// A handle rather than direct `std::fs` calls, for the reason every other engine fact here is a +/// feed: `benilla-ui` is engine-free and touches no filesystem. It also puts the fence where it +/// belongs — the app owns the one directory these verbs may reach, and the VM side cannot widen +/// it however a `filename` is spelled. `None` on every closure's return is "refused or failed", +/// which the verbs report as nil; the app logs which it was. +pub struct CustomFileAccess { + pub read: CustomFileReader, + pub write: CustomFileWriter, + pub exists: CustomFileProbe, +} + +/// A fenced name's bytes, or `None` for a refused or absent file. +pub type CustomFileReader = Box Option>>; +/// Write (or append) to a fenced name; `false` is refused or failed. +pub type CustomFileWriter = Box bool>; +/// Whether a fenced name is a readable file. +pub type CustomFileProbe = Box bool>; + /// One `SpellRange.dbc` row by its index: `(min, max, flags)`, for nampower's /// `GetSpellRangeData(rangeIndex)`. `None` for an index no row occupies. /// @@ -838,6 +872,11 @@ pub(crate) struct Model { pub(crate) spell_rec_field_probe: Option, pub(crate) spell_record_probe: Option, pub(crate) spell_range_probe: Option, + pub(crate) custom_files: Option, + pub(crate) password_encrypt_probe: Option, + /// `EncryptedServerLogin(username, blob)` calls, drained by `benilla-app`'s + /// `ui_script::drain_credential_logins`. + pub(crate) credential_logins: Vec<(String, String)>, /// Creature display id to model path ([`CreatureModelProbe`]). pub(crate) creature_model_probe: Option, /// The pet's spellbook, a second array as in the reference (`0xb700f0`, `0xb6f098`). @@ -1743,6 +1782,9 @@ impl Model { spell_rec_field_probe: None, spell_record_probe: None, spell_range_probe: None, + custom_files: None, + password_encrypt_probe: None, + credential_logins: Vec::new(), spell_mechanic_probe: None, player_cache: HashMap::new(), player_cache_enabled: [true, true], diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index 6c4d8754..31f4d8e0 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -133,6 +133,22 @@ impl super::UiScript { /// Install nampower's `Spell.dbc` row readers, both built from the one name-to-column mapping /// in `benilla-app` (see [`super::SpellRecFieldProbe`]). + /// Install nampower's `EncryptPassword`. Absent, that verb answers nil. + pub fn set_password_encrypt_probe(&mut self, probe: super::PasswordEncryptProbe) { + self.model_mut().password_encrypt_probe = Some(probe); + } + + /// Take the `EncryptedServerLogin` calls this tick. + pub fn take_credential_logins(&mut self) -> Vec<(String, String)> { + std::mem::take(&mut self.model_mut().credential_logins) + } + + /// Install nampower's custom-file family, fenced by the caller + /// ([`super::CustomFileAccess`]). Absent, every one of those verbs answers nil. + pub fn set_custom_files(&mut self, access: super::CustomFileAccess) { + self.model_mut().custom_files = Some(access); + } + /// Install nampower's `SpellRange.dbc` reader. pub fn set_spell_range_probe(&mut self, probe: super::SpellRangeProbe) { self.model_mut().spell_range_probe = Some(probe); diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index faaa2fd7..2f52c6ea 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -115,6 +115,15 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> { "GetQuestLogQuestIds", "GetQuestDialogQuestId", "CombatLogFlush", + "ReadCustomFile", + "ImportFile", + "WriteCustomFile", + "ExportFile", + "CustomFileExists", + "ExecuteCustomLuaFile", + "StartItemExport", + "EncryptPassword", + "EncryptedServerLogin", // nampower v4's spellbook index helpers (script/compat/nampower.rs), all four of them the // fast paths DoiteAuras takes before falling back to a 200-slot `GetSpellName` scan per // lookup — it does one of those per tracked aura per condition evaluation. diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index fbde9e9f..2cde8e30 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -476,7 +476,7 @@ real when something else agrees — an addon calling it, or the mod's own docs. What it found that this file did not already have: **38 names in `nampower.dll` that grom's addons call**, on top of the families the nampower section above already tracks. Unported, and listed -rather than guessed at — `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CombatLogFlush`, +rather than guessed at — `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CombatLogFlush`, `CustomFileExists`, `EncryptPassword`, `EncryptedServerLogin`, `ExecuteCustomLuaFile`, `ExportFile`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `ChannelStopCastingNextTick`, `CombatLogFlush`, `CustomFileExists`, `EncryptPassword`, `ExecuteCustomLuaFile`, `FindPlayerItemSlot`, `GetBagItem`, `GetCastInfo`, `GetCurrentCastingInfo`, `GetItemIconTexture`, `GetItemIdCooldown`, @@ -565,13 +565,60 @@ Plus the six `NP_EnableUnitEventsX` CVars, registered stored-only. Each has its `RAW_COMBATLOG`. The script compares against `dump_globals` on a **bare** VM, so a verb installed later and an *event* (never a global at all) both read as absent. Subtract those before counting. -**Withheld, pending a decision that is not mine (7).** `ExportFile`, `ImportFile`, -`WriteCustomFile`, `ReadCustomFile`, `CustomFileExists`, `ExecuteCustomLuaFile`, `EncryptPassword` -(with `EncryptedServerLogin`). The first six give addon Lua read, write and **execute** on files -under the client folder; the last takes a password. Every one is real in nampower and used for -config export, but they are a different kind of surface from a getter, and the right default for a -client that did not have them yesterday is not to grow them silently. `CombatLogFlush` is here only -because it is in the same family — it would flush our own `WoWCombatLog.txt`, which is benign. +**The nine file and credential functions — built, on grom's explicit say-so.** They were withheld +for a round and the reason recorded ("the right default for a client that did not have them +yesterday is not to grow them silently"); he said yes to all nine. Both halves of that decision +stay here, because the reasoning is what makes the second half a decision rather than a drift. + +| Name | Where it lands | +|---|---| +| `ReadCustomFile`, `ImportFile` | one operation under two names (upstream registers both); the file's **bytes** as a Lua string, so a binary file round-trips | +| `WriteCustomFile`, `ExportFile` | `mode` `"a"`/`"append"` adds, anything else replaces; `ExportFile` has no mode and always replaces. Both answer `1` or nil, so a caller can report a failure the player can fix | +| `CustomFileExists` | `1` or nil, and `is_file` rather than `exists` — a directory of that name is not a file this family can read | +| `ExecuteCustomLuaFile` | loads and runs the chunk at global scope, named `@` so a traceback points at the player's file. A missing file is nil; a **broken** one raises, because that is the distinction worth the most on a file edited by hand | +| `StartItemExport` | writes the item cache as TSV to `ItemExport.txt`. **Deviation**: upstream's name implies a server sweep; this exports the rows the session already saw. A sweep is a few thousand `CMSG_ITEM_QUERY_SINGLE`s at the realm, which is not something a Lua call should start | +| `EncryptPassword` | the blob, from `benilla-app`'s `credential.rs` | +| `EncryptedServerLogin` | **upstream logs in; this remembers** — see below | + +**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**. The escapes a string check misses, each with its own +rule: an absolute path (`join` *replaces* the base rather than appending), a Windows prefix +(`C:\…`, `\\server\share`), a `..` nested inside a longer path, and a planted symlink — which is +the one that no amount of sanitising the string will catch. nampower writes relative to the +client's working directory, the install root, which is read-only here; a *subdirectory* of the +config folder rather than its 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", and it is not the one being granted. + +**The credential pair's deviation is forced, not chosen.** Both are registered by nampower under +"Registering *Glue* Lua functions" — the login screen's VM — and this client has **no glue Lua VM +at all**: login, character-select and character-create are Rust (`autologin.rs`'s header sets that +out at length). `EncryptPassword` is a pure function and works the same wherever it is defined. +`EncryptedServerLogin` cannot mean "submit the login form" in the only VM there is, because in it +you are already in the world. What the pair is *for* is a password on disk as a blob rather than as +itself, so it stores the credential for the next login instead: `enabled` on, the plaintext field +**cleared**, the blob kept, and `AutoLogin::effective_password` reads it back at the next refill. + +What that protects: 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** +(`benilla-config/credential.key`, mode `0600`). What it does not protect: 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. The construction is encrypt-then-MAC +with HMAC-SHA1 as the PRF for both halves, from the two primitives this tree already had; the HMAC +is tested against RFC 2202's own vectors, because a hand-written one still round-trips with itself +when it is wrong. + +**Not wire-compatible with nampower's blobs**, and it cannot be: that scheme is undocumented and a +key file generated here would not decrypt one made there whatever the cipher. A blob copied from a +real install answers nil, which the drain reports rather than storing — a stored credential that +cannot be read back is a login box that silently refills empty. + +**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; 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 in `login/mod.rs` — and that is his call to make, not a side effect of +porting two verbs. **Needs a feed this client does not have, with what it would take (2).** @@ -824,13 +871,13 @@ comm -23 /tmp/np /tmp/ours | Bucket | Count | Names | |---|---|---| -| **Built** | 45 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetBagItems`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetQuestDialogQuestId`, `GetQuestLogQuestIds`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdCooldown`, `GetSpellIdForName`, `GetSpellModifiers`, `GetSpellNameAndRankForId`, `GetSpellRangeData`, `GetSpellRec`, `GetSpellRecField`, `GetSpellSlotTypeIdForName`, `GetTrinketCooldown`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `SetLocalRaidTargetIndex`, `SetMouseoverUnit`, `UseItemIdOrName`, `UseTrinket` | -| **Withheld pending grom's say-so** | 9 | `WriteCustomFile`, `ReadCustomFile`, `CustomFileExists`, `ImportFile`, `ExportFile`, `ExecuteCustomLuaFile`, `StartItemExport` (writes a file of its own), `EncryptPassword`, `EncryptedServerLogin` | +| **Built** | 54 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetBagItems`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetQuestDialogQuestId`, `GetQuestLogQuestIds`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdCooldown`, `GetSpellIdForName`, `GetSpellModifiers`, `GetSpellNameAndRankForId`, `GetSpellRangeData`, `GetSpellRec`, `GetSpellRecField`, `ImportFile`, `ReadCustomFile`, `StartItemExport`, `WriteCustomFile`, `GetSpellSlotTypeIdForName`, `GetTrinketCooldown`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `SetLocalRaidTargetIndex`, `SetMouseoverUnit`, `UseItemIdOrName`, `UseTrinket` | + | **Not ours to add** | 4 | `QueueSpellByName`, `QueueScript`, `ChannelStopCastingNextTick`, `LearnTalentRank` | | **Measured out, with the reason at each** | 4 | `DisenchantAll` (queue machinery — see the table above), `GetUnitData`, `GetRaidTargets`, `GetSpellPower` | -45 + 9 + 4 + 4 = 62, and the `comm -23` above is the check: run it and the seventeen it prints are -exactly the last three rows. Nothing is in two buckets and nothing is in none. +54 + 4 + 4 = 62, and the `comm -23` above is the check: run it and the eight it prints are exactly +the last two rows. Nothing is in two buckets and nothing is in none. The two that were at the head of that last bucket are now built — see "The cast feed" above for why they came first and what each decision cost.