securecallfunction, because modern libraries upvalue it at load time

The stitched map never said which zone you were looking at — from about zoom 3.5 to 10
there is no zone name anywhere on screen, which is the band a player pans around in.
Wiring up a port file that draws them took the addon corpus's "survived a UI probe" column
from 86/87 to 85/87, and the failure was not in the new file:

    OpenMapClassic/Libs/CallbackHandler-1.0/CallbackHandler-1.0.lua:19:
    attempt to call upvalue `securecallfunction' (a nil value)

Ace3's modern `CallbackHandler` takes `local securecallfunction = securecallfunction` on
its line 10 and calls it for **every handler of every event**, so the first callback any
addon shipping a post-2022 Ace fires raises. Nothing had fired one before, because
OpenMap's whole callback surface was dead for an unrelated reason until tonight.

1.12 has no taint, so there is nothing here to make secure — but the half of the contract
that is not about taint is the half library code leans on: it is a **protected** call, so
one raising handler does not stop the registry walking the rest. `securecallfunction`,
`securecall` (which also takes a global's name), `issecure` and `forceinsecure` go beside
`seterrorhandler` in `stdlib.rs`, with failures reaching `geterrorhandler()` exactly as the
reference's own secure dispatch does, so an addon bug still lands in the log rather than
being swallowed. Back to 86/87.

`the_rest_of_the_bare_globals` asserted these ABSENT, on the reasoning that the restricted
-execution system has no 1.12 counterpart and nothing here implements it. That was half
right; the half it missed is above, so the assertion is inverted with the measurement
written beside it — the same shape `hooksecurefunc` already has one line below.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-04 21:56:17 -07:00
co-authored by Claude Opus 5
parent 65520a4a58
commit e15a415da9
4 changed files with 181 additions and 7 deletions
+36
View File
@@ -235,6 +235,42 @@ do
function geterrorhandler() return handler end
end
-- ── the taint-system globals, **beyond 1.12** ────────────────────────────────────────
-- 1.12 has no taint, so there is nothing here to make secure; what these are for is that modern
-- library code **upvalues them at load time** and then calls them on every dispatch. The case
-- that reached a player: Ace3's own `CallbackHandler-1.0` MINOR 8 takes
-- `local securecallfunction = securecallfunction` on its line 10 and calls it for every handler
-- of every event (`Dispatch`, line 19). With the global absent that upvalue is nil, so the first
-- callback any addon shipping that copy fires raises `attempt to call upvalue
-- 'securecallfunction'` — and the copy is vendored by OpenMapClassic, Ace3 addons generally, and
-- anything else carrying a post-2022 Ace.
--
-- On a client with no taint, `securecallfunction(f, ...)` is `f(...)` made **protected**: that is
-- the half of its contract that is not about taint, and the half library code depends on — one
-- handler raising must not stop the registry walking the rest. Errors go to `geterrorhandler()`,
-- as the reference's own secure dispatch does, so an addon bug still reaches the log instead of
-- being swallowed. `issecure()` answers true because nothing here can be tainted, and
-- `forceinsecure()` has nothing to do.
do
-- the varargs-preserving `pcall` idiom: Lua 5.1 cannot count return values any other way
local function pass(ok, ...)
if not ok then geterrorhandler()((...)) return end
return ...
end
function securecallfunction(func, ...)
if type(func) ~= "function" then return end
return pass(pcall(func, ...))
end
-- the older spelling, which also takes a global's NAME in place of the function
function securecall(func, ...)
if type(func) == "string" then func = _G[func] end
if type(func) ~= "function" then return end
return pass(pcall(func, ...))
end
function issecure() return true end
function forceinsecure() end
end
-- ── _G accessors (getglobal/setglobal: _G[name] get/set) ───────────────────────────────
function getglobal(name) return _G[name] end
function setglobal(name, value) _G[name] = value end
@@ -57,6 +57,18 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
// TurtleWoW's native arena-score request: a no-op here (its reply is parsed by TurtleWoW's
// client, and benilla has no arena scoreboard), so its WorldStateFrame.lua:217 cannot raise.
"RequestArenaScoreInfo",
// ── the taint system, which 1.12 does not have at all ──
// Not for taint — there is none here — but because modern library code **upvalues these
// at load time** and then calls them on every dispatch. Ace3's `CallbackHandler-1.0`
// MINOR 8 takes `local securecallfunction = securecallfunction` on its line 10 and calls
// it for every handler of every event, so without the global the first callback any addon
// shipping that copy fires raises `attempt to call upvalue 'securecallfunction'` — it took
// the addon corpus's UI-probe column from 86/87 to 85/87 the moment OpenMapClassic's own
// vendored copy fired one. See `stdlib.rs`.
"securecallfunction",
"securecall",
"issecure",
"forceinsecure",
// ── WoW API past 1.12 ──
// Only `SubmitChatInput` is called from our UI files (`ScriptLogFrame.xml`'s slash
// commands); each of the rest awaits its 1.12 equivalent, removal or a reason.
+68 -7
View File
@@ -425,13 +425,20 @@ fn the_rest_of_the_bare_globals() {
-- string.match/gmatch, and claiming otherwise misleads an addon that feature-detects.
assert(strmatch == nil and gmatch == nil and strrev == nil)
assert(strlenutf8 == nil and strcmputf8i == nil)
-- `securecall`/`issecure` stay absent: both are the modern restricted-execution system,
-- which has no 1.12 counterpart and nothing here implements. `hooksecurefunc` is NOT in
-- this list any more — it is a deliberate addon-compat addition (ClassicAPI backports it,
-- and a large share of real addons call it), listed with its reason in
-- `reference_surface.rs`'s `allowed_beyond_1_12` and built in
-- `script/compat/classic_api/hooks.rs`. See `docs/ADDON_COMPAT.md`.
assert(securecall == nil and issecure == nil)
-- `securecall`/`securecallfunction`/`issecure`/`forceinsecure` used to be asserted ABSENT
-- here, on the reasoning that the restricted-execution system has no 1.12 counterpart and
-- nothing here implements it. That reasoning was half right, and the half it missed cost
-- the addon corpus a column: modern library code **upvalues `securecallfunction` at load
-- time** and calls it on every dispatch — Ace3's `CallbackHandler-1.0` MINOR 8, line 10
-- and line 19 — so the first callback any addon shipping that copy fires raises
-- `attempt to call upvalue 'securecallfunction'`. There is still no taint to make secure;
-- what is implemented is the other half of the contract, a protected call reporting
-- through `geterrorhandler()`, so one raising handler does not stop a registry walking the
-- rest. Same shape as `hooksecurefunc` below: a deliberate addon-compat addition, listed
-- with its reason in `reference_surface.rs`'s `allowed_beyond_1_12`, and pinned by
-- `the_taint_globals_are_protected_calls_that_keep_walking_after_one_raises`.
assert(type(securecallfunction) == "function" and type(securecall) == "function")
assert(issecure() == true and forceinsecure() == nil)
assert(type(hooksecurefunc) == "function")
-- math family
@@ -611,3 +618,57 @@ fn a_runscript_error_reaches_the_handler_and_not_the_caller() {
"a compile failure is reported under the same name, undecorated: {errs:?}"
);
}
/// `securecallfunction` is upvalued at load time by Ace3's `CallbackHandler-1.0` MINOR 8 (its
/// line 10) and called for every handler of every event (`Dispatch`, line 19). Absent, that
/// upvalue is nil and the first callback any addon shipping that copy fires raises — measured
/// against OpenMapClassic's own vendored copy, which took the corpus's "survived a UI probe"
/// column from 86/87 to 85/87 the moment anything fired through it.
///
/// There is no taint here to make secure, so what is implemented is the other half of the
/// contract and the half library code leans on: a protected call whose failure reaches
/// `geterrorhandler()` and lets the registry walk the rest of its handlers.
#[test]
fn the_taint_globals_are_protected_calls_that_keep_walking_after_one_raises() {
let mut s = script();
s.run(
"BenillaSeen = {}
local function ok(a, b) table.insert(BenillaSeen, a .. b) return a, b, nil, 4 end
local function bad() error('handler blew up') end
BenillaR = { securecallfunction(ok, 'x', 'y') }
securecallfunction(bad)
securecallfunction(ok, 'p', 'q')
-- the older spelling also takes a global's NAME
BenillaNamed = 0
function BenillaByName(n) BenillaNamed = n end
securecall('BenillaByName', 7)
securecall(ok, 'm', 'n')
-- neither spelling may raise on a non-function
securecallfunction(nil)
securecall('BenillaNoSuchGlobal')
BenillaSecure = issecure()
forceinsecure()",
)
.expect("a raising handler must not propagate to the caller");
assert_eq!(
s.eval::<Vec<String>>("return BenillaSeen").unwrap(),
vec!["xy", "pq", "mn"],
"the call after the raising one still ran, which is the whole point"
);
// Return values pass through, trailing nils and all — `CallbackHandler` discards them, but a
// caller that reads one must get its own answer back.
assert_eq!(
s.eval::<(String, String)>("return BenillaR[1], BenillaR[2]")
.unwrap(),
("x".to_string(), "y".to_string()),
);
assert_eq!(s.eval::<i64>("return BenillaNamed").unwrap(), 7);
assert!(
s.eval::<bool>("return BenillaSecure").unwrap(),
"nothing on a client with no taint system can be insecure"
);
let errs = s.take_errors();
assert_eq!(errs.len(), 1, "exactly the one raise, reported: {errs:?}");
assert!(errs[0].contains("handler blew up"), "{errs:?}");
}