app+ui: the nine file and credential verbs, fenced and keyed

Built on grom's explicit say-so, after a round where they were withheld
and the reason recorded. Both halves of that decision stay in the doc,
because the reasoning is what makes the second half a decision rather
than a drift.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-24 11:30:49 -06:00
co-authored by Claude Opus 5
parent 78e4e02cfe
commit e716709bca
15 changed files with 1599 additions and 20 deletions
+3 -1
View File
@@ -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
+33
View File
@@ -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<String>,
/// Per-realm memory, keyed by the realm's name as the realm list gives it.
pub(crate) realms: BTreeMap<String, RealmMemory>,
}
@@ -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.
+382
View File
@@ -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::<Vec<u8>>());
inner.update(msg);
let inner = inner.finalize();
let mut outer = Sha1::new();
outer.update(k.iter().map(|b| b ^ 0x5c).collect::<Vec<u8>>());
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<Vec<u8>> {
use rand::RngCore as _;
let decode = |text: &str| -> Option<Vec<u8>> {
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::<Option<Vec<u8>>>()
})
.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<String> {
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<String> {
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::<String>();
// 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);
}
}
+1
View File
@@ -44,6 +44,7 @@ mod combat_text;
mod console;
mod crash;
mod creature_anim;
mod credential;
mod cursor;
mod cvars;
mod death;
+127
View File
@@ -215,6 +215,65 @@ 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"))
@@ -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);
}
}
+6 -3
View File
@@ -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();
}
@@ -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::<i64>(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::<String>(r#"return ReadCustomFile("MyAddon/profile.txt")"#)
.unwrap(),
"kept"
);
assert_eq!(
script
.eval::<i64>(r#"return CustomFileExists("MyAddon/profile.txt")"#)
.unwrap(),
1
);
// Append, then the truncating default, both through the real file.
script
.eval::<i64>(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::<i64>(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::<Option<i64>>(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::<Option<i64>>(r#"return CustomFileExists("MyAddon")"#)
.unwrap()
.is_none());
let _ = std::fs::remove_dir_all(&tmp);
}
}
#[cfg(test)]
mod spell_modifier_probe_tests {
use super::*;
+44
View File
@@ -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<NonSendMut<benilla_ui::script::UiScript>>,
auto: Option<ResMut<crate::autologin::AutoLogin>>,
) {
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.
@@ -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<String>| {
let model = lua
.app_data_ref::<crate::script::Model>()
.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<String>, Option<String>)| {
let (Some(user), Some(blob)) = (user, blob) else {
return Ok(());
};
let mut model = lua
.app_data_mut::<crate::script::Model>()
.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<String>| {
let bytes = {
let model = lua
.app_data_ref::<crate::script::Model>()
.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<String>,
Option<mlua::String>,
Option<String>,
)| {
let (Some(file), Some(content)) = (file, content) else {
return Ok(None::<i64>);
};
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::<crate::script::Model>()
.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<String>| {
let model = lua
.app_data_ref::<crate::script::Model>()
.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 `@<filename>`, 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<String>| {
let Some(file) = file else {
return Ok(None::<i64>);
};
let bytes = {
let model = lua
.app_data_ref::<crate::script::Model>()
.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::<crate::script::Model>()
.expect("model app_data");
let mut ids: Vec<u32> = 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::<Option<mlua::Value>>(&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<RefCell<Vec<String>>>;
let log: Log = Rc::new(RefCell::new(Vec::new()));
let files: Rc<RefCell<std::collections::HashMap<String, Vec<u8>>>> = 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::<String>(r#"return ReadCustomFile("have.txt")"#)
.unwrap(),
"hello"
);
assert_eq!(
s.eval::<String>(r#"return ImportFile("have.txt")"#)
.unwrap(),
"hello",
"SuperWoW's own name for the same read"
);
assert!(s
.eval::<Option<String>>(r#"return ReadCustomFile("missing.txt")"#)
.unwrap()
.is_none());
assert!(
s.eval::<Option<String>>(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::<i64>(r#"return WriteCustomFile("note.txt", "one")"#)
.unwrap(),
1
);
s.eval::<i64>(r#"return WriteCustomFile("note.txt", "two", "a")"#)
.unwrap();
assert_eq!(
s.eval::<String>(r#"return ReadCustomFile("note.txt")"#)
.unwrap(),
"onetwo",
"append added rather than replaced"
);
s.eval::<i64>(r#"return ExportFile("note.txt", "three")"#)
.unwrap();
assert_eq!(
s.eval::<String>(r#"return ReadCustomFile("note.txt")"#)
.unwrap(),
"three",
"an export replaces — it has no mode argument"
);
assert!(
s.eval::<Option<i64>>(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<_>>(),
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::<i64>(r#"return CustomFileExists("have.txt")"#)
.unwrap(),
1
);
assert!(s
.eval::<bool>(r#"return CustomFileExists("have.txt") == 1"#)
.unwrap());
assert!(!s
.eval::<bool>(r#"return CustomFileExists("have.txt") == true"#)
.unwrap());
for absent in ["missing.txt", "refused"] {
assert!(
s.eval::<Option<i64>>(&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::<i64>(r#"return ExportFile("bin", "\000\255\001")"#)
.unwrap();
assert_eq!(
s.eval::<i64>(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<RefCell<std::collections::HashMap<String, Vec<u8>>>> =
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::<i64>(r#"return ExecuteCustomLuaFile("good.lua")"#)
.unwrap(),
1
);
assert_eq!(s.eval::<i64>("return RAN").unwrap(), 1, "at global scope");
// Twice, because an addon re-running its own config file is the normal case.
s.eval::<i64>(r#"return ExecuteCustomLuaFile("good.lua")"#)
.unwrap();
assert_eq!(s.eval::<i64>("return RAN").unwrap(), 2);
assert!(
s.eval::<Option<i64>>(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::<Option<i64>>(&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::<String>(
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<RefCell<Vec<(String, String)>>> = 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::<i64>("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::<i64>("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.
+146
View File
@@ -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<u32> {
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<Vec<u8>> {
let body: Vec<u8> = 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<u8> = (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");
}
}
}
+6 -4
View File
@@ -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};
+42
View File
@@ -71,6 +71,40 @@ pub type SpellProbe = Box<dyn Fn(u32) -> Option<SpellLookup>>;
/// through (`benilla-app`'s `cursor.rs`). `None` where the row or its icon is absent.
pub type ItemIconProbe = Box<dyn Fn(u32) -> Option<String>>;
/// 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<dyn Fn(&str) -> Option<String>>;
/// 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<dyn Fn(&str) -> Option<Vec<u8>>>;
/// Write (or append) to a fenced name; `false` is refused or failed.
pub type CustomFileWriter = Box<dyn Fn(&str, &[u8], CustomFileMode) -> bool>;
/// Whether a fenced name is a readable file.
pub type CustomFileProbe = Box<dyn Fn(&str) -> 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<SpellRecFieldProbe>,
pub(crate) spell_record_probe: Option<SpellRecordProbe>,
pub(crate) spell_range_probe: Option<SpellRangeProbe>,
pub(crate) custom_files: Option<CustomFileAccess>,
pub(crate) password_encrypt_probe: Option<PasswordEncryptProbe>,
/// `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<CreatureModelProbe>,
/// 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],
+16
View File
@@ -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);
@@ -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.
+59 -12
View File
@@ -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 `@<filename>` 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.