The flip: FUNCTION/XFUNCTION/FUN::fun/delim_check and the module
interfaces take `const UTF8 * const fargs[]`. Double-const is
load-bearing: C++ qualification conversion needs const at both pointer
levels, so builder-side `UTF8 *[]` arrays convert implicitly — the
evaluator, the JIT marshaller, and every owner site need zero casts,
and slot reassignment inside bodies becomes a compile error for free.
The conversions: the flip landed first so the compiler enumerated every
violation; this commit is that inventory worked to zero — ~250 sites
across funceval, funceval2, functions, funmath, help, mail, session,
powers, levels, predicates, conf, walkdb, stringutil, timeutil/
date_scan (regenerated, one-line diff), exp3, and mux_main, each
classified per docs/campaign-2136-const-fargs.md's four recipes.
New idioms (functions.h): trim_space_sep_n() — non-destructive trim for
(pointer, length) consumers, so trim-then-scan sites need no copy at
all; FargVec — the argv counterpart of FargCopy for CS_ARGV handlers.
countwords() and DecodeListOfIntegers() rewritten non-destructive.
The flip deleted more than it added: #2157's fun_munge list1 copy, the
engine_com help-topic copy, fun_index's in-place NUL write, and five
const_casts (process_sex x4, sha1_helper). const_cast budget: zero
added.
Trap recorded in the brief: an old-signature definition doesn't fail
the build — it becomes a C++ overload, and the new-signature symbol
stays undefined until dlopen(RTLD_NOW). delim_check, the conn_bridge
bridges, the dbt_spike stub, and exp3::Call were all silently shadowed;
muxscript was the only host that noticed, because netmux's own net.cpp
resolved the flat-namespace lookup. After any signature flip, grep the
old spelling.
Verified: make test EXPECT_CONFIG="jit=yes" (35 passed / 0 failed) and
make test-scenario, including the new tests/scenario/sidefx_fargs.py
that live-probes the class-3 wrappers smoke never touches (pemit/
trigger/link/tel/wipe/destroy).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One standard password-hash format everywhere: mux_sha_crypt implements
the sha-crypt construction (glibc/musl/openssl-compatible, including
rounds= presence, clamping-with-clamped-value-in-output, and 16-char
salt truncation) over OS crypto primitives -- OpenSSL EVP on Unix,
Windows CNG with a reusable hash handle. mux_crypt routes $5$/$6$
through it on BOTH platforms, so a Unix-written password database
verifies on Windows and vice versa, and Unix stops depending on which
libc crypt(3) understands those formats (macOS's does not). The libc
fall-through remains only for the legacy tail (DES, _-extended, $1$).
New hashes are $6$ with an explicit rounds= from the new conf directive
password_hash_rounds (default 220000, tracking current OWASP guidance
for the SHA-512 PRF; measured ~190 ms/hash on 2022-era server
hardware). The check_pass auto-upgrade trigger becomes parameter-aware:
a $5$/$6$ hash whose stored rounds differ from policy re-encodes on the
next successful login (work-factor migration, automatic), P6H/legacy
conversion-on-login is preserved, and the implicit default
(password_methods unset) no longer rewrites SHA1-or-stronger hashes --
it used to re-hash every login and would silently downgrade $6$ to
$SHA1$ after a config reset.
tests/shacrypt (make test-shacrypt, also in test-asan) pins 14 KATs
with every golden value from `openssl passwd` as an external oracle,
including both published spec vectors. Verified on Windows: 14/14 KATs,
digest KATs still 17/17, smoke ALL 1601 PASSED / 0 failed, and a live
netmux end-to-end: @pcreate under sha512 policy stores $6$, a $SHA1$
player auto-upgrades on login, a rounds=5000 hash refreshes to 220000 on
login, and every Windows/CNG-generated hash re-derives byte-identically
with `openssl passwd -6`.
Stacked on #1963's CNG backend (same OS-primitives posture and
bcrypt.lib link).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 3 coverage slice. Player and staff notifies that still used T()
(cast-only) now use M_() so they extract into the catalogue:
object name taken/silly, deposit refund, @chown summary, parent/zone/
home/dropto clears, floating room
flags Flags: header, type/flag parse errors, @set decompile, flag
name removed
boolexp match “don’t see / which”
wiz password changed by %s
player @protect all listing line
Left alone: log messages, HTML, softcode/machine tokens, concatenated
fragments (set @chown owner line, rob give pieces), and punctuation
separators. pot 704 -> 722; xx filled; ko msgmerge only.
Five call sites computed English plural morphology and passed it as a %s
argument:
tprintf(M_("%d connection%s closed."), count, (count == 1 ? "" : "s"))
That shape can only ever express English. A translator gets a %s slot
with nothing to put in it: dropping the argument makes msgfmt -c reject
the entry and desynchronises the varargs cursor; keeping it emits a stray
Latin "s" whenever count != 1. Korean has no plural inflection at all,
Russian and Polish have three forms, Arabic six.
MN_(singular, plural, n) sends the COUNT to the catalogue instead, and
the catalogue's own Plural-Forms rule chooses. Three distinct msgids
across five sites.
## The MSVC catalogue path deliberately does not translate plurals
It returns English. Choosing a form means parsing Plural-Forms out of
the .mo header and evaluating the expression, and that reader does
neither. Guessing form 0 would be right for the nplurals=1 languages and
quietly wrong for every other -- Russian would get the singular for 2..4.
English is wrong in a way a translator can see and report; a wrong
Russian form is not. The real gettext path, which every platform with
libintl takes, does it properly.
## Two guards needed teaching, and both had real bugs
check_nls.py treated msgid_plural as a CONTINUATION of msgid, welding the
two originals into one key that exists nowhere:
'stale msgid "**** %d failed connect ...****[xx] **** %d failed
connect%s ...****[xx] ****..."'
Every downstream check was then comparing against a msgid that does not
exist. It now parses msgstr[N] as a list of forms, requires every form
to carry the msgid's conversion sequence, and reports a partially
translated plural entry -- some forms filled, some empty -- which
previously counted as translated and would render blank at runtime.
check_formats.py read MN_() as a non-constant format and failed the
build. It could not simply join CONST_CAST_WRAPPERS: the third argument
is a runtime count, so the expression genuinely is not literal-only. It
now rewrites MN_(s,p,n) to (s p) -- adjacent literals, which is C++
concatenation and which _literal_only already accepts. Joined with a
space rather than a comma, because a comma is not in that check's allowed
character set and would have failed the very thing being enabled. Both
forms therefore reach the conversion scan; each is a format string in its
own right.
Verified non-vacuous: a bad conversion in the PLURAL form is caught, and
a genuinely non-literal format inside MN_() is still rejected.
## The pseudo-locale needed rebuilding, not merging
msgmerge fuzzy-matched the OLD msgids into the new plural entries, so
xx.po carried translations still containing the %s the new originals do
not have. Left alone they would have failed the conversion check the
moment the fuzzy marker came off. Rebuilt from each entry's own msgids,
and xx.po gained an explicit Plural-Forms so the next merge does not
guess.
ko.po needed no invented Korean: those three were already msgstr "",
exactly consistent with this issue's claim that they were untranslatable
in the old shape.
## Runtime proof
tests/nls/run.sh gains two cases. The existing three could not have
shown this -- both forms are translated, so a build that always returned
msgstr[0], or one where MN_() fell through to the English ternary, would
score identically:
plural: n=1 picks singular ok [xx] 1 entrance found.
plural: n>1 picks plural ok [xx] 2 entrances found.
The count is controlled rather than observed: @entrances against a
freshly dug room gives exactly 1, and one more exit gives exactly 2.
Reading it against the starter database would have made the case depend
on that database's exit topology.
make test green: 1561/1561 on all three smoke routes, format guard 1170
call sites clean, NLS guard 653 msgids clean, NLS runtime 5/5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Continue Phase 3 after create/set/wiz: mark player/staff tprintf and
safe_tprintf format templates with M_() so whole sentences (look examine
fields, say/page/whisper, login/alias/protect notices) enter the catalogue.
Left as T(): softcode decompile templates (@create/@dig/@lock…), ANSI color
attribute assemblies, pure name+message glue ("%s %s"/"%s%s"), machine
storage ("#%d", logindata, XX hash), and log_printf diagnostics.
Regenerate pot/xx/mo without empty msgstr. Format and NLS guards green.
Next notify slice while set/speech-look PRs are in review: player
lifecycle and flag chatter (~37 sites). Leave tprintf formats as T().
Half-mark pass clean. Regenerate pot/xx without fuzzy entries.
Replace ~900 typographic \xE2\x80\x.. escapes (curly quotes, en dashes)
and a few \xE2\x80\230 octal workarounds with real UTF-8 in message
strings under mux/modules and mux/src. Leave stringutil and convert
charset mapping tables as explicit byte sequences.
Completes the sweep the issue called for. mux_atol returns long, which
is 32-bit on LLP64, so every caller silently truncated on Windows. Two
of those were real defects (the truthiness family and cf_size, fixed in
the preceding commits); the rest were latent, waiting for a value large
enough to matter.
Rather than audit 290 sites for whether each can reach 2^31 today, use
the 64-bit parser everywhere and remove the class. A dbref cannot
overflow now, but nothing stops a later caller passing that same site a
timestamp or a byte count.
Pure 1:1 substitution: 285 lines changed, and every removed line
contained mux_atol while every added line contains mux_atoi64. No
control flow, no types, no behaviour beyond the wider parse.
This is a NO-OP on LP64 -- long is already 64-bit on Linux and macOS, so
the generated code there is unchanged. It only widens the parse on
Windows. Narrowing destinations are unaffected either way: `int x =
mux_atoi64(s)` truncates exactly as `int x = mux_atol(s)` did, on both
models.
Left alone: mux_atol itself in mathutil, its declaration, and three
comments that name it. Callers that genuinely want 32-bit semantics can
still ask for them; none appear to.
Verified on Windows: full solution builds clean with no new warnings,
smoke is 1418 passed / 16 failed / 0 crashes / 306 of 306 dispatched --
identical to before the sweep, with the same 16 build-configuration
failures (exp3 module not loaded, hmac/digest behind UNIX_DIGEST).
Spot checks after the change: the boolean family returns 1 for multiples
of 2^32, cf_size round-trips 3000000000 and still reads -1 as unlimited,
and arithmetic, string and list functions are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four unclaimed Pass 8 findings in four separate files, chosen to avoid the
files the open PR queue is already touching. Each premise was re-verified
against current source before fixing.
#1182 p6h_vaht_crypt OOB read (player.cpp). The guard only required
szSetting to be at least the 13-byte "$P6H$$1:sha1:" prefix, but the
timestamp is copied from a constant offset 54 bytes in (prefix + 40 hex
digits + separator). Any A_PASS carrying that prefix with a total length
of 13..53 passed the check and then ran safe_str off the end of the
attribute value. Reachable because mux_crypt classifies anything starting
"$P6H$" (and not "XX") as CRYPT_P6H_VAHT, so a truncated or corrupt A_PASS
-- raw attribute write or a damaged import, not @password -- reaches it on
the login path. Require the whole fixed layout including the separator,
and fail closed to szFail.
#1184 CONNECTED leak via decode_flags (flags.cpp). has_flag() and
flag_description() both hide the 'c' letter on Hidden(target) &&
!See_Hidden(player). decode_flags() required (WIZARD | DARK) together, so
every dark non-wizard -- royalty, staff, any mortal able to set itself DARK
-- still emitted 'c' to examiners. Hidden(x) is exactly (Flags(x) & DARK),
and decode_flags takes a FLAGSET rather than a dbref, so the aligned test
is DARK on the caller's flagset (unparse_object passes the target's).
#1186 moniker injection in look_contents (look.cpp). look_exits()
html_escape()s exit names inside xch_cmd="...", and the anchor text in
look_contents() was already escaped, but CONTENTS_LOCAL and CONTENTS_NESTED
inserted Moniker() raw into the attribute. A moniker containing a double
quote closed the attribute early and let the rest become further
Pueblo/HTML markup for HTML-capable clients. CONTENTS_REMOTE was already
safe (it emits #dbref).
#1187 page_check charged before validating (speech.cpp). payfor() ran
first; the not-connected and both A_LPAGE lock failures then returned false
with no refund, so a page that was never delivered still cost page_cost --
once per recipient, since do_page() calls page_check() per target.
Reordered to validate first and charge last; payfor() deducts only on
success, so no refund path is needed. The wizard "can't return your page"
warning is now held until after payment, so a sender who cannot afford the
page is not told about one that never happens.
Behaviour change worth noting: when a sender both lacks funds and the
target is offline, the message is now "Sorry, X is not connected." rather
than "You don't have enough coins." -- the actual reason rather than the
one that happened to be checked first.
Adds tests/scenario/page_cost.py (wired into run.sh) for #1187, the only
one of the four reachable without an HTML client or manufactured
connection state. It needs a mortal sender, since payfor() exempts
wizards outright and a Wizard-only test would pass against any
implementation.
Test validated against the unfixed build: cases 2 and 4 fail there
(offline page charged 5; two offline recipients charged 10, showing the
per-recipient amplification) while case 3 passes in both -- so it pins the
charge, not merely the absence of one.
The other three are not smoke-reachable: #1182 needs a crafted A_PASS,
#1184 needs live CONNECTED state on a dark player, #1186 needs an HTML
client. Their normal paths are covered -- every scenario driver logs in
through mux_crypt, and all four suites pass.
Verified: build clean, smoke 1404/1404 0 crashes, scenario 4/4 drivers
(wild_capture, site_threshold, jit_perms, telnet_negotiation) plus the new
page_cost 4/4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the crash-loop fix. decrypt_logindata now tolerates a truncated
A_LOGINDATA, but the write-side source of the truncation (seen live as "#794")
is still unknown. Log a breadcrumb identifying the player whenever record_login
reads a non-empty value with fewer than the 2*NUM_GOOD + 2*NUM_BAD + 3 (== 17)
';'-separated fields a well-formed value has, so the intermittent corruption
leaves a trail. No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Found via three live netmux processes (one healthy, two orphans pinned at
100% CPU). Both spinners' gdb backtraces showed the same crash loop on a
player connect:
1. decrypt_logindata() parsed A_LOGINDATA with mux_atol(grabto(...)) and did
not guard the nullptr that grabto() returns once the ';'-separated fields
are exhausted (e.g. a truncated "#794"). mux_atol(nullptr) dereferences
pString[0] -> SIGSEGV on connect. Add grab_field(), which returns "" instead
of nullptr, for all field reads; also keeps host/dtm pointers non-null for
the later tprintf()/notify() paths.
2. unset_signals() looped `while (pst->szSignal)` with no pst++ -- an infinite
loop. So instead of resetting handlers to core-dump and die, a caught crash
spun at 100% CPU in the signal handler forever, also defeating crash-driven
@restart recovery. Add the missing pst++.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
trim_spaces() returns a caller-owned lbuf that create_player freed on
four separate exit paths (three early returns + the main path). Adopt
it into an LBuf so the three early returns free automatically, and keep
pbuf.reset() at the original main-path free point to preserve the exact
early-free timing before the tail work.
Verified behaviorally identical by driving @pcreate: valid create
succeeds, bad-password and duplicate-name early returns decline cleanly
with no crash, and a valid create after a failed one still works (pool
intact across the early-return frees). Full smoke 1281/1281.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
check_pass() did strcmp(mux_crypt(pPassword, pTarget, &iType), pTarget) with no
null check. mux_crypt() can return NULL: for $1$/$5$/$6$/_… settings it falls
through to crypt(szPassword, szSetting), and POSIX crypt(3) returns NULL on a
malformed/unsupported salt setting (macOS/BSD, older glibc, some musl). So
strcmp(NULL, …) crashes the server on a login attempt — reachable by migrating a
DB whose A_PASS hashes use a method the destination libc's crypt() can't compute
(e.g. $6$ SHA-512), or via a corrupt A_PASS.
ChangePassword() already null-checks mux_crypt(); check_pass() — the security-
critical caller — did not. Null-check it and fail authentication closed when the
hash can't be computed.
Rest of the auth path audited sound (do_password requires the old password,
connect_player gates on check_pass, buffers bounded, salt PRNG seeds from
/dev/urandom). Smoke 1255/1255.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Use the new LBuf_Adopt() macro to take RAII ownership of caller-owned
pool buffers returned by atr_get, atr_pget, and atr_get_LEN. This
eliminates ~82 explicit free_lbuf calls and automatically covers
early-return paths that previously required careful manual placement
of the free.
Heaviest conversions: player.cpp (13), comsys.cpp (14), command.cpp (9).
Complex interleaved patterns (did_it charge/runout swaps, PureName
reassignment, process_cmdent loops) left manual for now.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Second wave: convert manual alloc/free pairs in cque (1), db_rw (4),
conf (5), db (2), engine_com (6), help (7), levels (2), predicates (5),
player (7), funmath (8). Eliminates ~80 explicit free_lbuf calls
including multi-exit error paths in getboolexp1 (5 frees → 0),
get_list, AnnounceConnect/Disconnect, and the eight NOEVAL function
variants (cand/cor/firstof/allof and bool variants).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Eliminate the ISOUTOFMEMORY macro that unconditionally aborted on allocation
failure. Each of the 23 call sites now handles OOM appropriately:
- Fatal sites (buffer pools, db array, anum table): mux_assert or OutOfMemory
- Recoverable sites (queue, mail, commands, guests, vattrs, config, restart,
forward lists): log the failure and return gracefully
Also fix g_dump_child_pid portability: volatile pid_t -> volatile sig_atomic_t
with explicit casts in ganl_adapter.cpp.
Close integer overflow issue as false alarm (getstring_noalloc uses a bounded
static buffer, so nBuffer+1 cannot wrap).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
player.cpp: GenerateSalt() szSalt was sized for SHA1 (19 bytes) but
SHA256/SHA512 paths wrote 20 bytes (off-by-one overflow). Use a fixed
32-byte constant that covers all formats with room to spare.
mail_mod.cpp: Multiple snprintf calls formatted LBUF-sized strings
into LBUF-sized buffers with extra format text, triggering GCC
-Wformat-truncation. Size output buffers to accommodate the formatted
result.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New switches modeled after RhostMUSH's @protect:
@protect/alias <name> — set a protected name as the player's alias.
The name must already be in the player's protected list. Replaces
any existing alias (TinyMUX supports one alias per player).
@protect/unalias <name> — remove the player's alias (must match).
@protect/all — Wizard-only. Lists all protected names across every
player in the database.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. SendGmcp: validate total frame size (3 + pkg + json + 2) fits in
LBUF_SIZE before memcpy. Softcode can pass arbitrary-length strings
via gmcp(), so this was a stack overwrite path.
2. @protect: switch from space-delimited to pipe-delimited storage.
Player names can contain spaces when name_spaces is enabled (the
default). Space-delimited storage would split "Jane Doe" into two
entries, break deletion, and falsely block unrelated names.
Pipe (|) is explicitly forbidden in player names by
ValidatePlayerName, so it's a safe delimiter.
Display output uses ", " for readability.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Four new features identified from the TinyMUSH/PennMUSH/RhostMUSH survey:
MSSP (MUD Server Status Protocol, telnet option 70):
- Server sends structured key-value data (NAME, PLAYERS, UPTIME, PORT,
CODEBASE, FAMILY) to MU* directory crawlers on IAC DO MSSP
- Stateless response via send_mssp() in telnet.cpp using g_dc config basket
- start_time_utc added to DRIVER_CONFIG for uptime calculation
GMCP (Generic MUD Communication Protocol, telnet option 201):
- Protocol negotiation: server offers WILL GMCP, tracks gmcp_enabled per DESC
- Inbound: GMCP subneg queued as synthetic "\x01GMCP" command, dispatched
to handle_gmcp() which fires A_GMCP attribute with %0=package %1=json
- Outbound: gmcp(<player>, <package>, <json>) softcode function sends
GMCP frames to all GMCP-enabled descriptors via SendGmcp COM method
- Full COM architecture: mux_IConnectionManager::SendGmcp in driver,
send_gmcp() bridge in engine, CConnectionManager impl in modules.cpp
@protect (player name reservation):
- @protect[/add] <name>, @protect/del <name>, @protect/list [<player>]
- A_PROTECTNAME attribute (234) stores space-separated protected names
- protectname_check() hooked into create_player() and do_name()
- max_name_protect config param (default 5)
benchmark(<expression>, <iterations>):
- FN_NOEVAL, CA_PUBLIC, 10000 iteration cap
- Uses clock_gettime(CLOCK_MONOTONIC) / QueryPerformanceCounter
- Returns elapsed seconds as floating point
Also fixes: add unicode_tables.c to libmux.so LIBMUX_C_SRC (resolves
pre-existing tr_tolower_sbt etc. link errors).
505/505 smoke tests passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>