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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ mod combat_text;
|
||||
mod console;
|
||||
mod crash;
|
||||
mod creature_anim;
|
||||
mod credential;
|
||||
mod cursor;
|
||||
mod cvars;
|
||||
mod death;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user