tinymux/mux/modules/engine/mail.cpp

6073 lines
162 KiB
C++
Raw Permalink Normal View History

/*! \file mail.cpp
* \brief In-game \@mail system.
*
* This code was taken from Kalkin's DarkZone code, which was
* originally taken from PennMUSH 1.50 p10, and has been heavily modified
* since being included in MUX.
*/
#include "copyright.h"
#include "autoconf.h"
#include "config.h"
#include "externs.h"
#include "mux_table.h"
#include <algorithm>
#include <memory>
#include <string>
#include <vector>
extern "C" {
#include "color_ops.h"
}
const UTF8 *DASH_LINE =
T(""
""
""
""
""
""
""
""
""
""
""
""
"");
const char *MAIL_LINE =
""
""
""
""
" MAIL: %s "
""
""
"";
const char *FOLDER_LINE =
""
""
""
""
""
" MAIL: Folder %d "
""
""
""
""
"";
#define SIZEOF_MALIAS 13
#define WIDTHOF_MALIASDESC 40
#define SIZEOF_MALIASDESC (WIDTHOF_MALIASDESC*2)
struct malias_t
{
int owner;
std::string name;
std::string desc;
size_t desc_width; // The visual width of the Mail Alias Description.
std::vector<dbref> list;
malias_t() : owner(NOTHING), desc_width(0) {}
};
static std::vector<std::unique_ptr<malias_t>> malias;
static std::vector<MAILBODY> mail_list;
// Per-player mail lists using STL for ownership and iteration.
static std::unordered_map<dbref, std::list<mail>> mail_storage;
// #1191 softcode gen-sync (defined with mail_fetch).
//
static void ensure_mail_softcode_sync(void);
// Small helper to reduce reinterpret_cast noise when passing std::string
// contents to UTF8*-taking APIs (UTF8 is typically unsigned char*).
static inline const UTF8* utf8(const std::string& s) {
return reinterpret_cast<const UTF8*>(s.c_str());
}
// ---------------------------------------------------------------------------
// SQLite write-through helpers for mail mutations.
// ---------------------------------------------------------------------------
#include "sqlite_backend.h"
#define SQLITE_MAIL_WRITABLE() (!mudstate.bSQLiteLoading)
static void sqlite_wt_insert_mail(struct mail *mp)
{
if (!SQLITE_MAIL_WRITABLE()) return;
CSQLiteDB &sqldb = g_pSQLiteBackend->GetDB();
mp->sqlite_id = sqldb.InsertMailHeaderReturningId(
mp->to, mp->from, mp->number,
utf8(mp->tolist), utf8(mp->time), utf8(mp->subject), mp->read);
if (mp->sqlite_id < 0)
{
Log.tinyprintf(T("mail sqlite_wt_insert_mail failed for to=#%d from=#%d body=%d" ENDLINE),
mp->to, mp->from, mp->number);
}
}
static void sqlite_wt_update_mail_flags(struct mail *mp)
{
if (!SQLITE_MAIL_WRITABLE() || mp->sqlite_id < 0) return;
CSQLiteDB &sqldb = g_pSQLiteBackend->GetDB();
if (!sqldb.UpdateMailReadFlags(mp->sqlite_id, mp->read))
{
Log.tinyprintf(T("mail sqlite_wt_update_mail_flags failed for rowid=%lld" ENDLINE),
static_cast<long long>(mp->sqlite_id));
}
}
static void sqlite_wt_delete_mail(struct mail *mp)
{
if (!SQLITE_MAIL_WRITABLE() || mp->sqlite_id < 0) return;
CSQLiteDB &sqldb = g_pSQLiteBackend->GetDB();
if (!sqldb.DeleteMailHeader(mp->sqlite_id))
{
Log.tinyprintf(T("mail sqlite_wt_delete_mail failed for rowid=%lld" ENDLINE),
static_cast<long long>(mp->sqlite_id));
}
}
static void sqlite_wt_delete_all_mail(int to_player)
{
if (!SQLITE_MAIL_WRITABLE()) return;
CSQLiteDB &sqldb = g_pSQLiteBackend->GetDB();
if (!sqldb.DeleteAllMailHeaders(to_player))
{
Log.tinyprintf(T("mail sqlite_wt_delete_all_mail failed for to=#%d" ENDLINE),
to_player);
}
}
static void sqlite_wt_mail_body(int number, const UTF8 *message)
{
if (!SQLITE_MAIL_WRITABLE()) return;
CSQLiteDB &sqldb = g_pSQLiteBackend->GetDB();
if (!sqldb.SyncMailBody(number, message))
{
Log.tinyprintf(T("mail sqlite_wt_mail_body failed for body=%d" ENDLINE), number);
}
// Keep the loader's gate current: sqlite_load_mail() only reads the
// mail tables when the mail_db_top meta exists (it sizes the body
// array), but until now only a DbConvert -m import set it -- mail
// sent in-game wrote through to the tables and was then ignored on
// the next boot (fallback to the legacy mail.db flatfile). (#783)
//
if (!sqldb.PutMeta("mail_db_top", mudstate.mail_db_top))
{
Log.tinyprintf(T("mail sqlite_wt_mail_body: mail_db_top meta update failed" ENDLINE));
}
}
static void sqlite_wt_delete_mail_body(int number)
{
if (!SQLITE_MAIL_WRITABLE()) return;
CSQLiteDB &sqldb = g_pSQLiteBackend->GetDB();
if (!sqldb.DeleteMailBody(number))
{
Log.tinyprintf(T("mail sqlite_wt_delete_mail_body failed for body=%d" ENDLINE), number);
}
}
static void sqlite_wt_sync_all_aliases(void)
{
if (!SQLITE_MAIL_WRITABLE()) return;
CSQLiteDB &sqldb = g_pSQLiteBackend->GetDB();
if (!sqldb.ClearMailAliases())
{
Log.WriteString(T("mail sqlite_wt_sync_all_aliases failed to clear aliases." ENDLINE));
return;
}
for (size_t i = 0; i < malias.size(); i++)
{
malias_t *m = malias[i].get();
LBuf members_buf = LBuf_Src("wt_sync_alias");
UTF8 *bp = members_buf;
for (size_t j = 0; j < m->list.size(); j++)
{
if (j > 0)
{
safe_chr(' ', members_buf, &bp);
}
safe_str(tprintf(T("%d"), m->list[j]), members_buf, &bp);
}
*bp = '\0';
if (!sqldb.SyncMailAlias(m->owner, utf8(m->name), utf8(m->desc),
static_cast<int>(m->desc_width), members_buf))
{
Log.tinyprintf(T("mail sqlite_wt_sync_all_aliases failed for owner=#%d alias=%s" ENDLINE),
m->owner, m->name.c_str());
}
}
}
// Handling functions for the database of mail messages.
//
// mail_db_grow - We keep a database of mail text, so if we send a
// message to more than one player, we won't have to duplicate the
// text. Now implemented with std::vector + std::string.
//
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
// Largest message index we will honor. Message numbers come from the (admin-
// supplied, possibly corrupt) mail database; an absurd value would drive a
// wild index. No real mail database approaches this.
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
//
static constexpr int MAIL_DB_LIMIT = 0x04000000; // 67,108,864
// True if number is a safe index into the (currently sized) mail_list.
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
//
static inline bool mail_index_valid(int number)
{
return 0 <= number
&& number < mudstate.mail_db_top;
}
static void mail_db_grow(int newtop)
{
if (newtop <= mudstate.mail_db_top)
{
return;
}
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
if (newtop > MAIL_DB_LIMIT)
{
STARTLOG(LOG_PROBLEMS, "MAIL", "SIZE");
log_printf(T("mail_db_grow: refusing absurd mail size %d."), newtop);
ENDLOG;
return;
}
// With vector, just resize. Existing entries are preserved.
// No fudge, no manual alloc/copy.
if (static_cast<int>(mail_list.size()) < newtop)
{
mail_list.resize(newtop);
}
// New entries are default-constructed (refs=0, empty string).
mudstate.mail_db_top = newtop;
}
// MessageReferenceInc - Increments the reference count for any
// particular message.
//
2007-12-23 23:00:58 -08:00
static inline void MessageReferenceInc(int number)
{
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
if (!mail_index_valid(number))
{
return;
}
mail_list[number].m_nRefs++;
}
// MessageReferenceCheck - Checks whether the reference count for
// any particular message indicates that the message body should be
// freed. Also checks that if a message pointer is null, that the
// reference count is zero.
//
static void MessageReferenceCheck(int number)
{
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
if (!mail_index_valid(number))
{
return;
}
MAILBODY &m = mail_list[number];
if (m.m_nRefs <= 0)
{
m.m_pMessage.clear();
}
if (m.m_pMessage.empty())
{
m.m_nRefs = 0;
}
}
// MessageReferenceDec - Decrements the reference count for a message, and
// will also delete the message if the counter reaches 0.
//
static void MessageReferenceDec(int number)
{
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
if (!mail_index_valid(number))
{
return;
}
mail_list[number].m_nRefs--;
if (mail_list[number].m_nRefs <= 0)
{
sqlite_wt_delete_mail_body(number);
}
MessageReferenceCheck(number);
}
// MessageFetch - returns the text for a particular message number. This
// text should not be modified.
//
const UTF8 *MessageFetch(int number)
{
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
if (!mail_index_valid(number))
{
return M_("MAIL: This mail message does not exist in the database. Please alert your admin.");
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
}
MessageReferenceCheck(number);
if (!mail_list[number].m_pMessage.empty())
{
return utf8(mail_list[number].m_pMessage);
}
else
{
return M_("MAIL: This mail message does not exist in the database. Please alert your admin.");
}
}
size_t MessageFetchSize(int number)
{
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
if (!mail_index_valid(number))
{
return 0;
}
MessageReferenceCheck(number);
return mail_list[number].m_pMessage.size();
}
// This function returns a reference to the message and the the
// reference count is increased to reflect that.
//
static int MessageAdd(UTF8 *pMessage)
{
size_t len = strlen(reinterpret_cast<char *>(pMessage));
int i;
bool bFound = false;
for (i = 0; i < mudstate.mail_db_top; i++)
{
if (mail_list[i].m_pMessage.empty())
{
mail_list[i].m_nRefs = 0;
bFound = true;
break;
}
}
if (!bFound)
{
mail_db_grow(i + 1);
}
MAILBODY &pm = mail_list[i];
pm.m_pMessage.assign(reinterpret_cast<const char *>(pMessage), len);
MessageReferenceInc(i);
sqlite_wt_mail_body(i, pMessage);
return i;
}
// add_mail_message - adds a new text message to the mail database, and returns
// a unique number for that message.
//
// IF return value is !NOTHING, you have a reference to the message,
// and the reference count reflects that.
//
static int add_mail_message(dbref player, UTF8 *message)
{
if (!mux_stricmp(message, T("clear")))
{
raw_notify(player, M_("MAIL: You probably did not intend to send a @mail saying clear."));
return NOTHING;
}
// Evaluate signature.
//
int aflags;
dbref aowner;
UTF8 *bp = alloc_lbuf("add_mail_message");
UTF8 *atrstr = atr_get("add_mail_message.216", player, A_SIGNATURE, &aowner, &aflags);
UTF8 *execstr = bp;
mux_exec(atrstr, LBUF_SIZE-1, execstr, &bp, player, player, player,
AttrTrace(aflags, EV_STRIP_CURLY|EV_FCHECK|EV_EVAL),
2018-10-03 17:54:51 +00:00
nullptr, 0);
*bp = '\0';
// Save message body and return a reference to it.
//
fix(mail): body freed on send, and a trailing byte on every message (#1587) Three defects, two of which share one root cause. ## The module freed the body it had just stored CMailMod::new_mail_message omits the creation reference the engine's MessageAdd takes -- and the engine states the contract directly above itself: "This function returns a reference to the message and the reference count is increased to reflect that." Without it the arithmetic on a send is 0 -> Inc per recipient -> Dec once at the end of mail_to_list. A single-recipient message returns to zero the instant it is sent and the body is cleared. Two recipients survived undercounted by one and would die when the first of them cleared their copy. Symptom: @mail <n> rendered the header and then answered "This mail message does not exist in the database", and @mail/stats reported "0 bytes total" -- because stats sums MessageFetchSize over bodies that had already been cleared. Fixing the reference fixed both. The reference is taken only when ALLOCATING. The engine splits these into two functions; this one does both jobs, so the distinction is captured on entry. Taking it on the load path would leak every body for the life of the process, since the per-header Inc in the storage callback already counts every reference there is. ## It was saved from real data loss by a foreign key The clear also calls sqlite_wt_delete_mail_body, which is REFUSED: mail_headers.body_number REFERENCES mail_bodies(number), and a header still pointed at it. Verified by hand: sqlite> DELETE FROM mail_bodies WHERE number=0; Error: stepping, FOREIGN KEY constraint failed (19) The module discarded that result, so nothing said so. The row survived by accident and a RESTART read the message back correctly -- which is why this looked like "mail never worked" rather than "mail loses bodies", and why it had to be caught within a single session. That delete now checks its result and logs, as the engine's equivalent already did. ## The engine added a byte to every message add_mail_message joined the signature unconditionally: MessageAdd(tprintf(T("%s %s"), message, execstr)) With no A_SIGNATURE -- most players -- execstr is empty and every body gained a trailing space. A 32-character message read back as 33 and mailinfo(...,size,...) faithfully reported the inflated length. The module already guarded this; the module was the correct one. Both now report 32. ## The regression test took three attempts, and all three failed the ## same way Recorded because the pattern matters more than the fix: 1. Asserted through mailreview(). That is fun_mailreview in functions.cpp -- an ENGINE function reading the engine's store, which was never the broken one. It would not have caught this. 2. Asserted through @mail, the right path -- but the driver's module config loaded only comsys_mod, so mail ran on the engine in BOTH configurations and proved nothing. 3. run_as asserted the Comsys implementation line but not the Mail one, which is why (2) went unnoticed. The driver now loads mail_mod and bails when EITHER line is wrong. That guard immediately caught a literal "\n" in the config heredoc on its first run, before it could produce a meaningless pass. Verified in both directions: 13 pass with the fixes; with mail_mod loaded and the fixes reverted, cases 9 and 10 fail with the module answering "does not exist in the database" -- the defect itself, not a size mismatch standing in for it. make test green: 1561/1561 on all three smoke routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:39:41 -06:00
// Join with a space only when there IS a signature (#1587). The
// unconditional "%s %s" appended a trailing space to every message sent
// by a player with no A_SIGNATURE -- which is most of them -- so a
// 32-character body read back as 33, and mailinfo(...,size,...)
// faithfully reported the inflated length. The comsys module already
// guards this; the engine did not, and the module was the correct one.
//
int number = ('\0' != execstr[0])
? MessageAdd(tprintf(T("%s %s"), message, execstr))
: MessageAdd(message);
free_lbuf(atrstr);
free_lbuf(execstr);
return number;
}
// This function is -only- used from reading from the disk, and so
// it does -not- manage the reference counts.
//
static bool MessageAddWithNumber(int i, UTF8 *pMessage)
{
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
// i comes from the (possibly corrupt) mail database. Reject negative and
// absurd values, and bail if the grow did not actually make room.
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
//
if (i < 0 || MAIL_DB_LIMIT < i)
{
return false;
}
mail_db_grow(i+1);
fix: mail loaders index mail_list[] with unchecked DB message numbers — OOB write (#843) The mail loaders read message numbers straight from the (admin-supplied, possibly corrupt) mail database and use them as mail_list[] indices with no bounds check, in both the flatfile (load_mail_V5/V6) and SQLite (sqlite_load_mail) paths: MessageReferenceInc(mp->number) does mail_list[number].m_nRefs++, and new_mail_message -> MessageAddWithNumber(number) does mail_db_grow(number+1) then mail_list[number] = … . A huge number makes mail_db_grow's allocation fail (it returns without growing) and MessageAddWithNumber then writes past the end of mail_list[] — a heap OOB write. VERIFIED: a crafted SQLite DB (mail_bodies number=2000000000 + mail_db_top meta) SIGSEGVs the unpatched build on load; the patched build loads it cleanly. Reachable via a corrupt/migrated/crafted mail DB (cf. flatfile #806, comsys #841). Fix: a mail_index_valid(n) helper (0 <= n < mail_db_top) guards every mail_list[] accessor (MessageReferenceInc/Check/Dec, MessageFetch, MessageFetchSize) — protecting the load path and any runtime fetch; MessageAddWithNumber rejects negative/absurd i (avoids i+1 overflow) and re-checks validity after the grow; mail_db_grow refuses an absurd newtop (> MAIL_DB_LIMIT=64M) to avoid int size-arithmetic overflow. The accessor guards funnel-protect all three loaders. parse_msglist ranges are mailbox filters (not mail_list indices), already safe. Smoke 1255/1255. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:47:54 -06:00
if (!mail_index_valid(i))
{
return false;
}
size_t len = strlen(reinterpret_cast<char *>(pMessage));
MAILBODY &pm = mail_list[i];
pm.m_pMessage.assign(reinterpret_cast<const char *>(pMessage), len);
return true;
}
// new_mail_message - used for reading messages in from disk which
// already have a number assigned to them.
//
// This function is -only- used from reading from the disk, and so
// it does -not- manage the reference counts.
//
static void new_mail_message(UTF8 *message, int number)
{
bool bTruncated = false;
if (strlen(reinterpret_cast<char *>(message)) > LBUF_SIZE-1)
{
bTruncated = true;
message[LBUF_SIZE-1] = '\0';
}
MessageAddWithNumber(number, message);
if (bTruncated)
{
STARTLOG(LOG_BUGS, "BUG", "MAIL");
log_printf(T("new_mail_message: Mail message %d truncated."), number);
ENDLOG;
}
}
/*-------------------------------------------------------------------------*
* User mail functions (these are called from game.c)
*
* do_mail - cases
* do_mail_read - read messages
* do_mail_list - list messages
* do_mail_flags - tagging, untagging, clearing, unclearing of messages
* do_mail_file - files messages into a new folder
* do_mail_fwd - forward messages to another player(s)
* do_mail_reply - reply to a message
* do_mail_count - count messages
* do_mail_purge - purge cleared messages
* do_mail_change_folder - change current folder
*-------------------------------------------------------------------------*/
static void set_player_folder(dbref player, int fnum)
{
// Set a player's folder to fnum.
//
UTF8 *tbuf1 = alloc_sbuf("set_player_folder");
mux_ltoa(fnum, tbuf1);
ATTR *a = atr_num(A_MAILCURF);
if (a)
{
atr_add(player, A_MAILCURF, tbuf1, GOD, a->flags);
}
else
{
// Shouldn't happen, but...
//
atr_add(player, A_MAILCURF, tbuf1, GOD, AF_ODARK | AF_WIZARD | AF_NOPROG | AF_LOCK);
}
free_sbuf(tbuf1);
}
static void add_folder_name(dbref player, int fld, UTF8 *name)
{
// Fetch current list of folders
//
int aflags;
size_t nFolders;
dbref aowner;
UTF8 *aFolders = alloc_lbuf("add_folder_name.str");
atr_get_str_LEN(aFolders, player, A_MAILFOLDERS, &aowner, &aflags,
&nFolders);
// Build new record ("%d:%s:%d", fld, uppercase(name), fld) upper-casing
// the provided folder name.
//
UTF8 *aNew = alloc_lbuf("add_folder_name.new");
mux_sprintf(aNew, LBUF_SIZE, T("%d:%s:%d"), fld, name, fld);
size_t nNew = strlen(reinterpret_cast<char *>(aNew));
{
LBuf tmp = LBuf_Src("add_folder_name");
nNew = co_toupper(tmp, aNew, nNew);
memcpy(aNew, tmp, nNew + 1);
}
UTF8 *p, *q;
if (0 != nFolders)
{
// Build pattern ("%d:", fld)
//
UTF8 *aPattern = alloc_lbuf("add_folder_name.pat");
q = aPattern;
q += mux_ltoa(fld, q);
safe_chr(':', aPattern, &q);
*q = '\0';
size_t nPattern = q - aPattern;
BMH_State bmhs;
BMH_Prepare(&bmhs, nPattern, aPattern);
for (;;)
{
size_t i;
if (!BMH_Execute(&bmhs, &i, nPattern, aPattern, nFolders, aFolders))
{
break;
}
// Remove old record.
//
q = aFolders + i;
p = q + nPattern;
// Eat leading spaces.
//
while ( aFolders < q
&& mux_isspace(q[-1]))
{
q--;
}
// Skip past old record and trailing spaces.
//
while ( *p
&& *p != ':')
{
p++;
}
while ( *p
&& !mux_isspace(*p))
{
p++;
}
while (mux_isspace(*p))
{
p++;
}
if (q != aFolders)
{
*q++ = ' ';
}
while (*p)
{
safe_chr(*p, aFolders, &q);
p++;
}
*q = '\0';
nFolders = q - aFolders;
}
free_lbuf(aPattern);
}
if (nFolders + 1 + nNew < LBUF_SIZE)
{
// It will fit. Append new record.
//
q = aFolders + nFolders;
if (nFolders)
{
*q++ = ' ';
}
memcpy(q, aNew, nNew);
q += nNew;
*q = '\0';
atr_add(player, A_MAILFOLDERS, aFolders, player,
AF_MDARK | AF_WIZARD | AF_NOPROG | AF_LOCK);
}
free_lbuf(aFolders);
free_lbuf(aNew);
}
static const UTF8 *get_folder_name(dbref player, int fld)
{
// Get the name of the folder, or return "unnamed".
//
int aflags;
size_t nFolders;
dbref aowner;
Convert all static scratch buffers to thread_local 43 static scratch-buffer arrays across 21 files (5 in mux/src, 38 in mux/modules/) changed from `static` to `thread_local`. Under the current single-threaded evaluator this is a zero-behavior-change swap — `thread_local` storage has the same lifetime and zero-allocation properties as `static` — but each thread gets its own copy, which makes these functions safe for a future multi-threaded evaluator without any locking. Read-only constant tables (`aRadix64`, `aRadixPenn36`, `aRadixPenn64`, `Empty`) left as `static` because they are immutable shared data. Affected areas: net.cpp — queue_string co_buf, trimmed_site, dump_users NameField signals.cpp — signal_desc stubslave.cpp — Stub_PipePump attrcache.cpp — sqlite_attr_buf boolexp.cpp — parsestore command.cpp — preserve_cmd, SpaceCompressCommand, LowerCaseCommand comsys.cpp — NewTitle, Buffer, temp db.cpp — tbuff, Buffer (x2) flags.cpp — buff funceval.cpp — textbuff functions.cpp — TimeBuffer64, TimeBuffer80, Buffer help.cpp — Line, Buffer mail.cpp — aFolders, Buffer, res, szFittedMailAliasDesc match.cpp — buffer player.cpp — szSalt, buf (x2), buff plusemail.cpp — buf predicates.cpp — Buf (x2), pName session.cpp — szFittedDoing set.cpp — pRestrictedKeyText unparse.cpp — buf, boolexp_buf mail_mod.cpp — result, res, buf All 21 files verified with g++ -std=c++17 -fsyntax-only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:21:14 -06:00
thread_local UTF8 aFolders[LBUF_SIZE];
atr_get_str_LEN(aFolders, player, A_MAILFOLDERS, &aowner, &aflags,
&nFolders);
if (nFolders != 0)
{
UTF8 *aPattern = alloc_lbuf("get_folder_name");
UTF8 *p = aPattern;
p += mux_ltoa(fld, p);
*p++ = ':';
*p = '\0';
size_t nPattern = p - aPattern;
size_t i;
bool bSucceeded = BMH_StringSearch(&i, nPattern, aPattern, nFolders, aFolders);
free_lbuf(aPattern);
if (bSucceeded)
{
UTF8 *pFolder = aFolders + i + nPattern;
UTF8 *q = pFolder;
while ( *q
&& *q != ':')
{
q++;
}
*q = '\0';
return pFolder;
}
}
return T("unnamed");
}
static int get_folder_number(dbref player, UTF8 *name)
{
// Look up a folder name and return the corresponding folder number.
//
int aflags;
size_t nFolders;
dbref aowner;
UTF8 *aFolders = alloc_lbuf("get_folder_num_str");
atr_get_str_LEN(aFolders, player, A_MAILFOLDERS, &aowner, &aflags,
&nFolders);
if (nFolders != 0)
{
// Convert the folder name provided into upper-case characters.
//
UTF8 *aPattern = alloc_lbuf("add_folder_num_pat");
mux_sprintf(aPattern, LBUF_SIZE, T(":%s:"), name);
size_t nPattern = strlen(reinterpret_cast<char *>(aPattern));
{
LBuf tmp = LBuf_Src("get_folder_number");
nPattern = co_toupper(tmp, aPattern, nPattern);
memcpy(aPattern, tmp, nPattern + 1);
}
size_t i;
bool bSucceeded = BMH_StringSearch(&i, nPattern, aPattern, nFolders, aFolders);
free_lbuf(aPattern);
UTF8 *p, *q;
if (bSucceeded)
{
p = aFolders + i + nPattern;
q = p;
while ( *q
&& !mux_isspace(*q))
{
q++;
}
*q = '\0';
2009-01-31 08:57:39 -08:00
// A bug in TinyMUX 2.7 (Jan 22, 2008 through Feb 1, 2009)
// generated a leading '#' in folder numbers. The following
// workaround can eventually be removed.
//
if ('#' == *p)
{
p++;
}
int64_t iFolderNumber = mux_atoi64(p);
free_lbuf(aFolders);
return iFolderNumber;
}
}
free_lbuf(aFolders);
return -1;
}
static int parse_folder(dbref player, UTF8 *folder_string)
{
// Given a string, return a folder #, or -1.
//
if ( !folder_string
|| !*folder_string)
{
return -1;
}
if (mux_isdigit(*folder_string))
{
int64_t fnum = mux_atoi64(folder_string);
if ( fnum < 0
|| fnum > MAX_FOLDERS)
{
return -1;
}
else
{
return fnum;
}
}
// Handle named folders here
//
return get_folder_number(player, folder_string);
}
#define MAIL_INVALID_RANGE 0
#define MAIL_INVALID_NUMBER 1
#define MAIL_INVALID_AGE 2
#define MAIL_INVALID_DBREF 3
#define MAIL_INVALID_PLAYER 4
#define MAIL_INVALID_SPEC 5
#define MAIL_INVALID_PLAYER_OR_USING_MALIAS 6
// Player-facing @mail errors. N_() marks for xgettext; mail_msg() looks up
// via mux_gettext at use so a static table does not need a refresh pass.
//
static const UTF8 *mailmsg[] =
{
N_("MAIL: Invalid message range"),
N_("MAIL: Invalid message number"),
N_("MAIL: Invalid age"),
N_("MAIL: Invalid dbref #"),
N_("MAIL: Invalid player"),
N_("MAIL: Invalid message specification"),
N_("MAIL: Invalid player or trying to send @mail to a @malias without a subject"),
};
static inline const UTF8 *mail_msg(int i)
{
#if defined(HAVE_NLS)
return mux_gettext(mailmsg[i]);
#else
return mailmsg[i];
#endif
}
static bool parse_msglist(UTF8 *msglist, struct mail_selector *ms, dbref player)
{
// Take a message list, and return the appropriate mail_selector setup.
// For now, msglists are quite restricted. That'll change once all this
// is working. Returns 0 if couldn't parse, and also notifies the player
// why.
// Initialize the mail selector - this matches all messages.
//
ms->low = 0;
ms->high = 0;
ms->flags = 0x0FFF | M_MSUNREAD;
ms->player = 0;
ms->days = -1;
ms->day_comp = 0;
// Now, parse the message list.
//
if (!msglist || !*msglist)
{
// All messages
//
return true;
}
UTF8 *p = msglist;
while (mux_isspace(*p))
{
p++;
}
if (*p == '\0')
{
return true;
}
if (mux_isdigit(*p))
{
// Message or range.
//
UTF8 *q = reinterpret_cast<UTF8 *>(strchr(reinterpret_cast<char *>(p), '-'));
if (q)
{
// We have a subrange, split it up and test to see if it is valid.
//
q++;
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
ms->low = mux_atoi64(p);
if (ms->low <= 0)
{
raw_notify(player, mail_msg(MAIL_INVALID_RANGE));
return false;
}
if (*q == '\0')
{
// Unbounded range.
//
ms->high = 0;
}
else
{
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
ms->high = mux_atoi64(q);
if (ms->low > ms->high)
{
raw_notify(player, mail_msg(MAIL_INVALID_RANGE));
return false;
}
}
}
else
{
// A single message.
//
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
ms->low = ms->high = mux_atoi64(p);
if (ms->low <= 0)
{
raw_notify(player, mail_msg(MAIL_INVALID_NUMBER));
return false;
}
}
}
else
{
switch (*p)
{
case '-':
// Range with no start.
//
p++;
if (*p == '\0')
{
raw_notify(player, mail_msg(MAIL_INVALID_RANGE));
return false;
}
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
ms->high = mux_atoi64(p);
if (ms->high <= 0)
{
raw_notify(player, mail_msg(MAIL_INVALID_RANGE));
return false;
}
break;
case '~':
// Exact # of days old.
//
p++;
if (*p == '\0')
{
raw_notify(player, mail_msg(MAIL_INVALID_AGE));
return false;
}
ms->day_comp = 0;
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
ms->days = mux_atoi64(p);
if (ms->days < 0)
{
raw_notify(player, mail_msg(MAIL_INVALID_AGE));
return false;
}
break;
case '<':
// Less than # of days old.
//
p++;
if (*p == '\0')
{
raw_notify(player, mail_msg(MAIL_INVALID_AGE));
return false;
}
ms->day_comp = -1;
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
ms->days = mux_atoi64(p);
if (ms->days < 0)
{
raw_notify(player, mail_msg(MAIL_INVALID_AGE));
return false;
}
break;
case '>':
// Greater than # of days old.
//
p++;
if (*p == '\0')
{
raw_notify(player, mail_msg(MAIL_INVALID_AGE));
return false;
}
ms->day_comp = 1;
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
ms->days = mux_atoi64(p);
if (ms->days < 0)
{
raw_notify(player, mail_msg(MAIL_INVALID_AGE));
return false;
}
break;
case '#':
// From db#.
//
p++;
if (*p == '\0')
{
raw_notify(player, mail_msg(MAIL_INVALID_DBREF));
return false;
}
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
ms->player = mux_atoi64(p);
if (!Good_obj(ms->player) || !(ms->player))
{
raw_notify(player, mail_msg(MAIL_INVALID_DBREF));
return false;
}
break;
case '*':
// From player name.
//
p++;
if (*p == '\0')
{
raw_notify(player, mail_msg(MAIL_INVALID_PLAYER));
return false;
}
ms->player = lookup_player(player, p, true);
if (ms->player == NOTHING)
{
raw_notify(player, mail_msg(MAIL_INVALID_PLAYER_OR_USING_MALIAS));
return false;
}
break;
case 'a':
case 'A':
// All messages, all folders
//
p++;
switch (*p)
{
case '\0':
raw_notify(player, M_("MAIL: A isnt enough (all?)"));
return false;
case 'l':
case 'L':
// All messages, all folders
//
p++;
switch (*p)
{
case '\0':
raw_notify(player, M_("MAIL: AL isnt enough (all?)"));
return false;
case 'l':
case 'L':
// All messages, all folders
//
p++;
if (*p == '\0')
{
ms->flags = M_ALL;
}
else
{
raw_notify(player, mail_msg(MAIL_INVALID_SPEC));
return false;
}
break;
default:
// Bad
//
raw_notify(player, mail_msg(MAIL_INVALID_SPEC));
return false;
}
break;
default:
// Bad
//
raw_notify(player, mail_msg(MAIL_INVALID_SPEC));
return false;
}
break;
case 'u':
case 'U':
// Urgent, Unread
//
p++;
if (*p == '\0')
{
raw_notify(player, M_("MAIL: U is ambiguous (urgent or unread?)"));
return false;
}
switch (*p)
{
case 'r':
case 'R':
// Urgent
//
ms->flags = M_URGENT;
break;
case 'n':
case 'N':
// Unread
//
ms->flags = M_MSUNREAD;
break;
default:
// Bad
//
raw_notify(player, mail_msg(MAIL_INVALID_SPEC));
return false;
}
break;
case 'r':
case 'R':
// Read
//
ms->flags = M_ISREAD;
break;
case 'c':
case 'C':
// Cleared.
//
ms->flags = M_CLEARED;
break;
case 't':
case 'T':
// Tagged.
//
ms->flags = M_TAG;
break;
case 'm':
case 'M':
// Mass, me.
//
p++;
if (*p == '\0')
{
raw_notify(player, M_("MAIL: M is ambiguous (mass or me?)"));
return false;
}
switch (*p)
{
case 'a':
case 'A':
ms->flags = M_MASS;
break;
case 'e':
case 'E':
ms->player = player;
break;
default:
raw_notify(player, mail_msg(MAIL_INVALID_SPEC));
return false;
}
break;
default:
// Bad news.
//
raw_notify(player, mail_msg(MAIL_INVALID_SPEC));
return false;
}
}
return true;
}
static int player_folder(dbref player)
{
// Return the player's current folder number. If they don't have one, set
// it to 0.
//
int flags;
UTF8 *atrstr = atr_pget(player, A_MAILCURF, &player, &flags);
if (!*atrstr)
{
free_lbuf(atrstr);
set_player_folder(player, 0);
return 0;
}
int64_t number = mux_atoi64(atrstr);
free_lbuf(atrstr);
return number;
}
// List mail stats for all current folders
//
static void DoListMailBrief(dbref player)
{
for (int folder = 0; folder < MAX_FOLDERS; folder++)
{
check_mail(player, folder, true);
// Show named but empty folders that check_mail skips.
//
int rc, uc, cc;
count_mail(player, folder, &rc, &uc, &cc);
if ( rc + uc == 0
&& cc == 0)
{
const UTF8 *fname = get_folder_name(player, folder);
if (strcmp(reinterpret_cast<const char *>(fname), "unnamed") != 0)
{
raw_notify(player, tprintf(
M_("MAIL: 0 messages in folder %d [%s]."),
folder, fname));
}
}
}
int current_folder = player_folder(player);
raw_notify(player, tprintf(M_("MAIL: Current folder is %d [%s]."),
current_folder, get_folder_name(player, current_folder)));
}
// Change or rename a folder
//
static void do_mail_change_folder(dbref player, UTF8 *fld, UTF8 *newname)
{
int pfld;
if (!fld || !*fld)
{
// Check mail in all folders
//
DoListMailBrief(player);
return;
}
pfld = parse_folder(player, fld);
if (pfld < 0)
{
raw_notify(player, M_("MAIL: What folder is that?"));
return;
}
if (newname && *newname)
{
// We're changing a folder name here
//
if (strlen(reinterpret_cast<char *>(newname)) > FOLDER_NAME_LEN)
{
raw_notify(player, M_("MAIL: Folder name too long"));
return;
}
UTF8 *p;
for (p = newname; mux_isalnum(*p); p++) ;
if (*p != '\0')
{
raw_notify(player, M_("MAIL: Illegal folder name"));
return;
}
add_folder_name(player, pfld, newname);
raw_notify(player, tprintf(M_("MAIL: Folder %d now named %s"), pfld,
newname));
}
else
{
// Set a new folder
//
set_player_folder(player, pfld);
raw_notify(player, tprintf(M_("MAIL: Current folder set to %d [%s]."),
pfld, get_folder_name(player, pfld)));
}
}
static int sign(int x)
{
if (x == 0)
{
return 0;
}
else if (x < 0)
{
return -1;
}
else
{
return 1;
}
}
static bool mail_match(struct mail *mp, struct mail_selector ms, int num)
{
// Does a piece of mail match the mail_selector?
//
if (ms.low && num < ms.low)
{
return false;
}
if (ms.high && ms.high < num)
{
return false;
}
if (ms.player && mp->from != ms.player)
{
return false;
}
mail_flag mpflag = Read(mp)
? (mp->read | M_ALL)
: (mp->read | M_ALL | M_MSUNREAD);
if ((ms.flags & mpflag) == 0)
{
return false;
}
if (ms.days == -1)
{
return true;
}
// Get the time now, subtract mp->time, and compare the results with
// ms.days (in manner of ms.day_comp)
//
CLinearTimeAbsolute ltaNow;
ltaNow.GetLocal();
const UTF8 *pMailTimeStr = utf8(mp->time);
CLinearTimeAbsolute ltaMail;
if (ltaMail.SetString(pMailTimeStr))
{
CLinearTimeDelta ltd(ltaMail, ltaNow);
int iDiffDays = ltd.ReturnDays();
if (sign(iDiffDays - ms.days) == ms.day_comp)
{
return true;
}
}
return false;
}
// Adjust the flags of a set of messages.
// If negate is true, clear the flag.
static void do_mail_flags(dbref player, UTF8 *msglist, mail_flag flag, bool negate)
{
struct mail_selector ms;
if (!parse_msglist(msglist, &ms, player))
{
return;
}
int i = 0, j = 0;
int folder = player_folder(player);
MailList ml(player);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if ( All(ms)
|| Folder(mp) == folder)
{
i++;
if (mail_match(mp, ms, i))
{
j++;
if (negate)
{
mp->read &= ~flag;
}
else
{
mp->read |= flag;
}
sqlite_wt_update_mail_flags(mp);
switch (flag)
{
case M_TAG:
raw_notify(player, tprintf(M_("MAIL: Msg #%d %s."), i,
negate ? "untagged" : "tagged"));
break;
case M_CLEARED:
if (Unread(mp) && !negate)
{
raw_notify(player, tprintf(M_("MAIL: Unread Msg #%d cleared! Use @mail/unclear %d to recover."), i, i));
}
else
{
raw_notify(player, tprintf(M_("MAIL: Msg #%d %s."), i, negate ? "uncleared" : "cleared"));
}
break;
case M_SAFE:
raw_notify(player, tprintf(M_("MAIL: Msg #%d %s."), i,
2026-03-18 12:56:16 -06:00
negate ? "marked unsafe" : "marked safe"));
break;
}
}
}
}
if (!j)
{
// Ran off the end of the list without finding anything.
//
raw_notify(player, M_("MAIL: You dont have any matching messages!"));
}
}
static void do_mail_tag(dbref player, UTF8 *msglist)
{
do_mail_flags(player, msglist, M_TAG, false);
}
static void do_mail_safe(dbref player, UTF8 *msglist)
{
do_mail_flags(player, msglist, M_SAFE, false);
}
2026-03-18 12:56:16 -06:00
static void do_mail_unsafe(dbref player, UTF8 *msglist)
{
do_mail_flags(player, msglist, M_SAFE, true);
}
void do_mail_clear(dbref player, UTF8 *msglist)
{
do_mail_flags(player, msglist, M_CLEARED, false);
}
static void do_mail_untag(dbref player, UTF8 *msglist)
{
do_mail_flags(player, msglist, M_TAG, true);
}
static void do_mail_unclear(dbref player, UTF8 *msglist)
{
do_mail_flags(player, msglist, M_CLEARED, true);
}
// Change a message's folder.
//
static void do_mail_file(dbref player, UTF8 *msglist, UTF8 *folder)
{
struct mail_selector ms;
if (!parse_msglist(msglist, &ms, player))
{
return;
}
int foldernum;
if ((foldernum = parse_folder(player, folder)) == -1)
{
raw_notify(player, M_("MAIL: Invalid folder specification"));
return;
}
int i = 0, j = 0;
int origfold = player_folder(player);
MailList ml(player);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if ( All(ms)
|| (Folder(mp) == origfold))
{
i++;
if (mail_match(mp, ms, i))
{
j++;
// Clear the folder.
//
mp->read &= M_FMASK;
mp->read |= FolderBit(foldernum);
sqlite_wt_update_mail_flags(mp);
raw_notify(player, tprintf(M_("MAIL: Msg %d filed in folder %d"), i,
foldernum));
}
}
}
if (!j)
{
// Ran off the end of the list without finding anything.
//
raw_notify(player, M_("MAIL: You dont have any matching messages!"));
}
}
// A mail alias can be any combination of upper-case letters, lower-case
// letters, and digits. No leading digits. No symbols. No ANSI. Length is
// limited to SIZEOF_MALIAS-1. Case is preserved.
//
UTF8 *MakeCanonicalMailAlias
(
feat(#2136): flip fargs to const UTF8 * const — and convert every site the compiler surfaced 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>
2026-08-06 14:34:23 -06:00
const UTF8 *pMailAlias,
size_t *pnValidMailAlias,
bool *pbValidMailAlias
)
{
Convert all static scratch buffers to thread_local 43 static scratch-buffer arrays across 21 files (5 in mux/src, 38 in mux/modules/) changed from `static` to `thread_local`. Under the current single-threaded evaluator this is a zero-behavior-change swap — `thread_local` storage has the same lifetime and zero-allocation properties as `static` — but each thread gets its own copy, which makes these functions safe for a future multi-threaded evaluator without any locking. Read-only constant tables (`aRadix64`, `aRadixPenn36`, `aRadixPenn64`, `Empty`) left as `static` because they are immutable shared data. Affected areas: net.cpp — queue_string co_buf, trimmed_site, dump_users NameField signals.cpp — signal_desc stubslave.cpp — Stub_PipePump attrcache.cpp — sqlite_attr_buf boolexp.cpp — parsestore command.cpp — preserve_cmd, SpaceCompressCommand, LowerCaseCommand comsys.cpp — NewTitle, Buffer, temp db.cpp — tbuff, Buffer (x2) flags.cpp — buff funceval.cpp — textbuff functions.cpp — TimeBuffer64, TimeBuffer80, Buffer help.cpp — Line, Buffer mail.cpp — aFolders, Buffer, res, szFittedMailAliasDesc match.cpp — buffer player.cpp — szSalt, buf (x2), buff plusemail.cpp — buf predicates.cpp — Buf (x2), pName session.cpp — szFittedDoing set.cpp — pRestrictedKeyText unparse.cpp — buf, boolexp_buf mail_mod.cpp — result, res, buf All 21 files verified with g++ -std=c++17 -fsyntax-only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:21:14 -06:00
thread_local UTF8 Buffer[SIZEOF_MALIAS];
size_t nLeft = sizeof(Buffer)-1;
UTF8 *q = Buffer;
feat(#2136): flip fargs to const UTF8 * const — and convert every site the compiler surfaced 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>
2026-08-06 14:34:23 -06:00
const UTF8 *p = pMailAlias;
if ( !p
|| !mux_isalpha(*p))
{
*pnValidMailAlias = 0;
*pbValidMailAlias = false;
2018-10-03 17:54:51 +00:00
return nullptr;
}
*q++ = *p++;
nLeft--;
while ( *p
&& nLeft)
{
if ( !mux_isalpha(*p)
&& !mux_isdigit(*p)
&& *p != '_')
{
break;
}
*q++ = *p++;
nLeft--;
}
*q = '\0';
*pnValidMailAlias = q - Buffer;
*pbValidMailAlias = true;
return Buffer;
}
#define GMA_NOTFOUND 1
#define GMA_FOUND 2
#define GMA_INVALIDFORM 3
static malias_t *get_malias(dbref player, UTF8 *alias, int *pnResult)
{
*pnResult = GMA_INVALIDFORM;
if (!alias)
{
2018-10-03 17:54:51 +00:00
return nullptr;
}
if (alias[0] == '#')
{
if (ExpMail(player))
{
int64_t x = mux_atoi64(alias + 1);
if (x < 0 || x >= static_cast<int>(malias.size()))
{
*pnResult = GMA_NOTFOUND;
2018-10-03 17:54:51 +00:00
return nullptr;
}
*pnResult = GMA_FOUND;
return malias[x].get();
}
}
else if (alias[0] == '*')
{
size_t nValidMailAlias;
bool bValidMailAlias;
UTF8 *pValidMailAlias = MakeCanonicalMailAlias(
alias + 1, &nValidMailAlias, &bValidMailAlias);
if (bValidMailAlias)
{
for (size_t i = 0; i < malias.size(); i++)
{
malias_t *m = malias[i].get();
if (m->owner == player || m->owner == GOD || ExpMail(player))
{
if (!strcmp(reinterpret_cast<char *>(pValidMailAlias), m->name.c_str()))
{
*pnResult = GMA_FOUND;
return m;
}
}
}
*pnResult = GMA_NOTFOUND;
}
}
if (*pnResult == GMA_INVALIDFORM)
{
if (ExpMail(player))
{
raw_notify(player, M_("MAIL: Mail aliases must be of the form *<name> or #<num>."));
}
else
{
raw_notify(player, M_("MAIL: Mail aliases must be of the form *<name>."));
}
}
2018-10-03 17:54:51 +00:00
return nullptr;
}
static UTF8 *make_namelist(dbref player, UTF8 *arg)
{
UNUSED_PARAMETER(player);
UTF8 *p;
UTF8 *oldarg = alloc_lbuf("make_namelist.oldarg");
UTF8 *names = alloc_lbuf("make_namelist.names");
UTF8 *bp = names;
mux_strncpy(oldarg, arg, LBUF_SIZE-1);
string_token st(oldarg, T(" "));
bool bFirst = true;
for (p = st.parse(); p; p = st.parse())
{
if (!bFirst)
{
safe_str(T(", "), names, &bp);
}
bFirst = false;
if ( mux_isdigit(p[0])
|| ( p[0] == '!'
&& mux_isdigit(p[1])))
{
UTF8 ch = p[0];
if (ch == '!')
{
p++;
}
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
dbref target = mux_atoi64(p);
if ( Good_obj(target)
&& isPlayer(target))
{
if (ch == '!')
{
safe_chr('!', names, &bp);
}
safe_str(Moniker(target), names, &bp);
}
}
else
{
safe_str(p, names, &bp);
}
}
*bp = '\0';
free_lbuf(oldarg);
return names;
}
#define NUM_MAILSTATUSTABLE 7
static struct tag_mailstatusentry
{
int nMask;
const UTF8 *pYes;
int nYes;
const UTF8 *pNo;
int nNo;
}
aMailStatusTable[NUM_MAILSTATUSTABLE] =
{
{ M_ISREAD, T("Read"), 4, T("Unread"), 6 },
{ M_CLEARED, T("Cleared"), 7, 0, 0 },
{ M_URGENT, T("Urgent"), 6, 0, 0 },
{ M_MASS, T("Mass"), 4, 0, 0 },
{ M_FORWARD, T("Fwd"), 3, 0, 0 },
{ M_TAG, T("Tagged"), 6, 0, 0 },
{ M_SAFE, T("Safe"), 4, 0, 0 }
};
static UTF8 *status_string(struct mail *mp)
{
// Return a longer description of message flags.
//
UTF8 *tbuf1 = alloc_lbuf("status_string");
UTF8 *p = tbuf1;
struct tag_mailstatusentry *mse = aMailStatusTable;
for (int i = 0; i < NUM_MAILSTATUSTABLE; i++, mse++)
{
if (mp->read & mse->nMask)
{
if (p != tbuf1) *p++ = ' ';
memcpy(p, mse->pYes, mse->nYes);
p += mse->nYes;
}
else if (mse->pNo)
{
if (p != tbuf1) *p++ = ' ';
memcpy(p, mse->pNo, mse->nNo);
p += mse->nNo;
}
}
*p++ = '\0';
return tbuf1;
}
static void do_mail_read(dbref player, UTF8 *arg1, UTF8 *arg2)
{
UTF8 *msglist;
int folder = player_folder(player);
int original_folder = folder;
2007-09-03 17:57:33 -07:00
// Check the argument list, if arg2 is present and valid, then lookup
// mail in the arg1 folder rather than the default folder.
//
2018-10-03 17:54:51 +00:00
if ( nullptr == arg2
2007-09-03 17:57:33 -07:00
|| '\0' == arg2[0])
{
msglist = arg1;
}
else
{
folder = parse_folder(player, arg1);
2007-09-03 17:57:33 -07:00
if (-1 == folder)
{
raw_notify(player, M_("MAIL: No such folder."));
return;
}
set_player_folder(player, folder);
msglist = arg2;
}
struct mail_selector ms;
if (!parse_msglist(msglist, &ms, player))
{
return;
}
UTF8 *status, *names;
int i = 0, j = 0;
UTF8 *buff = alloc_lbuf("do_mail_read.1");
MailList ml(player);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if ( Folder(mp) == folder
&& mail_to_player(player, mp))
{
i++;
if (mail_match(mp, ms, i))
{
// Read it.
//
j++;
UTF8 *bp = buff;
safe_str(MessageFetch(mp->number), buff, &bp);
*bp = '\0';
raw_notify(player, DASH_LINE);
status = status_string(mp);
names = make_namelist(player, const_cast<UTF8*>(utf8(mp->tolist)));
UTF8 szFromName[MBUF_SIZE];
trimmed_name(mp->from, szFromName, 16, 16, 0);
UTF8 szSubjectBuffer[MBUF_SIZE];
StripTabsAndTruncate(utf8(mp->subject), szSubjectBuffer, MBUF_SIZE-1, 65);
raw_notify(player, tprintf(T("%-3d From: %s At: %-25s %s\r\nFldr : %-2d Status: %s\r\nTo : %-65s\r\nSubject: %s"),
i, szFromName,
utf8(mp->time),
(Connected(mp->from) &&
(!Hidden(mp->from) || See_Hidden(player))) ?
" (Conn)" : " ", folder,
status,
names,
szSubjectBuffer));
free_lbuf(names);
free_lbuf(status);
raw_notify(player, DASH_LINE);
raw_notify(player, buff);
raw_notify(player, DASH_LINE);
if (Unread(mp))
{
// Mark message as read.
//
mp->read |= M_ISREAD;
sqlite_wt_update_mail_flags(mp);
}
}
}
}
free_lbuf(buff);
// If the folder was changed, restore the original folder setting
//
2007-09-03 17:57:33 -07:00
if (folder != original_folder)
{
set_player_folder(player, original_folder);
}
if (!j)
{
// Ran off the end of the list without finding anything.
//
raw_notify(player,
M_("MAIL: You dont have that many matching messages!"));
}
}
static void do_mail_next(dbref player)
{
int folder = player_folder(player);
int i = 0;
MailList ml(player);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if ( Folder(mp) == folder
&& mail_to_player(player, mp))
{
i++;
if (Unread(mp))
{
// Display the message using the same format as do_mail_read.
//
UTF8 *buff = alloc_lbuf("do_mail_next.1");
UTF8 *bp = buff;
safe_str(MessageFetch(mp->number), buff, &bp);
*bp = '\0';
raw_notify(player, DASH_LINE);
UTF8 *status = status_string(mp);
UTF8 *names = make_namelist(player, const_cast<UTF8*>(utf8(mp->tolist)));
UTF8 szFromName[MBUF_SIZE];
trimmed_name(mp->from, szFromName, 16, 16, 0);
UTF8 szSubjectBuffer[MBUF_SIZE];
StripTabsAndTruncate(utf8(mp->subject), szSubjectBuffer, MBUF_SIZE-1, 65);
raw_notify(player, tprintf(T("%-3d From: %s At: %-25s %s\r\nFldr : %-2d Status: %s\r\nTo : %-65s\r\nSubject: %s"),
i, szFromName,
utf8(mp->time),
(Connected(mp->from) &&
(!Hidden(mp->from) || See_Hidden(player))) ?
" (Conn)" : " ", folder,
status,
names,
szSubjectBuffer));
free_lbuf(names);
free_lbuf(status);
raw_notify(player, DASH_LINE);
raw_notify(player, buff);
raw_notify(player, DASH_LINE);
free_lbuf(buff);
// Mark message as read.
//
mp->read |= M_ISREAD;
sqlite_wt_update_mail_flags(mp);
return;
}
}
}
raw_notify(player,
M_("MAIL: You have no unread messages in that folder."));
}
static UTF8 *status_chars(struct mail *mp)
{
// Return a short description of message flags.
//
Convert all static scratch buffers to thread_local 43 static scratch-buffer arrays across 21 files (5 in mux/src, 38 in mux/modules/) changed from `static` to `thread_local`. Under the current single-threaded evaluator this is a zero-behavior-change swap — `thread_local` storage has the same lifetime and zero-allocation properties as `static` — but each thread gets its own copy, which makes these functions safe for a future multi-threaded evaluator without any locking. Read-only constant tables (`aRadix64`, `aRadixPenn36`, `aRadixPenn64`, `Empty`) left as `static` because they are immutable shared data. Affected areas: net.cpp — queue_string co_buf, trimmed_site, dump_users NameField signals.cpp — signal_desc stubslave.cpp — Stub_PipePump attrcache.cpp — sqlite_attr_buf boolexp.cpp — parsestore command.cpp — preserve_cmd, SpaceCompressCommand, LowerCaseCommand comsys.cpp — NewTitle, Buffer, temp db.cpp — tbuff, Buffer (x2) flags.cpp — buff funceval.cpp — textbuff functions.cpp — TimeBuffer64, TimeBuffer80, Buffer help.cpp — Line, Buffer mail.cpp — aFolders, Buffer, res, szFittedMailAliasDesc match.cpp — buffer player.cpp — szSalt, buf (x2), buff plusemail.cpp — buf predicates.cpp — Buf (x2), pName session.cpp — szFittedDoing set.cpp — pRestrictedKeyText unparse.cpp — buf, boolexp_buf mail_mod.cpp — result, res, buf All 21 files verified with g++ -std=c++17 -fsyntax-only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:21:14 -06:00
thread_local UTF8 res[10];
UTF8 *p = res;
*p++ = Read(mp) ? '-' : 'N';
*p++ = M_Safe(mp) ? 'S' : '-';
*p++ = Cleared(mp) ? 'C' : '-';
*p++ = Urgent(mp) ? 'U' : '-';
*p++ = Mass(mp) ? 'M' : '-';
*p++ = Forward(mp) ? 'F' : '-';
*p++ = Tagged(mp) ? '+' : '-';
*p = '\0';
return res;
}
// Folder / review summary line (#1667 Phase 4 B3) — same shape as module
// format_mail_list_line: [flags] n (size) From: name(16) Sub:/At: tail.
// From uses display-column width via mux_table (not printf codepoints).
//
static const size_t kMailListFromCols = 16;
static const size_t kMailListSubCols = 25;
static void format_mail_list_line_sub(
UTF8 *line, size_t nLine,
const UTF8 *status, int i, size_t nSize,
const UTF8 *from, const UTF8 *subject)
{
size_t pos = 0;
pos = mux_table_append_bytes(line, nLine, pos, "[");
pos = mux_table_append_bytes(line, nLine, pos,
reinterpret_cast<const char *>(status));
pos = mux_table_append_bytes(line, nLine, pos, "] ");
if (pos < nLine)
{
mux_sprintf(line + pos, nLine - pos, T("%-3d (%4zu) From: "),
i, nSize);
pos = mux_table_pos_after(line, nLine, pos);
}
pos = mux_table_append_ljust(line, nLine, pos, from, kMailListFromCols);
pos = mux_table_append_bytes(line, nLine, pos, " Sub: ");
mux_table_append_trunc(line, nLine, pos, subject, kMailListSubCols);
}
static void format_mail_list_line_at(
UTF8 *line, size_t nLine,
const UTF8 *status, int i, size_t nSize,
const UTF8 *from, const UTF8 *time_str, const UTF8 *conn_tag)
{
size_t pos = 0;
pos = mux_table_append_bytes(line, nLine, pos, "[");
pos = mux_table_append_bytes(line, nLine, pos,
reinterpret_cast<const char *>(status));
pos = mux_table_append_bytes(line, nLine, pos, "] ");
if (pos < nLine)
{
mux_sprintf(line + pos, nLine - pos, T("%-3d (%4zu) From: "),
i, nSize);
pos = mux_table_pos_after(line, nLine, pos);
}
pos = mux_table_append_ljust(line, nLine, pos, from, kMailListFromCols);
pos = mux_table_append_bytes(line, nLine, pos, " At: ");
pos = mux_table_append_bytes(line, nLine, pos,
reinterpret_cast<const char *>(
(nullptr != time_str) ? time_str : T("")));
pos = mux_table_append_bytes(line, nLine, pos, " ");
pos = mux_table_append_bytes(line, nLine, pos,
reinterpret_cast<const char *>(
(nullptr != conn_tag) ? conn_tag : T("")));
}
// Returns true if mp was sent by the current incarnation of player.
// Guards against recycled dbref: if the mail predates the player's
// creation, a previous player held this dbref.
//
bool mail_from_player(dbref player, struct mail *mp)
{
if (mp->from != player)
{
return false;
}
const UTF8 *pCreated = atr_get_raw(player, A_CREATED);
if (nullptr == pCreated)
{
return false;
}
CLinearTimeAbsolute ltaCreated, ltaMail;
if ( ltaCreated.SetString(pCreated)
&& ltaMail.SetString(utf8(mp->time)))
{
return ltaCreated <= ltaMail;
}
return false;
}
// Returns true if mp was received by the current incarnation of player.
// Guards against recycled dbref: if the mail predates the player's
// creation, a previous player held this dbref.
//
bool mail_to_player(dbref player, struct mail *mp)
{
if (mp->to != player)
{
return false;
}
const UTF8 *pCreated = atr_get_raw(player, A_CREATED);
if (nullptr == pCreated)
{
return false;
}
CLinearTimeAbsolute ltaCreated, ltaMail;
if ( ltaCreated.SetString(pCreated)
&& ltaMail.SetString(utf8(mp->time)))
{
return ltaCreated <= ltaMail;
}
return false;
}
static void do_mail_review_all(dbref player, UTF8 *msglist)
{
struct mail *mp;
struct mail_selector ms;
int i = 0, j = 0;
UTF8 szSubjectBuffer[MBUF_SIZE];
UTF8 szFromName[MBUF_SIZE];
if ( !msglist
|| !*msglist)
{
// Summary mode: list all sent messages grouped by recipient.
//
for (auto &kv : mail_storage)
{
dbref target = kv.first;
bool bHeader = false;
MailList ml(target);
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if (mail_from_player(player, mp))
{
i++;
if (!bHeader)
{
trimmed_name(target, szFromName, 25, 25, 0);
raw_notify(player, tprintf(T(MAIL_LINE), szFromName));
bHeader = true;
}
trimmed_name(mp->from, szFromName, 16, 16, 0);
size_t nSize = MessageFetchSize(mp->number);
UTF8 line[LBUF_SIZE];
format_mail_list_line_sub(line, sizeof(line),
status_chars(mp), i, nSize, szFromName, utf8(mp->subject));
raw_notify(player, line);
}
}
}
if (0 == i)
{
raw_notify(player, M_("MAIL: You have no matching messages."));
}
else
{
raw_notify(player, DASH_LINE);
}
}
else
{
// Detail mode: show full messages matching msglist.
//
if (!parse_msglist(msglist, &ms, player))
{
return;
}
for (auto &kv : mail_storage)
{
dbref target = kv.first;
MailList ml(target);
for (mp = ml.FirstItem(); !ml.IsEnd() && !alarm_clock.alarmed; mp = ml.NextItem())
{
if (mail_from_player(player, mp))
{
i++;
if (mail_match(mp, ms, i))
{
j++;
UTF8 *status = status_string(mp);
const UTF8 *str = MessageFetch(mp->number);
trimmed_name(mp->from, szFromName, 16, 16, 0);
StripTabsAndTruncate(utf8(mp->subject), szSubjectBuffer, MBUF_SIZE-1, 65);
raw_notify(player, DASH_LINE);
raw_notify(player, tprintf(T("%-3d From: %s At: %-25s %s\r\nFldr : %-2d Status: %s\r\nSubject: %s"),
i, szFromName,
utf8(mp->time),
(Connected(mp->from) &&
(!Hidden(mp->from) || See_Hidden(player))) ?
" (Conn)" : " ", 0,
status, szSubjectBuffer));
free_lbuf(status);
raw_notify(player, DASH_LINE);
raw_notify(player, str);
raw_notify(player, DASH_LINE);
}
}
}
}
if (!j)
{
raw_notify(player,
M_("MAIL: You dont have that many matching messages!"));
}
}
}
static void do_mail_review(dbref player, UTF8 *name, UTF8 *msglist)
{
if ( name
&& '\0' != name[0]
&& 0 == mux_stricmp(name, T("all")))
{
do_mail_review_all(player, msglist);
return;
}
dbref target = lookup_player(player, name, true);
if (target == NOTHING)
{
raw_notify(player, M_("MAIL: No such player."));
return;
}
struct mail *mp;
struct mail_selector ms;
int i = 0, j = 0;
UTF8 szSubjectBuffer[MBUF_SIZE];
UTF8 szFromName[MBUF_SIZE];
if ( !msglist
|| !*msglist)
{
trimmed_name(target, szFromName, 25, 25, 0);
raw_notify(player, tprintf(T(MAIL_LINE), szFromName));
MailList ml(target);
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if (mail_from_player(player, mp))
{
i++;
trimmed_name(mp->from, szFromName, 16, 16, 0);
size_t nSize = MessageFetchSize(mp->number);
UTF8 line[LBUF_SIZE];
format_mail_list_line_sub(line, sizeof(line),
status_chars(mp), i, nSize, szFromName, utf8(mp->subject));
raw_notify(player, line);
}
}
raw_notify(player, DASH_LINE);
}
else
{
if (!parse_msglist(msglist, &ms, target))
{
return;
}
MailList ml(target);
for (mp = ml.FirstItem(); !ml.IsEnd() && !alarm_clock.alarmed; mp = ml.NextItem())
{
if (mail_from_player(player, mp))
{
i++;
if (mail_match(mp, ms, i))
{
j++;
UTF8 *status = status_string(mp);
const UTF8 *str = MessageFetch(mp->number);
trimmed_name(mp->from, szFromName, 16, 16, 0);
StripTabsAndTruncate(utf8(mp->subject), szSubjectBuffer, MBUF_SIZE-1, 65);
raw_notify(player, DASH_LINE);
raw_notify(player, tprintf(T("%-3d From: %s At: %-25s %s\r\nFldr : %-2d Status: %s\r\nSubject: %s"),
i, szFromName,
utf8(mp->time),
(Connected(mp->from) &&
(!Hidden(mp->from) || See_Hidden(player))) ?
" (Conn)" : " ", 0,
status, szSubjectBuffer));
free_lbuf(status);
raw_notify(player, DASH_LINE);
raw_notify(player, str);
raw_notify(player, DASH_LINE);
}
}
}
if (!j)
{
// Ran off the end of the list without finding anything.
//
raw_notify(player,
M_("MAIL: You dont have that many matching messages!"));
}
}
}
static UTF8 *mail_list_time(const UTF8 *the_time)
{
const UTF8 *p = the_time;
UTF8 *new0 = alloc_lbuf("mail_list_time");
UTF8 *q = new0;
if (!p || !*p)
{
*new0 = '\0';
return new0;
}
// Format of the_time is: day mon dd hh:mm:ss yyyy
// Chop out :ss
//
int i;
for (i = 0; i < 16; i++)
{
if (*p)
{
*q++ = *p++;
}
}
for (i = 0; i < 3; i++)
{
if (*p)
{
p++;
}
}
for (i = 0; i < 5; i++)
{
if (*p)
{
*q++ = *p++;
}
}
*q = '\0';
return new0;
}
static void do_mail_list(dbref player, UTF8 *arg1, UTF8 *arg2, bool sub)
{
UTF8 *msglist;
int folder = player_folder(player);
int original_folder = folder;
2007-09-03 17:57:33 -07:00
// Check the argument list, if arg2 is present and valid, then lookup
// mail in the arg1 folder rather than the default folder.
//
2018-10-03 17:54:51 +00:00
if ( nullptr == arg2
2007-09-03 17:57:33 -07:00
|| '\0' == arg2[0])
{
msglist = arg1;
}
else
{
folder = parse_folder(player, arg1);
2007-09-03 17:57:33 -07:00
if (-1 == folder)
{
raw_notify(player, M_("MAIL: No such folder."));
return;
}
set_player_folder(player, folder);
msglist = arg2;
2018-10-03 17:54:51 +00:00
if ( nullptr != msglist
2007-09-03 17:57:33 -07:00
&& '*' == msglist[0])
{
msglist[0] = '\0';
}
sub = true;
}
2007-09-03 17:57:33 -07:00
struct mail_selector ms;
if (!parse_msglist(msglist, &ms, player))
{
return;
}
int i = 0;
raw_notify(player, tprintf(T(FOLDER_LINE), folder));
MailList ml(player);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if ( Folder(mp) == folder
&& mail_to_player(player, mp))
{
i++;
if (mail_match(mp, ms, i))
{
size_t nSize = MessageFetchSize(mp->number);
UTF8 szFromName[MBUF_SIZE];
trimmed_name(mp->from, szFromName, 16, 16, 0);
UTF8 line[LBUF_SIZE];
if (sub)
{
format_mail_list_line_sub(line, sizeof(line),
status_chars(mp), i, nSize, szFromName, utf8(mp->subject));
}
else
{
UTF8 *time = mail_list_time(utf8(mp->time));
format_mail_list_line_at(line, sizeof(line),
status_chars(mp), i, nSize, szFromName, time,
(Connected(mp->from) && (!Hidden(mp->from) || See_Hidden(player)))
? T("Conn") : T(" "));
free_lbuf(time);
}
raw_notify(player, line);
}
}
}
raw_notify(player, DASH_LINE);
2007-09-03 17:57:33 -07:00
if (folder != original_folder)
{
set_player_folder(player, original_folder);
}
}
void do_mail_purge(dbref player)
{
// Go through player's mail, and remove anything marked cleared.
//
MailList ml(player);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if (Cleared(mp))
{
ml.RemoveItem();
}
}
raw_notify(player, M_("MAIL: Mailbox purged."));
}
static UTF8 *make_numlist(dbref player, UTF8 *arg, bool bBlind)
{
UTF8 *tail, spot;
malias_t *m;
dbref target;
int nRecip = 0;
dbref aRecip[(LBUF_SIZE+1)/2];
UTF8 *head = arg;
while ( head
&& *head)
{
while (*head == ' ')
{
head++;
}
tail = head;
while ( *tail
&& *tail != ' ')
{
if (*tail == '"')
{
head++;
tail++;
while ( *tail
&& *tail != '"')
{
tail++;
}
}
if (*tail)
{
tail++;
}
}
// #1197: guard like the module path — a lone '"' leaves tail == head
// and must not be decremented (would underflow before *tail write).
//
if (tail > head)
{
tail--;
if (*tail != '"')
{
tail++;
}
}
spot = *tail;
*tail = '\0';
if (*head == '*')
{
int nResult;
m = get_malias(player, head, &nResult);
if (nResult == GMA_NOTFOUND)
{
raw_notify(player,
tprintf(M_("MAIL: Alias %s does not exist."), head));
2018-10-03 17:54:51 +00:00
return nullptr;
}
else if (nResult == GMA_INVALIDFORM)
{
raw_notify(player,
tprintf(M_("MAIL: %s is a badly-formed alias."), head));
2018-10-03 17:54:51 +00:00
return nullptr;
}
harden: fix five memory-safety/DoS bugs in database load paths Audit of the DB load paths (flatfile reader, mail/malias loader, SQLite attribute bulk-load) under the malicious/corrupt-database threat model -- the same class as #834-843. Five distinct bugs, all in code the June hardening (#806/#808/#841/#843) did not reach; each is reachable only from crafted or tampered database content, not from normal gameplay. Verified by full rebuild + smoke suite (1264/1264, 0 crashes), which exercises flatfile export/import and SQLite attribute reads end to end. 1. getstring_noalloc: static buffer overflow (lib/dbutil.cpp) The escaped-string reader mis-accumulated its output-byte count: `nOutput = pOutput - p` overwrote the count from prior escape emits instead of adding to it. On the multi-fgets refill path this under-decrements nBufferLeft, so the `nBufferLeft <= 0` guard never trips and the next fgets writes past the 2*LBUF_SIZE+20 static buffer. Legitimate attributes (<= LBUF) never take the refill path, so this only fires on a crafted quoted string > ~64KB. Fix: accumulate (+=). 2. make_numlist / malias_read: stack buffer overflow (modules/engine/mail.cpp) malias_read read the recipient count (numrecep) straight from the file and pushed that many dbrefs into m->list with no cap, making m->list.size() attacker-controlled. make_numlist then copied all of m->list into the fixed stack array aRecip[(LBUF_SIZE+1)/2] with no bound on nRecip -- a crafted mail.db with numrecep > 16384 overflows the stack (with attacker-chosen dbrefs) the next time any player mails the alias. Fix: clamp numrecep to (LBUF_SIZE+1)/2 at load (also bounds the reserve() that could otherwise exhaust memory on an INT_MAX count), and defensively bound the copy loop in make_numlist. 3. SQLite bulk-load attribute value: heap buffer overflow (modules/engine/sqlitedb.cpp) GetAllAttributes/GetBuiltinAttributes passed the raw column blob length to the cache with no clamp, unlike the write path (cache_put) and the standalone read path (GetAttribute), both of which clamp to LBUF_SIZE. A value blob written directly into the SQLite file therefore flows unclamped to atr_get_str_LEN's `memcpy(s, buff, (*pLen)+1)` into a fixed LBUF_SIZE buffer, overflowing the heap on first read of the attribute (Name/look/examine/get). Fix: clamp len to LBUF_SIZE in both bulk-load functions, mirroring the existing clamps. 4. get_list: infinite loop + unbounded log on truncated flatfile (modules/engine/db_rw.cpp) get_list had no EOF case: at end-of-file getc() returns EOF, falls to default, and calls getstring_noalloc(), which makes no progress at EOF (ungetc(EOF) is a no-op, fgets returns NULL). The for(;;) then spins forever, pegging a core and emitting log lines. Trigger: a flatfile whose last object's attribute list is truncated before its '<' terminator. Fix: add a case EOF that aborts the load. 5. getboolexp1: BOOLEXP subtree leak on malformed v2 lock (modules/engine/db_rw.cpp) Three error paths in the v2 lock parser returned TRUE_BOOLEXP without freeing the partially built node/subtree (bad connective, missing ')', EOF mid lock-string). Bounded and single-shot (db_read aborts the whole load on corruption; nesting capped at 1024), but still a leak on crafted input. Fix: free the appropriate node/subtree at each site, matching the partial-construction state. Also traced and dropped a sixth candidate (SQLite attribute owner read unvalidated): the attribute owner is never used to index db[] -- only compared, read for its flags, or re-stored -- so a corrupt owner is harmless. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:52:53 -06:00
for (size_t i = 0;
i < m->list.size()
&& nRecip < static_cast<int>(sizeof(aRecip)/sizeof(aRecip[0]));
i++)
{
aRecip[nRecip++] = m->list[i];
}
}
else
{
target = lookup_player(player, head, true);
if (Good_obj(target))
{
harden: bound two remaining DB-load overflow paths Two follow-ups to a056f67d0, same malicious/corrupt-database threat model, found by review of code paths analogous to the ones fixed there. Verified by full rebuild + smoke suite (1264/1264, 0 crashes). 1. getstring_noalloc legacy path: static buffer overflow (lib/dbutil.cpp) a056f67d0 fixed the escaped-string (new_strings=true) branch, but the legacy (new_strings=false) continued-line branch advanced `p += nLine` across '\r'-terminated lines with no capacity guard against the static buf[2*LBUF_SIZE+20]. A crafted legacy string with enough continued lines marches p past the buffer and the next fgets writes out of bounds. This branch is reachable from v1/v2 flatfile import (db_rw.cpp lock/field reads pass new_strings=false). Fix: track nBufferLeft and cap each fgets request to the space remaining, mirroring the escaped branch. 2. make_numlist direct-recipient path: stack buffer overflow (modules/engine/mail.cpp) a056f67d0 bounded the *alias-expansion copy loop into aRecip[(LBUF_SIZE+1)/2] but left the direct-recipient append (aRecip[nRecip++] = target) unbounded. Because the malias_read clamp lets a single alias fill aRecip to capacity, a crafted mail.db alias followed by additional recipients in the same @mail still overflows the stack array. Fix: bound the direct-recipient write with the same cap, dropping recipients past it (matching the alias loop) rather than overflowing. Also assessed but left as-is: malias_read's `new malias_t*[getref()]` takes an unbounded count, but an oversized/overflowing count throws (bad_alloc / bad_array_new_length), is caught, and the read loop truncates at EOF -- a bounded memory-DoS with no natural clamp value, not an overwrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:31:15 -06:00
// Bound the write like the alias-copy loop above: aRecip[] is
// sized (LBUF_SIZE+1)/2, and a single clamped malias can fill
// it, so a valid recipient past the cap is dropped rather than
// overflowing the stack array.
//
if (nRecip < static_cast<int>(sizeof(aRecip)/sizeof(aRecip[0])))
{
aRecip[nRecip++] = target;
}
}
else
{
raw_notify(player, tprintf(M_("MAIL: %s does not exist."), head));
2018-10-03 17:54:51 +00:00
return nullptr;
}
}
// Get the next recip.
//
*tail = spot;
head = tail;
if (*head == '"')
{
head++;
}
}
if (nRecip <= 0)
{
raw_notify(player, M_("MAIL: No players specified."));
2018-10-03 17:54:51 +00:00
return nullptr;
}
else
{
ITL itl;
UTF8 *numbuf, *numbp;
numbp = numbuf = alloc_lbuf("mail.make_numlist");
ItemToList_Init(&itl, numbuf, &numbp, bBlind ? '!' : '\0');
int i;
for (i = 0; i < nRecip; i++)
{
if (aRecip[i] != NOTHING)
{
for (int j = i + 1; j < nRecip; j++)
{
if (aRecip[i] == aRecip[j])
{
aRecip[j] = NOTHING;
}
}
if (Good_obj(aRecip[i]))
{
ItemToList_AddInteger(&itl, aRecip[i]);
}
}
}
ItemToList_Final(&itl);
return numbuf;
}
}
static void do_expmail_start(dbref player, UTF8 *arg, UTF8 *subject)
{
if (!arg || !*arg)
{
raw_notify(player, M_("MAIL: I do not know whom you want to mail."));
return;
}
if (!subject || !*subject)
{
raw_notify(player, M_("MAIL: No subject."));
return;
}
if (Flags2(player) & PLAYER_MAILS)
{
raw_notify(player, M_("MAIL: Mail message already in progress."));
return;
}
if ( !Wizard(player)
&& ThrottleMail(player))
{
raw_notify(player, M_("MAIL: Too much @mail sent recently."));
return;
}
UTF8 *tolist = make_numlist(player, arg, false);
if (!tolist)
{
return;
}
atr_add_raw(player, A_MAILTO, tolist);
atr_add_raw(player, A_MAILSUB, subject);
atr_add_raw(player, A_MAILFLAGS, T("0"));
atr_clr(player, A_MAILMSG);
Flags2(player) |= PLAYER_MAILS;
UTF8 *names = make_namelist(player, tolist);
raw_notify(player, tprintf(M_("MAIL: You are sending mail to %s."), names));
free_lbuf(names);
free_lbuf(tolist);
}
static void do_mail_fwd(dbref player, UTF8 *msg, UTF8 *tolist)
{
if (Flags2(player) & PLAYER_MAILS)
{
raw_notify(player, M_("MAIL: Mail message already in progress."));
return;
}
if (!msg || !*msg)
{
raw_notify(player, M_("MAIL: No message list."));
return;
}
if (!tolist || !*tolist)
{
raw_notify(player, M_("MAIL: To whom should I forward?"));
return;
}
if ( !Wizard(player)
&& ThrottleMail(player))
{
raw_notify(player, M_("MAIL: Too much @mail sent recently."));
return;
}
int64_t num = mux_atoi64(msg);
if (!num)
{
raw_notify(player, M_("MAIL: I dont understand that message number."));
return;
}
struct mail *mp = mail_fetch(player, num);
if (!mp)
{
raw_notify(player, M_("MAIL: You cant forward non-existent messages."));
return;
}
do_expmail_start(player, tolist, tprintf(M_("%s (fwd from %s)"), utf8(mp->subject), Moniker(mp->from)));
atr_add_raw(player, A_MAILMSG, MessageFetch(mp->number));
const UTF8 *pValue = atr_get_raw(player, A_MAILFLAGS);
int iFlag = M_FORWARD;
if (pValue)
{
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
iFlag |= mux_atoi64(pValue);
}
atr_add_raw(player, A_MAILFLAGS, mux_ltoa_t(iFlag));
}
static void do_mail_reply(dbref player, UTF8 *msg, bool all, int key)
{
if (Flags2(player) & PLAYER_MAILS)
{
raw_notify(player, M_("MAIL: Mail message already in progress."));
return;
}
if (!msg || !*msg)
{
raw_notify(player, M_("MAIL: No message list."));
return;
}
if ( !Wizard(player)
&& ThrottleMail(player))
{
raw_notify(player, M_("MAIL: Too much @mail sent recently."));
return;
}
int64_t num = mux_atoi64(msg);
if (!num)
{
raw_notify(player, M_("MAIL: I dont understand that message number."));
return;
}
struct mail *mp = mail_fetch(player, num);
if (!mp)
{
raw_notify(player, M_("MAIL: You cant reply to non-existent messages."));
return;
}
if (!mail_from_player(mp->from, mp))
{
raw_notify(player, M_("MAIL: The original sender no longer exists."));
return;
}
UTF8 *tolist = alloc_lbuf("do_mail_reply.tolist");
UTF8 *bp = tolist;
if (all)
{
UTF8 *names = alloc_lbuf("do_mail_reply.names");
UTF8 *oldlist = alloc_lbuf("do_mail_reply.oldlist");
bp = names;
*bp = '\0';
mux_strncpy(oldlist, utf8(mp->tolist), LBUF_SIZE-1);
string_token st(oldlist, T(" "));
UTF8 *p;
for (p = st.parse(); p; p = st.parse())
{
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
if (mux_atoi64(p) != mp->from)
{
safe_chr('#', names, &bp);
safe_str(p, names, &bp);
safe_chr(' ', names, &bp);
}
}
free_lbuf(oldlist);
safe_chr('#', names, &bp);
safe_ltoa(mp->from, names, &bp);
*bp = '\0';
mux_strncpy(tolist, names, LBUF_SIZE-1);
free_lbuf(names);
}
else
{
safe_chr('#', tolist, &bp);
safe_ltoa(mp->from, tolist, &bp);
*bp = '\0';
}
const UTF8 *pSubject = utf8(mp->subject);
const UTF8 *pMessage = MessageFetch(mp->number);
const UTF8 *pTime = utf8(mp->time);
if (strncmp(reinterpret_cast<const char *>(pSubject), "Re:", 3))
{
do_expmail_start(player, tolist, tprintf(M_("Re: %s"), pSubject));
}
else
{
do_expmail_start(player, tolist, tprintf(T("%s"), pSubject));
}
if (key & MAIL_QUOTE)
{
const UTF8 *pFromName = Moniker(mp->from);
UTF8 *pMessageBody =
tprintf(M_("On %s, %s wrote:\r\n\r\n%s\r\n\r\n********** End of included message from %s\r\n"),
pTime, pFromName, pMessage, pFromName);
atr_add_raw(player, A_MAILMSG, pMessageBody);
}
// The following combination of atr_get_raw() with atr_add_raw() is OK
// because we are not passing a pointer to atr_add_raw() that came
// directly from atr_get_raw().
//
const UTF8 *pValue = atr_get_raw(player, A_MAILFLAGS);
int iFlag = M_REPLY;
if (pValue)
{
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
iFlag |= mux_atoi64(pValue);
}
atr_add_raw(player, A_MAILFLAGS, mux_ltoa_t(iFlag));
free_lbuf(tolist);
}
/*-------------------------------------------------------------------------*
* Admin mail functions
*
* do_mail_nuke - clear & purge mail for a player, or all mail in db.
* do_mail_stat - stats on mail for a player, or for all db.
* do_mail_debug - fix mail with a sledgehammer
*-------------------------------------------------------------------------*/
/*-------------------------------------------------------------------------*
* Basic mail functions
*-------------------------------------------------------------------------*/
// #1191: Softcode mail_* helpers use engine maps; re-sync from SQLite when
// the module store is ahead.
//
static void ensure_mail_softcode_sync(void)
{
if (nullptr == mudstate.pIMailControl)
{
return;
}
// Re-entrancy guard, mirroring the comsys side: the loader runs engine
// paths that can call back into us before s_seen_rev is assigned.
//
if (mudstate.bSQLiteLoading)
{
return;
}
static int s_seen_rev = -1;
int rev = 0;
MUX_RESULT mr = mudstate.pIMailControl->GetRevision(&rev);
if (MUX_FAILED(mr))
{
return;
}
if (rev == s_seen_rev)
{
return;
}
(void)sqlite_load_mail();
s_seen_rev = rev;
}
struct mail *mail_fetch(dbref player, int num)
{
ensure_mail_softcode_sync();
int i = 0;
MailList ml(player);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if ( Folder(mp) == player_folder(player)
&& mail_to_player(player, mp))
{
i++;
if (i == num)
{
return mp;
}
}
}
2018-10-03 17:54:51 +00:00
return nullptr;
}
const UTF8 *mail_fetch_message(dbref player, int num)
{
struct mail *mp = mail_fetch(player, num);
if (mp)
{
return MessageFetch(mp->number);
}
2018-10-03 17:54:51 +00:00
return nullptr;
}
int mail_fetch_from(dbref player, int num)
{
struct mail *mp = mail_fetch(player, num);
if (mp)
{
return mp->from;
}
return NOTHING;
}
// Returns count of read, unread, and cleared messages as rcount, ucount, ccount.
//
void count_mail(dbref player, int folder, int *rcount, int *ucount, int *ccount)
{
if (nullptr != mudstate.pIMailControl)
{
MUX_RESULT mr = mudstate.pIMailControl->CountMail(player, folder, rcount, ucount, ccount);
if (MUX_SUCCEEDED(mr))
{
return;
}
}
ensure_mail_softcode_sync();
int rc = 0;
int uc = 0;
int cc = 0;
MailList ml(player);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if ( Folder(mp) == folder
&& mail_to_player(player, mp))
{
if (Read(mp))
{
rc++;
}
else
{
uc++;
}
if (Cleared(mp))
{
cc++;
}
}
}
*rcount = rc;
*ucount = uc;
*ccount = cc;
}
static void urgent_mail(dbref player, int folder, int *ucount)
{
int uc = 0;
MailList ml(player);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if ( Folder(mp) == folder
&& mail_to_player(player, mp))
{
if (Unread(mp) && Urgent(mp))
{
uc++;
}
}
}
*ucount = uc;
}
static void mail_return(dbref player, dbref target)
{
dbref aowner;
int aflags;
UTF8 *str = atr_pget(target, A_MFAIL, &aowner, &aflags);
if (*str)
{
UTF8 *str2, *bp;
str2 = bp = alloc_lbuf("mail_return");
mux_exec(str, LBUF_SIZE-1, str2, &bp, target, player, player,
AttrTrace(aflags, EV_FCHECK|EV_EVAL|EV_TOP|EV_NO_LOCATION),
2018-10-03 17:54:51 +00:00
nullptr, 0);
*bp = '\0';
if (*str2)
{
CLinearTimeAbsolute ltaNow;
ltaNow.GetLocal();
FIELDEDTIME ft;
ltaNow.ReturnFields(&ft);
raw_notify(player, tprintf(M_("MAIL: Reject message from %s: %s"),
Moniker(target), str2));
raw_notify(target, tprintf(M_("[%d:%02d] MAIL: Reject message sent to %s."),
ft.iHour, ft.iMinute, Moniker(player)));
}
free_lbuf(str2);
}
else
{
raw_notify(player, tprintf(M_("Sorry, %s is not accepting mail."), Moniker(target)));
}
free_lbuf(str);
}
static bool mail_check(dbref player, dbref target)
{
if (!could_doit(player, target, A_LMAIL))
{
mail_return(player, target);
}
else if (!could_doit(target, player, A_LMAIL))
{
if (Wizard(player))
{
raw_notify(player, tprintf(M_("Warning: %s cant return your mail."), Moniker(target)));
return true;
}
else
{
raw_notify(player, tprintf(M_("Sorry, %s cant return your mail."), Moniker(target)));
return false;
}
}
else
{
return true;
}
return false;
}
static void send_mail
(
dbref player,
dbref target,
const UTF8 *tolist,
const UTF8 *subject,
int number,
mail_flag flags,
bool silent
)
{
if (!isPlayer(target))
{
raw_notify(player, M_("MAIL: You cannot send mail to non-existent people."));
return;
}
if (!mail_check(player, target))
{
return;
}
CLinearTimeAbsolute ltaNow;
ltaNow.GetLocal();
const UTF8 *pTimeStr = ltaNow.ReturnDateString(0);
// Initialize the appropriate fields.
//
auto& lst = mail_storage[target];
lst.emplace_back();
mail& newm = lst.back();
newm.to = target;
newm.sqlite_id = -1;
// Sender attribution policy for @mail/quick from objects:
// - If the sender is a player, credit the player.
// - If the sender is an object owned by a wizard, credit the
// object itself (wizards can run trusted delivery bots).
// - Otherwise credit the object's owner (so a non-wizard player
// can't spoof mail origin by using an intermediate object).
//
if (isPlayer(player))
{
newm.from = player;
}
else
{
dbref mailbag = Owner(player);
if (Wizard(mailbag))
{
newm.from = player;
}
else
{
newm.from = mailbag;
}
}
if ( !tolist
|| tolist[0] == '\0')
{
newm.tolist = "*HIDDEN*";
}
else
{
newm.tolist.assign(reinterpret_cast<const char *>(tolist));
}
newm.number = number;
MessageReferenceInc(number);
newm.time.assign(reinterpret_cast<const char *>(pTimeStr));
newm.subject.assign(reinterpret_cast<const char *>(subject));
// Send to folder 0
//
newm.read = flags & M_FMASK;
// Reject if the target's mailbox is full.
//
if ( 0 < mudconf.mail_max_per_player
&& !No_Mail_Expire(target))
{
int total = 0;
MailList ml_count(target);
struct mail *mp_count;
for (mp_count = ml_count.FirstItem(); !ml_count.IsEnd();
mp_count = ml_count.NextItem())
{
total++;
}
fix: restore mail SQLite write-through insert + mailbox-full off-by-one Two bugs in send_mail introduced by the mail STL storage migration (d1f84fd19 / ae9ee42ff), found in review of that stack. 1. Lost SQLite write-through for newly sent mail. d1f84fd19 dropped send_mail's sqlite_wt_insert_mail() call (the raw pointer it took was about to be deleted by the new AppendItem), and no later commit restored it -- the function became dead code. MessageAdd still writes the mail *body* through, so a crash before the next full sqlite_sync_mail left an orphaned body with no header: the recipient loses the message. Worse, the header's sqlite_id stayed -1, so the read-flag and delete write-throughs (which guard on sqlite_id < 0) silently skipped persistence for that mail until a full sync rebuilt the tables. Restore the insert on the committed list element (&newm) after the mailbox-full check, matching the pre-migration behavior. 2. Mailbox-full off-by-one. ae9ee42ff restructured send_mail to emplace_back the new message before the capacity check, but the check counts by iterating the mailbox -- which now includes the just-added message. That cut the effective limit from mail_max_per_player to mail_max_per_player-1 and inflated the "mailbox is full (N messages)" count by one. Compare total > max (reject at existing == max) and report total-1. Verified: full rebuild + smoke suite (1264/1264, 0 crashes; the harness imports into SQLite and exercises the mail backend paths). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 01:24:11 -06:00
// total already includes the message we emplaced above, so the limit
// is reached at max+1 (existing == max). Report the pre-existing
// count (total-1) to match the prior behavior.
//
if (total > mudconf.mail_max_per_player)
{
raw_notify(player,
fix(nls): plural morphology baked into msgids, and a fragment that could not be translated (#1717) Found while translating mail (#1419). The tell was a line wrong in English before translation is even involved: MAIL: 1 messages in folder 0 [unnamed] (1 unread, 0 cleared). MN_() exists for this (#1622) and #1655 converted the @mail/stats family; this set was missed. A translator cannot work around it -- the msgid has no msgid_plural, so there is no second form for a catalogue to supply, and es must pick one wording for all counts while ru and pl need three and can express none of them. The fix is in the C++, not the .po. ## Converted, 11 sites mail.cpp, mail_mod.cpp mailbox is full; URGENT MAIL urgent messages walkdb.cpp universe contains; objects @chowned; objects marked object.cpp objects @chowned to you wiz.cpp You toaded ... objects @chowned command.cpp at most N players logged in The mail module duplicates are converted alongside the engine's so the two implementations do not drift (#1614). ## One was a different bug than the issue described walkdb.cpp built its message as M_("%d objects %smarked") with %s = "" or "un". That is fragment assembly, not a missing plural: a translator handed that %s cannot use it, because the prefix is English morphology rather than a word. Split into two whole sentences, each pluralised -- the #1575 / #1588 rule. Now renders "1 object marked" / "8 objects unmarked" instead of a %s a catalogue has no way to fill. ## Two deliberately NOT converted MAIL: %d messages in folder %d [%s] (%d unread, %d cleared). %d objects = %d rooms, %d exits, %d things, %d players. (%d garbage) MN_(s, p, n) selects one form from one n. Four independent counts are four independent plural decisions, and in Russian each noun needs its own form. These want rewording, which is a design call rather than a mechanical conversion, so they are left and recorded rather than half-fixed. ## Catalogue fallout .pot 18 -> 27 plural entries; all three catalogues msgmerge'd. xx is `complete` policy, so its nine new entries had to be filled and unfuzzied or the guard fails -- which it duly did, first try. msgmerge had copied the old singular-only text into BOTH forms, so msgstr[0] said "messages"; each form is now prefixed from its own msgid. es and ko received real translations for all nine. es.po 190 -> 198/976 (20.3%) ko.po 229 -> 236/976 (24.2%) ## Verified end to end, at n=1 and n>1 en 1 object marked 8 objects marked es 1 objeto marcado 8 objetos marcados ko 사물 1개를 표시했습니다 사물 8개를 표시했습니다 xx [xx] 1 object marked [xx] 8 objects marked Korean renders identically for both, which is correct: it declares nplurals=1, so the catalogue's own rule is being honoured rather than English's imposed on it. The unconverted multi-count line still reads "1 messages", as documented. make test: Smoke 1561 x3, tests/nls 7, ko 4, plural 43. TESTEXIT=0. Refs #1419, #1575, #1588, #1614, #1622, #1655, #1702. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:29:08 -06:00
tprintf(MN_("MAIL: %ss mailbox is full (%d message).",
"MAIL: %ss mailbox is full (%d messages).",
total - 1),
fix: restore mail SQLite write-through insert + mailbox-full off-by-one Two bugs in send_mail introduced by the mail STL storage migration (d1f84fd19 / ae9ee42ff), found in review of that stack. 1. Lost SQLite write-through for newly sent mail. d1f84fd19 dropped send_mail's sqlite_wt_insert_mail() call (the raw pointer it took was about to be deleted by the new AppendItem), and no later commit restored it -- the function became dead code. MessageAdd still writes the mail *body* through, so a crash before the next full sqlite_sync_mail left an orphaned body with no header: the recipient loses the message. Worse, the header's sqlite_id stayed -1, so the read-flag and delete write-throughs (which guard on sqlite_id < 0) silently skipped persistence for that mail until a full sync rebuilt the tables. Restore the insert on the committed list element (&newm) after the mailbox-full check, matching the pre-migration behavior. 2. Mailbox-full off-by-one. ae9ee42ff restructured send_mail to emplace_back the new message before the capacity check, but the check counts by iterating the mailbox -- which now includes the just-added message. That cut the effective limit from mail_max_per_player to mail_max_per_player-1 and inflated the "mailbox is full (N messages)" count by one. Compare total > max (reject at existing == max) and report total-1. Verified: full rebuild + smoke suite (1264/1264, 0 crashes; the harness imports into SQLite and exercises the mail backend paths). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 01:24:11 -06:00
Moniker(target), total - 1));
MessageReferenceDec(number);
lst.pop_back(); // undo the emplace
return;
}
}
fix: restore mail SQLite write-through insert + mailbox-full off-by-one Two bugs in send_mail introduced by the mail STL storage migration (d1f84fd19 / ae9ee42ff), found in review of that stack. 1. Lost SQLite write-through for newly sent mail. d1f84fd19 dropped send_mail's sqlite_wt_insert_mail() call (the raw pointer it took was about to be deleted by the new AppendItem), and no later commit restored it -- the function became dead code. MessageAdd still writes the mail *body* through, so a crash before the next full sqlite_sync_mail left an orphaned body with no header: the recipient loses the message. Worse, the header's sqlite_id stayed -1, so the read-flag and delete write-throughs (which guard on sqlite_id < 0) silently skipped persistence for that mail until a full sync rebuilt the tables. Restore the insert on the committed list element (&newm) after the mailbox-full check, matching the pre-migration behavior. 2. Mailbox-full off-by-one. ae9ee42ff restructured send_mail to emplace_back the new message before the capacity check, but the check counts by iterating the mailbox -- which now includes the just-added message. That cut the effective limit from mail_max_per_player to mail_max_per_player-1 and inflated the "mailbox is full (N messages)" count by one. Compare total > max (reject at existing == max) and report total-1. Verified: full rebuild + smoke suite (1264/1264, 0 crashes; the harness imports into SQLite and exercises the mail backend paths). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 01:24:11 -06:00
// The message is committed to the target's in-memory list; write it
// through to SQLite so it is durable and so later read/delete mutations
// (which key off sqlite_id) persist. Mirrors the pre-refactor insert
// that the STL migration dropped.
//
fix: restore mail SQLite write-through insert + mailbox-full off-by-one Two bugs in send_mail introduced by the mail STL storage migration (d1f84fd19 / ae9ee42ff), found in review of that stack. 1. Lost SQLite write-through for newly sent mail. d1f84fd19 dropped send_mail's sqlite_wt_insert_mail() call (the raw pointer it took was about to be deleted by the new AppendItem), and no later commit restored it -- the function became dead code. MessageAdd still writes the mail *body* through, so a crash before the next full sqlite_sync_mail left an orphaned body with no header: the recipient loses the message. Worse, the header's sqlite_id stayed -1, so the read-flag and delete write-throughs (which guard on sqlite_id < 0) silently skipped persistence for that mail until a full sync rebuilt the tables. Restore the insert on the committed list element (&newm) after the mailbox-full check, matching the pre-migration behavior. 2. Mailbox-full off-by-one. ae9ee42ff restructured send_mail to emplace_back the new message before the capacity check, but the check counts by iterating the mailbox -- which now includes the just-added message. That cut the effective limit from mail_max_per_player to mail_max_per_player-1 and inflated the "mailbox is full (N messages)" count by one. Compare total > max (reject at existing == max) and report total-1. Verified: full rebuild + smoke suite (1264/1264, 0 crashes; the harness imports into SQLite and exercises the mail backend paths). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 01:24:11 -06:00
sqlite_wt_insert_mail(&newm);
// Notify people.
//
if (!silent)
{
raw_notify(player,
tprintf(M_("MAIL: You sent your message to %s."), Moniker(target)));
}
raw_notify(target,
tprintf(M_("MAIL: You have a new message from %s. Subject: %s"),
Moniker(player), subject));
2018-10-03 17:54:51 +00:00
did_it(player, target, A_MAIL, nullptr, 0, nullptr, A_AMAIL, 0, nullptr, NOTHING);
}
static void do_mail_nuke(dbref player)
{
if (!God(player))
{
raw_notify(player,
M_("The postal service issues a warrant for your arrest."));
return;
}
// Walk the list.
//
dbref thing;
DO_WHOLE_DB(thing)
{
MailList ml(thing);
ml.RemoveAll();
}
log_printf(T("** MAIL PURGE ** done by %s(#%d)." ENDLINE), PureName(player), player);
raw_notify(player, M_("You annihilate the post office. All messages cleared."));
}
// Purge a destroyed player's mail presence. Called from destroy_player().
//
// 1. Remove all received mail.
// 2. Orphan sent mail so a recycled dbref cannot reply to the old player.
// 3. Clean up mail aliases.
//
void mail_destroy_player(dbref victim)
{
if (nullptr != mudstate.pIMailControl)
{
MUX_RESULT mr = mudstate.pIMailControl->DestroyPlayerMail(victim);
if (MUX_SUCCEEDED(mr))
{
return;
}
}
// Step 1: Purge received mail.
//
MailList ml(victim);
ml.RemoveAll();
// Step 2: Orphan sent mail in every other player's mailbox.
//
for (auto &kv : mail_storage)
{
MailList ml2(kv.first);
struct mail *mp;
for (mp = ml2.FirstItem(); !ml2.IsEnd(); mp = ml2.NextItem())
{
if (mp->from == victim)
{
mp->from = NOTHING;
}
}
}
// Step 3: Clean up mail aliases.
//
malias_cleanup(victim);
}
#ifdef SELFCHECK
void finish_mail()
{
dbref thing;
DO_WHOLE_DB(thing)
{
MailList ml(thing);
ml.RemoveAll();
}
mail_list.clear();
}
#endif
static void do_mail_debug(dbref player, UTF8 *action, UTF8 *victim)
{
if (!ExpMail(player))
{
raw_notify(player, M_("Go get some bugspray."));
return;
}
dbref thing;
if (string_prefix(T("clear"), action))
{
dbref target = lookup_player(player, victim, true);
if (target == NOTHING)
{
init_match(player, victim, NOTYPE);
match_absolute();
target = match_result();
}
if (target == NOTHING)
{
raw_notify(player, tprintf(M_("%s: no such player."), victim));
return;
}
if (Wizard(target))
{
raw_notify(player, tprintf(M_("Let %s clear their own @mail."), Moniker(target)));
return;
}
2018-10-03 17:54:51 +00:00
do_mail_clear(target, nullptr);
do_mail_purge(target);
raw_notify(player, tprintf(M_("Mail cleared for %s(#%d)."), Moniker(target), target));
return;
}
else if (string_prefix(T("sanity"), action))
{
std::vector<int> ai(mudstate.mail_db_top, 0);
DO_WHOLE_DB(thing)
{
MailList ml(thing);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
bool bGoodReference;
if (0 <= mp->number && mp->number < mudstate.mail_db_top)
{
ai[mp->number]++;
bGoodReference = true;
}
else
{
bGoodReference = false;
}
if (!Good_obj(mp->to))
{
if (bGoodReference)
{
raw_notify(player, tprintf(M_("Bad object #%d has mail."), mp->to));
}
else
{
raw_notify(player, tprintf(M_("Bad object #%d has mail which refers to a non-existent mailbag item."), mp->to));
}
}
else if (!isPlayer(mp->to))
{
if (bGoodReference)
{
raw_notify(player, tprintf(M_("%s(#%d) has mail, but is not a player."),
Moniker(mp->to), mp->to));
}
else
{
raw_notify(player, tprintf(M_("%s(#%d) is not a player, but has mail which refers to a non-existent mailbag item."),
Moniker(mp->to), mp->to));
}
}
else if (!bGoodReference)
{
raw_notify(player, tprintf(M_("%s(#%d) has mail which refers to a non-existent mailbag item."), Moniker(mp->to), mp->to));
}
}
}
// Check ref counts.
//
{
int i;
int nCountHigher = 0;
int nCountLower = 0;
for (i = 0; i < mudstate.mail_db_top; i++)
{
if (mail_list[i].m_nRefs < ai[i])
{
nCountLower++;
}
else if (mail_list[i].m_nRefs > ai[i])
{
nCountHigher++;
}
}
if (nCountLower)
{
raw_notify(player, M_("Some mailbag items are referred to more often than the mailbag item indicates."));
}
if (nCountHigher)
{
raw_notify(player, M_("Some mailbag items are referred to less often than the mailbag item indicates."));
}
}
raw_notify(player, M_("Mail sanity check completed."));
}
else if (string_prefix(T("fix"), action))
{
// First, we should fixup the reference counts.
//
{
raw_notify(player, M_("Re-counting mailbag reference counts."));
std::vector<int> ai(mudstate.mail_db_top, 0);
DO_WHOLE_DB(thing)
{
MailList ml(thing);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if ( 0 <= mp->number
&& mp->number < mudstate.mail_db_top)
{
ai[mp->number]++;
}
else
{
mp->number = NOTHING;
}
}
}
int i;
int nCountWrong = 0;
for (i = 0; i < mudstate.mail_db_top; i++)
{
if (mail_list[i].m_nRefs != ai[i])
{
mail_list[i].m_nRefs = ai[i];
nCountWrong++;
}
}
if (nCountWrong)
{
raw_notify(player, M_("Some reference counts were wrong [FIXED]."));
}
}
raw_notify(player, M_("Removing @mail that is associated with non-players."));
// Now, remove all mail to non-good or non-players, or mail that
// points to non-existent mailbag items.
//
DO_WHOLE_DB(thing)
{
MailList ml(thing);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if ( !Good_obj(mp->to)
|| !isPlayer(mp->to)
|| NOTHING == mp->number)
{
// Delete this item.
//
raw_notify(player, tprintf(M_("Fixing mail for #%d."), mp->to));
ml.RemoveItem();
}
}
}
raw_notify(player, M_("Mail sanity fix completed."));
}
else
{
raw_notify(player, M_("That is not a debugging option."));
return;
}
}
static void do_mail_stats(dbref player, UTF8 *name, int full)
{
dbref target, thing;
int fc, fr, fu, tc, tr, tu, count;
size_t cchars = 0;
size_t fchars = 0;
size_t tchars = 0;
fc = fr = fu = tc = tr = tu = count = 0;
// Find player.
//
if ( !name
|| *name == '\0')
{
if (Wizard(player))
{
target = AMBIGUOUS;
}
else
{
target = player;
}
}
else if (*name == NUMBER_TOKEN)
{
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
target = mux_atoi64(&name[1]);
if (!Good_obj(target) || !isPlayer(target))
{
target = NOTHING;
}
}
else if (!mux_stricmp(name, T("me")))
{
target = player;
}
else
{
target = lookup_player(player, name, true);
}
if (target == NOTHING)
{
init_match(player, name, NOTYPE);
match_absolute();
target = match_result();
}
if (target == NOTHING)
{
raw_notify(player, tprintf(M_("%s: No such player."), name));
return;
}
if (!ExpMail(player) && (target != player))
{
raw_notify(player, M_("The post office protects privacy!"));
return;
}
// This comand is computationally expensive.
//
if (!payfor(player, mudconf.searchcost))
{
raw_notify(player, tprintf(M_("Finding mail stats costs %d %s."),
mudconf.searchcost,
(mudconf.searchcost == 1) ? mudconf.one_coin : mudconf.many_coins));
return;
}
if (AMBIGUOUS == target)
{
// Stats for all.
//
if (full == 0)
{
DO_WHOLE_DB(thing)
{
MailList ml(thing);
for ((void)ml.FirstItem(); !ml.IsEnd(); (void)ml.NextItem())
{
count++;
}
}
fix(mail): the module's three stats levels, and ban raw snprintf (#1631) @mail/stats, @mail/dstats and @mail/fstats were one invented per-player summary served identically for all three switches, ignoring both the [<player>] argument and the wizard gate: engine There are 1 messages in the mail spool. module MAIL: 1 messages (0 read, 1 unread, 0 cleared). 10 bytes total. Different text, different scope, different meaning. Under #1614 the module is the shipped default, so every game's @mail/stats changed meaning on upgrade. The engine is the oracle: three cumulative detail levels, an optional target, and "no target from a wizard" meaning the whole spool. Deferring was not an option -- returning MUX_E_NOTIMPLEMENTED yields "That @mail command is not available." rather than falling through -- so the module implements all three. Engine and module output is now byte-identical across all six command forms, verified against one shared database. The count-bearing strings were T(), so mail statistics were untranslatable AND read "There are 1 messages" -- the #1622 shape. MN_()/M_() fixes both. Raw snprintf is now a build error at new call sites. It counts bytes, and C11 7.21.6.5 and 7.21.6.1p8 both truncate mid-sequence; measured, snprintf turns a 4-codepoint moniker into E6 97 A5 E6 9C AC E8 -- a lone lead byte, invalid UTF-8, sent to a player. mux_vsnprintf advances a whole codepoint at a time and stops before emitting a partial one. Every one of these sites interpolates player-settable text into a fixed buffer. The reason 280 such sites accumulated is that stringutil.h cannot be included from a module: its inlines want Ragel tables only the engine links, so the correct primitive was unreachable at compile time. mux_format.h declares the exported ones, removing the excuse; check_formats.py then freezes the count per file so existing code stays green and site #281 fails the build. Both directions of the ratchet are tested. Known divergence: the engine charges mudconf.searchcost, which no module-visible interface exposes. payfor() exempts wizards and every cross-player query is wizard-gated, so this is reachable only by a non-wizard running stats on themselves. Filed rather than bent into an ABI change here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:15:05 -06:00
// MN_() rather than T() (#1631): these were never marked, so mail
// stats were untranslatable; and "There are 1 messages" was the
// #1622 shape -- English morphology decided in C. One change
// fixes both.
//
raw_notify(player, tprintf(MN_("There is %d message in the mail spool.",
"There are %d messages in the mail spool.",
count), count));
return;
}
else if (full == 1)
{
DO_WHOLE_DB(thing)
{
MailList ml(thing);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if (Cleared(mp))
{
fc++;
}
else if (Read(mp))
{
fr++;
}
else
{
fu++;
}
}
}
raw_notify(player,
fix(mail): the module's three stats levels, and ban raw snprintf (#1631) @mail/stats, @mail/dstats and @mail/fstats were one invented per-player summary served identically for all three switches, ignoring both the [<player>] argument and the wizard gate: engine There are 1 messages in the mail spool. module MAIL: 1 messages (0 read, 1 unread, 0 cleared). 10 bytes total. Different text, different scope, different meaning. Under #1614 the module is the shipped default, so every game's @mail/stats changed meaning on upgrade. The engine is the oracle: three cumulative detail levels, an optional target, and "no target from a wizard" meaning the whole spool. Deferring was not an option -- returning MUX_E_NOTIMPLEMENTED yields "That @mail command is not available." rather than falling through -- so the module implements all three. Engine and module output is now byte-identical across all six command forms, verified against one shared database. The count-bearing strings were T(), so mail statistics were untranslatable AND read "There are 1 messages" -- the #1622 shape. MN_()/M_() fixes both. Raw snprintf is now a build error at new call sites. It counts bytes, and C11 7.21.6.5 and 7.21.6.1p8 both truncate mid-sequence; measured, snprintf turns a 4-codepoint moniker into E6 97 A5 E6 9C AC E8 -- a lone lead byte, invalid UTF-8, sent to a player. mux_vsnprintf advances a whole codepoint at a time and stops before emitting a partial one. Every one of these sites interpolates player-settable text into a fixed buffer. The reason 280 such sites accumulated is that stringutil.h cannot be included from a module: its inlines want Ragel tables only the engine links, so the correct primitive was unreachable at compile time. mux_format.h declares the exported ones, removing the excuse; check_formats.py then freezes the count per file so existing code stays green and site #281 fails the build. Both directions of the ratchet are tested. Known divergence: the engine charges mudconf.searchcost, which no module-visible interface exposes. payfor() exempts wizards and every cross-player query is wizard-gated, so this is reachable only by a non-wizard running stats on themselves. Filed rather than bent into an ABI change here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:15:05 -06:00
tprintf(MN_("MAIL: There is %d msg in the mail spool, %d unread, %d cleared.",
"MAIL: There are %d msgs in the mail spool, %d unread, %d cleared.",
fc + fr + fu),
fc + fr + fu, fu, fc));
return;
}
else
{
DO_WHOLE_DB(thing)
{
MailList ml(thing);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
fix(mail): @mail/fstats counted a NUL that was never there (#1639) @mail/fstats added 1 to every message size: cchars += MessageFetchSize(mp->number) + 1; MessageFetchSize returns std::string::size() (mail.cpp:337), so there is no terminator in the count to begin with and the + 1 is a phantom byte. The same engine then reported two different sizes for the same message depending on which command was asked, which is why this is a defect rather than a convention: /list, mailinfo(size) and the mail module all disagree with it. Five sites, not the three in the report. The two extra are in the per-player branch reached by `@mail/fstats <player>` (full == 2), which had the same + 1 and would have kept disagreeing after a three-site fix. ## Verified on live netmux, not muxscript Deliberately, because #1637 turned out to be a muxscript artifact: the engine's From: field is blank there only because CScriptConnectionManager::TrimmedName is a stub. Anything measured about the built-in mail display through that harness needs a real socket to be believed. Fresh DB, port 7860, netmux -s, two messages of 8 and 6 characters: before MAIL: There are 2 new msgs ... totalling 16 characters. after MAIL: There are 2 new msgs ... totalling 14 characters. @mail/list ( 8) and ( 6) = 14 @mail/fstats 14 @mail/fstats Wizard 14 <- the two extra sites mailinfo(1,size) 8 All four now agree. Before the fix a single 8-character message reported 9. Refs #1587, #1614, #1631, #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:06:38 -06:00
// These were MessageFetchSize() + 1, counting a NUL that
// MessageFetchSize does not report -- it returns
// std::string::size(). That made @mail/fstats disagree
// with @mail/list, mailinfo(size) and the mail module
// about how big the same message is, by one byte per
// message (#1639).
//
if (Cleared(mp))
{
fc++;
fix(mail): @mail/fstats counted a NUL that was never there (#1639) @mail/fstats added 1 to every message size: cchars += MessageFetchSize(mp->number) + 1; MessageFetchSize returns std::string::size() (mail.cpp:337), so there is no terminator in the count to begin with and the + 1 is a phantom byte. The same engine then reported two different sizes for the same message depending on which command was asked, which is why this is a defect rather than a convention: /list, mailinfo(size) and the mail module all disagree with it. Five sites, not the three in the report. The two extra are in the per-player branch reached by `@mail/fstats <player>` (full == 2), which had the same + 1 and would have kept disagreeing after a three-site fix. ## Verified on live netmux, not muxscript Deliberately, because #1637 turned out to be a muxscript artifact: the engine's From: field is blank there only because CScriptConnectionManager::TrimmedName is a stub. Anything measured about the built-in mail display through that harness needs a real socket to be believed. Fresh DB, port 7860, netmux -s, two messages of 8 and 6 characters: before MAIL: There are 2 new msgs ... totalling 16 characters. after MAIL: There are 2 new msgs ... totalling 14 characters. @mail/list ( 8) and ( 6) = 14 @mail/fstats 14 @mail/fstats Wizard 14 <- the two extra sites mailinfo(1,size) 8 All four now agree. Before the fix a single 8-character message reported 9. Refs #1587, #1614, #1631, #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:06:38 -06:00
cchars += MessageFetchSize(mp->number);
}
else if (Read(mp))
{
fr++;
fix(mail): @mail/fstats counted a NUL that was never there (#1639) @mail/fstats added 1 to every message size: cchars += MessageFetchSize(mp->number) + 1; MessageFetchSize returns std::string::size() (mail.cpp:337), so there is no terminator in the count to begin with and the + 1 is a phantom byte. The same engine then reported two different sizes for the same message depending on which command was asked, which is why this is a defect rather than a convention: /list, mailinfo(size) and the mail module all disagree with it. Five sites, not the three in the report. The two extra are in the per-player branch reached by `@mail/fstats <player>` (full == 2), which had the same + 1 and would have kept disagreeing after a three-site fix. ## Verified on live netmux, not muxscript Deliberately, because #1637 turned out to be a muxscript artifact: the engine's From: field is blank there only because CScriptConnectionManager::TrimmedName is a stub. Anything measured about the built-in mail display through that harness needs a real socket to be believed. Fresh DB, port 7860, netmux -s, two messages of 8 and 6 characters: before MAIL: There are 2 new msgs ... totalling 16 characters. after MAIL: There are 2 new msgs ... totalling 14 characters. @mail/list ( 8) and ( 6) = 14 @mail/fstats 14 @mail/fstats Wizard 14 <- the two extra sites mailinfo(1,size) 8 All four now agree. Before the fix a single 8-character message reported 9. Refs #1587, #1614, #1631, #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:06:38 -06:00
fchars += MessageFetchSize(mp->number);
}
else
{
fu++;
fix(mail): @mail/fstats counted a NUL that was never there (#1639) @mail/fstats added 1 to every message size: cchars += MessageFetchSize(mp->number) + 1; MessageFetchSize returns std::string::size() (mail.cpp:337), so there is no terminator in the count to begin with and the + 1 is a phantom byte. The same engine then reported two different sizes for the same message depending on which command was asked, which is why this is a defect rather than a convention: /list, mailinfo(size) and the mail module all disagree with it. Five sites, not the three in the report. The two extra are in the per-player branch reached by `@mail/fstats <player>` (full == 2), which had the same + 1 and would have kept disagreeing after a three-site fix. ## Verified on live netmux, not muxscript Deliberately, because #1637 turned out to be a muxscript artifact: the engine's From: field is blank there only because CScriptConnectionManager::TrimmedName is a stub. Anything measured about the built-in mail display through that harness needs a real socket to be believed. Fresh DB, port 7860, netmux -s, two messages of 8 and 6 characters: before MAIL: There are 2 new msgs ... totalling 16 characters. after MAIL: There are 2 new msgs ... totalling 14 characters. @mail/list ( 8) and ( 6) = 14 @mail/fstats 14 @mail/fstats Wizard 14 <- the two extra sites mailinfo(1,size) 8 All four now agree. Before the fix a single 8-character message reported 9. Refs #1587, #1614, #1631, #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:06:38 -06:00
tchars += MessageFetchSize(mp->number);
}
}
}
fix(mail): the module's three stats levels, and ban raw snprintf (#1631) @mail/stats, @mail/dstats and @mail/fstats were one invented per-player summary served identically for all three switches, ignoring both the [<player>] argument and the wizard gate: engine There are 1 messages in the mail spool. module MAIL: 1 messages (0 read, 1 unread, 0 cleared). 10 bytes total. Different text, different scope, different meaning. Under #1614 the module is the shipped default, so every game's @mail/stats changed meaning on upgrade. The engine is the oracle: three cumulative detail levels, an optional target, and "no target from a wizard" meaning the whole spool. Deferring was not an option -- returning MUX_E_NOTIMPLEMENTED yields "That @mail command is not available." rather than falling through -- so the module implements all three. Engine and module output is now byte-identical across all six command forms, verified against one shared database. The count-bearing strings were T(), so mail statistics were untranslatable AND read "There are 1 messages" -- the #1622 shape. MN_()/M_() fixes both. Raw snprintf is now a build error at new call sites. It counts bytes, and C11 7.21.6.5 and 7.21.6.1p8 both truncate mid-sequence; measured, snprintf turns a 4-codepoint moniker into E6 97 A5 E6 9C AC E8 -- a lone lead byte, invalid UTF-8, sent to a player. mux_vsnprintf advances a whole codepoint at a time and stops before emitting a partial one. Every one of these sites interpolates player-settable text into a fixed buffer. The reason 280 such sites accumulated is that stringutil.h cannot be included from a module: its inlines want Ragel tables only the engine links, so the correct primitive was unreachable at compile time. mux_format.h declares the exported ones, removing the excuse; check_formats.py then freezes the count per file so existing code stays green and site #281 fails the build. Both directions of the ratchet are tested. Known divergence: the engine charges mudconf.searchcost, which no module-visible interface exposes. payfor() exempts wizards and every cross-player query is wizard-gated, so this is reachable only by a non-wizard running stats on themselves. Filed rather than bent into an ABI change here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:15:05 -06:00
raw_notify(player, tprintf(MN_("MAIL: There is %d old msg in the mail spool, totalling %d characters.",
"MAIL: There are %d old msgs in the mail spool, totalling %d characters.",
fr), fr, fchars));
raw_notify(player, tprintf(MN_("MAIL: There is %d new msg in the mail spool, totalling %d characters.",
"MAIL: There are %d new msgs in the mail spool, totalling %d characters.",
fu), fu, tchars));
raw_notify(player, tprintf(MN_("MAIL: There is %d cleared msg in the mail spool, totalling %d characters.",
"MAIL: There are %d cleared msgs in the mail spool, totalling %d characters.",
fc), fc, cchars));
return;
}
}
// individual stats
//
if (full == 0)
{
// Just count the number of messages.
//
DO_WHOLE_DB(thing)
{
MailList ml(thing);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if (mp->from == target)
{
fr++;
}
if (mp->to == target)
{
tr++;
}
}
}
fix(mail): the module's three stats levels, and ban raw snprintf (#1631) @mail/stats, @mail/dstats and @mail/fstats were one invented per-player summary served identically for all three switches, ignoring both the [<player>] argument and the wizard gate: engine There are 1 messages in the mail spool. module MAIL: 1 messages (0 read, 1 unread, 0 cleared). 10 bytes total. Different text, different scope, different meaning. Under #1614 the module is the shipped default, so every game's @mail/stats changed meaning on upgrade. The engine is the oracle: three cumulative detail levels, an optional target, and "no target from a wizard" meaning the whole spool. Deferring was not an option -- returning MUX_E_NOTIMPLEMENTED yields "That @mail command is not available." rather than falling through -- so the module implements all three. Engine and module output is now byte-identical across all six command forms, verified against one shared database. The count-bearing strings were T(), so mail statistics were untranslatable AND read "There are 1 messages" -- the #1622 shape. MN_()/M_() fixes both. Raw snprintf is now a build error at new call sites. It counts bytes, and C11 7.21.6.5 and 7.21.6.1p8 both truncate mid-sequence; measured, snprintf turns a 4-codepoint moniker into E6 97 A5 E6 9C AC E8 -- a lone lead byte, invalid UTF-8, sent to a player. mux_vsnprintf advances a whole codepoint at a time and stops before emitting a partial one. Every one of these sites interpolates player-settable text into a fixed buffer. The reason 280 such sites accumulated is that stringutil.h cannot be included from a module: its inlines want Ragel tables only the engine links, so the correct primitive was unreachable at compile time. mux_format.h declares the exported ones, removing the excuse; check_formats.py then freezes the count per file so existing code stays green and site #281 fails the build. Both directions of the ratchet are tested. Known divergence: the engine charges mudconf.searchcost, which no module-visible interface exposes. payfor() exempts wizards and every cross-player query is wizard-gated, so this is reachable only by a non-wizard running stats on themselves. Filed rather than bent into an ABI change here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:15:05 -06:00
raw_notify(player, tprintf(MN_("%s sent %d message.",
"%s sent %d messages.", fr),
Moniker(target), fr));
raw_notify(player, tprintf(MN_("%s has %d message.",
"%s has %d messages.", tr),
Moniker(target), tr));
return;
}
// More detailed message count.
//
UTF8 last[50];
DO_WHOLE_DB(thing)
{
MailList ml(thing);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if (mp->from == target)
{
if (Cleared(mp))
{
fc++;
}
else if (Read(mp))
{
fr++;
}
else
{
fu++;
}
fix(mail): @mail/fstats counted a NUL that was never there (#1639) @mail/fstats added 1 to every message size: cchars += MessageFetchSize(mp->number) + 1; MessageFetchSize returns std::string::size() (mail.cpp:337), so there is no terminator in the count to begin with and the + 1 is a phantom byte. The same engine then reported two different sizes for the same message depending on which command was asked, which is why this is a defect rather than a convention: /list, mailinfo(size) and the mail module all disagree with it. Five sites, not the three in the report. The two extra are in the per-player branch reached by `@mail/fstats <player>` (full == 2), which had the same + 1 and would have kept disagreeing after a three-site fix. ## Verified on live netmux, not muxscript Deliberately, because #1637 turned out to be a muxscript artifact: the engine's From: field is blank there only because CScriptConnectionManager::TrimmedName is a stub. Anything measured about the built-in mail display through that harness needs a real socket to be believed. Fresh DB, port 7860, netmux -s, two messages of 8 and 6 characters: before MAIL: There are 2 new msgs ... totalling 16 characters. after MAIL: There are 2 new msgs ... totalling 14 characters. @mail/list ( 8) and ( 6) = 14 @mail/fstats 14 @mail/fstats Wizard 14 <- the two extra sites mailinfo(1,size) 8 All four now agree. Before the fix a single 8-character message reported 9. Refs #1587, #1614, #1631, #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:06:38 -06:00
// Same phantom byte as the whole-DB branch above (#1639).
// The report named only the three sites there; these two,
// reached by @mail/fstats <player>, had it as well.
//
if (full == 2)
{
fix(mail): @mail/fstats counted a NUL that was never there (#1639) @mail/fstats added 1 to every message size: cchars += MessageFetchSize(mp->number) + 1; MessageFetchSize returns std::string::size() (mail.cpp:337), so there is no terminator in the count to begin with and the + 1 is a phantom byte. The same engine then reported two different sizes for the same message depending on which command was asked, which is why this is a defect rather than a convention: /list, mailinfo(size) and the mail module all disagree with it. Five sites, not the three in the report. The two extra are in the per-player branch reached by `@mail/fstats <player>` (full == 2), which had the same + 1 and would have kept disagreeing after a three-site fix. ## Verified on live netmux, not muxscript Deliberately, because #1637 turned out to be a muxscript artifact: the engine's From: field is blank there only because CScriptConnectionManager::TrimmedName is a stub. Anything measured about the built-in mail display through that harness needs a real socket to be believed. Fresh DB, port 7860, netmux -s, two messages of 8 and 6 characters: before MAIL: There are 2 new msgs ... totalling 16 characters. after MAIL: There are 2 new msgs ... totalling 14 characters. @mail/list ( 8) and ( 6) = 14 @mail/fstats 14 @mail/fstats Wizard 14 <- the two extra sites mailinfo(1,size) 8 All four now agree. Before the fix a single 8-character message reported 9. Refs #1587, #1614, #1631, #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:06:38 -06:00
fchars += MessageFetchSize(mp->number);
}
}
if (mp->to == target)
{
if (!tr && !tu)
{
mux_strncpy(last, utf8(mp->time), sizeof(last)-1);
}
if (Cleared(mp))
{
tc++;
}
else if (Read(mp))
{
tr++;
}
else
{
tu++;
}
if (full == 2)
{
fix(mail): @mail/fstats counted a NUL that was never there (#1639) @mail/fstats added 1 to every message size: cchars += MessageFetchSize(mp->number) + 1; MessageFetchSize returns std::string::size() (mail.cpp:337), so there is no terminator in the count to begin with and the + 1 is a phantom byte. The same engine then reported two different sizes for the same message depending on which command was asked, which is why this is a defect rather than a convention: /list, mailinfo(size) and the mail module all disagree with it. Five sites, not the three in the report. The two extra are in the per-player branch reached by `@mail/fstats <player>` (full == 2), which had the same + 1 and would have kept disagreeing after a three-site fix. ## Verified on live netmux, not muxscript Deliberately, because #1637 turned out to be a muxscript artifact: the engine's From: field is blank there only because CScriptConnectionManager::TrimmedName is a stub. Anything measured about the built-in mail display through that harness needs a real socket to be believed. Fresh DB, port 7860, netmux -s, two messages of 8 and 6 characters: before MAIL: There are 2 new msgs ... totalling 16 characters. after MAIL: There are 2 new msgs ... totalling 14 characters. @mail/list ( 8) and ( 6) = 14 @mail/fstats 14 @mail/fstats Wizard 14 <- the two extra sites mailinfo(1,size) 8 All four now agree. Before the fix a single 8-character message reported 9. Refs #1587, #1614, #1631, #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:06:38 -06:00
tchars += MessageFetchSize(mp->number);
}
}
}
}
fix(mail): the module's three stats levels, and ban raw snprintf (#1631) @mail/stats, @mail/dstats and @mail/fstats were one invented per-player summary served identically for all three switches, ignoring both the [<player>] argument and the wizard gate: engine There are 1 messages in the mail spool. module MAIL: 1 messages (0 read, 1 unread, 0 cleared). 10 bytes total. Different text, different scope, different meaning. Under #1614 the module is the shipped default, so every game's @mail/stats changed meaning on upgrade. The engine is the oracle: three cumulative detail levels, an optional target, and "no target from a wizard" meaning the whole spool. Deferring was not an option -- returning MUX_E_NOTIMPLEMENTED yields "That @mail command is not available." rather than falling through -- so the module implements all three. Engine and module output is now byte-identical across all six command forms, verified against one shared database. The count-bearing strings were T(), so mail statistics were untranslatable AND read "There are 1 messages" -- the #1622 shape. MN_()/M_() fixes both. Raw snprintf is now a build error at new call sites. It counts bytes, and C11 7.21.6.5 and 7.21.6.1p8 both truncate mid-sequence; measured, snprintf turns a 4-codepoint moniker into E6 97 A5 E6 9C AC E8 -- a lone lead byte, invalid UTF-8, sent to a player. mux_vsnprintf advances a whole codepoint at a time and stops before emitting a partial one. Every one of these sites interpolates player-settable text into a fixed buffer. The reason 280 such sites accumulated is that stringutil.h cannot be included from a module: its inlines want Ragel tables only the engine links, so the correct primitive was unreachable at compile time. mux_format.h declares the exported ones, removing the excuse; check_formats.py then freezes the count per file so existing code stays green and site #281 fails the build. Both directions of the ratchet are tested. Known divergence: the engine charges mudconf.searchcost, which no module-visible interface exposes. payfor() exempts wizards and every cross-player query is wizard-gated, so this is reachable only by a non-wizard running stats on themselves. Filed rather than bent into an ABI change here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:15:05 -06:00
raw_notify(player, tprintf(M_("Mail statistics for %s:"), Moniker(target)));
if (full == 1)
{
fix(mail): the module's three stats levels, and ban raw snprintf (#1631) @mail/stats, @mail/dstats and @mail/fstats were one invented per-player summary served identically for all three switches, ignoring both the [<player>] argument and the wizard gate: engine There are 1 messages in the mail spool. module MAIL: 1 messages (0 read, 1 unread, 0 cleared). 10 bytes total. Different text, different scope, different meaning. Under #1614 the module is the shipped default, so every game's @mail/stats changed meaning on upgrade. The engine is the oracle: three cumulative detail levels, an optional target, and "no target from a wizard" meaning the whole spool. Deferring was not an option -- returning MUX_E_NOTIMPLEMENTED yields "That @mail command is not available." rather than falling through -- so the module implements all three. Engine and module output is now byte-identical across all six command forms, verified against one shared database. The count-bearing strings were T(), so mail statistics were untranslatable AND read "There are 1 messages" -- the #1622 shape. MN_()/M_() fixes both. Raw snprintf is now a build error at new call sites. It counts bytes, and C11 7.21.6.5 and 7.21.6.1p8 both truncate mid-sequence; measured, snprintf turns a 4-codepoint moniker into E6 97 A5 E6 9C AC E8 -- a lone lead byte, invalid UTF-8, sent to a player. mux_vsnprintf advances a whole codepoint at a time and stops before emitting a partial one. Every one of these sites interpolates player-settable text into a fixed buffer. The reason 280 such sites accumulated is that stringutil.h cannot be included from a module: its inlines want Ragel tables only the engine links, so the correct primitive was unreachable at compile time. mux_format.h declares the exported ones, removing the excuse; check_formats.py then freezes the count per file so existing code stays green and site #281 fails the build. Both directions of the ratchet are tested. Known divergence: the engine charges mudconf.searchcost, which no module-visible interface exposes. payfor() exempts wizards and every cross-player query is wizard-gated, so this is reachable only by a non-wizard running stats on themselves. Filed rather than bent into an ABI change here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:15:05 -06:00
raw_notify(player, tprintf(MN_("%d message sent, %d unread, %d cleared.",
"%d messages sent, %d unread, %d cleared.",
fr + fu + fc),
fc + fr + fu, fu, fc));
fix(mail): the module's three stats levels, and ban raw snprintf (#1631) @mail/stats, @mail/dstats and @mail/fstats were one invented per-player summary served identically for all three switches, ignoring both the [<player>] argument and the wizard gate: engine There are 1 messages in the mail spool. module MAIL: 1 messages (0 read, 1 unread, 0 cleared). 10 bytes total. Different text, different scope, different meaning. Under #1614 the module is the shipped default, so every game's @mail/stats changed meaning on upgrade. The engine is the oracle: three cumulative detail levels, an optional target, and "no target from a wizard" meaning the whole spool. Deferring was not an option -- returning MUX_E_NOTIMPLEMENTED yields "That @mail command is not available." rather than falling through -- so the module implements all three. Engine and module output is now byte-identical across all six command forms, verified against one shared database. The count-bearing strings were T(), so mail statistics were untranslatable AND read "There are 1 messages" -- the #1622 shape. MN_()/M_() fixes both. Raw snprintf is now a build error at new call sites. It counts bytes, and C11 7.21.6.5 and 7.21.6.1p8 both truncate mid-sequence; measured, snprintf turns a 4-codepoint moniker into E6 97 A5 E6 9C AC E8 -- a lone lead byte, invalid UTF-8, sent to a player. mux_vsnprintf advances a whole codepoint at a time and stops before emitting a partial one. Every one of these sites interpolates player-settable text into a fixed buffer. The reason 280 such sites accumulated is that stringutil.h cannot be included from a module: its inlines want Ragel tables only the engine links, so the correct primitive was unreachable at compile time. mux_format.h declares the exported ones, removing the excuse; check_formats.py then freezes the count per file so existing code stays green and site #281 fails the build. Both directions of the ratchet are tested. Known divergence: the engine charges mudconf.searchcost, which no module-visible interface exposes. payfor() exempts wizards and every cross-player query is wizard-gated, so this is reachable only by a non-wizard running stats on themselves. Filed rather than bent into an ABI change here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:15:05 -06:00
raw_notify(player, tprintf(MN_("%d message received, %d unread, %d cleared.",
"%d messages received, %d unread, %d cleared.",
tr + tu + tc),
tc + tr + tu, tu, tc));
}
else
{
raw_notify(player,
fix(mail): the module's three stats levels, and ban raw snprintf (#1631) @mail/stats, @mail/dstats and @mail/fstats were one invented per-player summary served identically for all three switches, ignoring both the [<player>] argument and the wizard gate: engine There are 1 messages in the mail spool. module MAIL: 1 messages (0 read, 1 unread, 0 cleared). 10 bytes total. Different text, different scope, different meaning. Under #1614 the module is the shipped default, so every game's @mail/stats changed meaning on upgrade. The engine is the oracle: three cumulative detail levels, an optional target, and "no target from a wizard" meaning the whole spool. Deferring was not an option -- returning MUX_E_NOTIMPLEMENTED yields "That @mail command is not available." rather than falling through -- so the module implements all three. Engine and module output is now byte-identical across all six command forms, verified against one shared database. The count-bearing strings were T(), so mail statistics were untranslatable AND read "There are 1 messages" -- the #1622 shape. MN_()/M_() fixes both. Raw snprintf is now a build error at new call sites. It counts bytes, and C11 7.21.6.5 and 7.21.6.1p8 both truncate mid-sequence; measured, snprintf turns a 4-codepoint moniker into E6 97 A5 E6 9C AC E8 -- a lone lead byte, invalid UTF-8, sent to a player. mux_vsnprintf advances a whole codepoint at a time and stops before emitting a partial one. Every one of these sites interpolates player-settable text into a fixed buffer. The reason 280 such sites accumulated is that stringutil.h cannot be included from a module: its inlines want Ragel tables only the engine links, so the correct primitive was unreachable at compile time. mux_format.h declares the exported ones, removing the excuse; check_formats.py then freezes the count per file so existing code stays green and site #281 fails the build. Both directions of the ratchet are tested. Known divergence: the engine charges mudconf.searchcost, which no module-visible interface exposes. payfor() exempts wizards and every cross-player query is wizard-gated, so this is reachable only by a non-wizard running stats on themselves. Filed rather than bent into an ABI change here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:15:05 -06:00
tprintf(MN_("%d message sent, %d unread, %d cleared, totalling %d characters.",
"%d messages sent, %d unread, %d cleared, totalling %d characters.",
fr + fu + fc),
fc + fr + fu, fu, fc, fchars));
raw_notify(player,
fix(mail): the module's three stats levels, and ban raw snprintf (#1631) @mail/stats, @mail/dstats and @mail/fstats were one invented per-player summary served identically for all three switches, ignoring both the [<player>] argument and the wizard gate: engine There are 1 messages in the mail spool. module MAIL: 1 messages (0 read, 1 unread, 0 cleared). 10 bytes total. Different text, different scope, different meaning. Under #1614 the module is the shipped default, so every game's @mail/stats changed meaning on upgrade. The engine is the oracle: three cumulative detail levels, an optional target, and "no target from a wizard" meaning the whole spool. Deferring was not an option -- returning MUX_E_NOTIMPLEMENTED yields "That @mail command is not available." rather than falling through -- so the module implements all three. Engine and module output is now byte-identical across all six command forms, verified against one shared database. The count-bearing strings were T(), so mail statistics were untranslatable AND read "There are 1 messages" -- the #1622 shape. MN_()/M_() fixes both. Raw snprintf is now a build error at new call sites. It counts bytes, and C11 7.21.6.5 and 7.21.6.1p8 both truncate mid-sequence; measured, snprintf turns a 4-codepoint moniker into E6 97 A5 E6 9C AC E8 -- a lone lead byte, invalid UTF-8, sent to a player. mux_vsnprintf advances a whole codepoint at a time and stops before emitting a partial one. Every one of these sites interpolates player-settable text into a fixed buffer. The reason 280 such sites accumulated is that stringutil.h cannot be included from a module: its inlines want Ragel tables only the engine links, so the correct primitive was unreachable at compile time. mux_format.h declares the exported ones, removing the excuse; check_formats.py then freezes the count per file so existing code stays green and site #281 fails the build. Both directions of the ratchet are tested. Known divergence: the engine charges mudconf.searchcost, which no module-visible interface exposes. payfor() exempts wizards and every cross-player query is wizard-gated, so this is reachable only by a non-wizard running stats on themselves. Filed rather than bent into an ABI change here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:15:05 -06:00
tprintf(MN_("%d message received, %d unread, %d cleared, totalling %d characters.",
"%d messages received, %d unread, %d cleared, totalling %d characters.",
tr + tu + tc),
tc + tr + tu, tu, tc, tchars));
}
if (tc + tr + tu > 0)
{
fix(mail): the module's three stats levels, and ban raw snprintf (#1631) @mail/stats, @mail/dstats and @mail/fstats were one invented per-player summary served identically for all three switches, ignoring both the [<player>] argument and the wizard gate: engine There are 1 messages in the mail spool. module MAIL: 1 messages (0 read, 1 unread, 0 cleared). 10 bytes total. Different text, different scope, different meaning. Under #1614 the module is the shipped default, so every game's @mail/stats changed meaning on upgrade. The engine is the oracle: three cumulative detail levels, an optional target, and "no target from a wizard" meaning the whole spool. Deferring was not an option -- returning MUX_E_NOTIMPLEMENTED yields "That @mail command is not available." rather than falling through -- so the module implements all three. Engine and module output is now byte-identical across all six command forms, verified against one shared database. The count-bearing strings were T(), so mail statistics were untranslatable AND read "There are 1 messages" -- the #1622 shape. MN_()/M_() fixes both. Raw snprintf is now a build error at new call sites. It counts bytes, and C11 7.21.6.5 and 7.21.6.1p8 both truncate mid-sequence; measured, snprintf turns a 4-codepoint moniker into E6 97 A5 E6 9C AC E8 -- a lone lead byte, invalid UTF-8, sent to a player. mux_vsnprintf advances a whole codepoint at a time and stops before emitting a partial one. Every one of these sites interpolates player-settable text into a fixed buffer. The reason 280 such sites accumulated is that stringutil.h cannot be included from a module: its inlines want Ragel tables only the engine links, so the correct primitive was unreachable at compile time. mux_format.h declares the exported ones, removing the excuse; check_formats.py then freezes the count per file so existing code stays green and site #281 fails the build. Both directions of the ratchet are tested. Known divergence: the engine charges mudconf.searchcost, which no module-visible interface exposes. payfor() exempts wizards and every cross-player query is wizard-gated, so this is reachable only by a non-wizard running stats on themselves. Filed rather than bent into an ABI change here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:15:05 -06:00
raw_notify(player, tprintf(M_("Last is dated %s"), last));
}
}
/*-------------------------------------------------------------------------*
* Main mail routine for @mail w/o a switch
*-------------------------------------------------------------------------*/
static void do_mail_stub(dbref player, UTF8 *arg1, UTF8 *arg2)
{
if (!arg1 || !*arg1)
{
if (arg2 && *arg2)
{
raw_notify(player, M_("MAIL: Invalid mail command."));
return;
}
// Just the "@mail" command.
//
2018-10-03 17:54:51 +00:00
do_mail_list(player, arg1, nullptr, true);
return;
}
// purge a player's mailbox
//
if (!mux_stricmp(arg1, T("purge")))
{
do_mail_purge(player);
return;
}
// clear message
//
if (!mux_stricmp(arg1, T("clear")))
{
do_mail_clear(player, arg2);
return;
}
if (!mux_stricmp(arg1, T("unclear")))
{
do_mail_unclear(player, arg2);
return;
}
if (arg2 && *arg2)
{
// Sending mail
//
do_expmail_start(player, arg1, arg2);
return;
}
else
{
// Must be reading or listing mail - no arg2
//
2007-09-03 17:57:33 -07:00
if ( mux_isdigit(*arg1)
&& !strchr(reinterpret_cast<char *>(arg1), '-'))
{
2018-10-03 17:54:51 +00:00
do_mail_read(player, arg1, nullptr);
}
else
{
2018-10-03 17:54:51 +00:00
do_mail_list(player, arg1, nullptr, true);
}
return;
}
}
static void malias_write(FILE *fp)
{
putref(fp, static_cast<int>(malias.size()));
for (size_t i = 0; i < malias.size(); i++)
{
malias_t *m = malias[i].get();
mux_fprintf(fp, T("%d %d\n"), m->owner, static_cast<int>(m->list.size()));
mux_fprintf(fp, T("N:%s\n"), m->name.c_str());
mux_fprintf(fp, T("D:%s\n"), m->desc.c_str());
for (size_t j = 0; j < m->list.size(); j++)
{
putref(fp, m->list[j]);
}
}
}
static void save_malias(FILE *fp)
{
2009-01-02 09:16:25 -08:00
mux_fprintf(fp, T("*** Begin MALIAS ***\n"));
malias_write(fp);
}
int dump_mail(FILE *fp)
{
dbref thing;
int count = 0, i;
// Write out version number
//
2009-01-02 09:16:25 -08:00
mux_fprintf(fp, T("+V6\n"));
putref(fp, mudstate.mail_db_top);
DO_WHOLE_DB(thing)
{
if (isPlayer(thing))
{
MailList ml(thing);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
putref(fp, mp->to);
putref(fp, mp->from);
putref(fp, mp->number);
putstring(fp, utf8(mp->tolist));
putstring(fp, utf8(mp->time));
putstring(fp, utf8(mp->subject));
putref(fp, mp->read);
count++;
}
}
}
2009-01-02 09:16:25 -08:00
mux_fprintf(fp, T("*** END OF DUMP ***\n"));
// Add the db of mail messages
//
for (i = 0; i < mudstate.mail_db_top; i++)
{
if (0 < mail_list[i].m_nRefs)
{
putref(fp, i);
putstring(fp, MessageFetch(i));
}
}
2009-01-02 09:16:25 -08:00
mux_fprintf(fp, T("+++ END OF DUMP +++\n"));
save_malias(fp);
return count;
}
2012-03-16 15:52:41 -07:00
static void malias_read(FILE *fp, bool bConvert);
static void load_mail_V6(FILE *fp)
{
int mail_top = getref(fp);
mail_db_grow(mail_top + 1);
size_t nBuffer;
UTF8 *pBuffer;
2012-03-16 15:52:41 -07:00
UTF8 nbuf1[200];
UTF8 *p = reinterpret_cast<UTF8 *>(fgets(reinterpret_cast<char *>(nbuf1), sizeof(nbuf1), fp));
2018-10-03 17:54:51 +00:00
while ( nullptr != p
&& strncmp(reinterpret_cast<char *>(nbuf1), "***", 3) != 0)
{
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
dbref to = mux_atoi64(nbuf1);
auto& lst = mail_storage[ to ];
lst.emplace_back();
mail& m = lst.back();
m.to = to;
m.from = getref(fp);
m.number = getref(fp);
MessageReferenceInc(m.number);
pBuffer = reinterpret_cast<UTF8 *>(getstring_noalloc(fp, true, &nBuffer));
m.tolist.assign(reinterpret_cast<const char *>(pBuffer), nBuffer);
pBuffer = reinterpret_cast<UTF8 *>(getstring_noalloc(fp, true, &nBuffer));
m.time.assign(reinterpret_cast<const char *>(pBuffer), nBuffer);
pBuffer = reinterpret_cast<UTF8 *>(getstring_noalloc(fp, true, &nBuffer));
m.subject.assign(reinterpret_cast<const char *>(pBuffer), nBuffer);
m.read = getref(fp);
m.sqlite_id = -1;
p = reinterpret_cast<UTF8 *>(fgets(reinterpret_cast<char *>(nbuf1), sizeof(nbuf1), fp));
}
p = reinterpret_cast<UTF8 *>(fgets(reinterpret_cast<char *>(nbuf1), sizeof(nbuf1), fp));
2018-10-03 17:54:51 +00:00
while ( nullptr != p
&& strncmp(reinterpret_cast<char *>(nbuf1), "+++", 3) != 0)
{
int64_t number = mux_atoi64(nbuf1);
pBuffer = reinterpret_cast<UTF8 *>(getstring_noalloc(fp, true, &nBuffer));
new_mail_message(pBuffer, number);
p = reinterpret_cast<UTF8 *>(fgets(reinterpret_cast<char *>(nbuf1), sizeof(nbuf1), fp));
}
2012-03-16 15:52:41 -07:00
p = reinterpret_cast<UTF8 *>(fgets(reinterpret_cast<char *>(nbuf1), sizeof(nbuf1), fp));
2018-10-03 17:54:51 +00:00
if ( nullptr != p
&& strcmp(reinterpret_cast<char *>(nbuf1), "*** Begin MALIAS ***\n") == 0)
2012-03-16 15:52:41 -07:00
{
malias_read(fp, false);
}
else
{
Log.WriteString(T("ERROR: Couldnt find Begin MALIAS." ENDLINE));
2012-03-16 15:52:41 -07:00
}
}
static void load_mail_V5(FILE *fp)
{
int mail_top = getref(fp);
mail_db_grow(mail_top + 1);
size_t nBufferLatin1;
char *pBufferLatin1;
size_t nBufferUnicode;
UTF8 *pBufferUnicode;
2012-03-16 15:52:41 -07:00
char nbuf1[200];
char *p = fgets(nbuf1, sizeof(nbuf1), fp);
2018-10-03 17:54:51 +00:00
while ( nullptr != p
&& strncmp(nbuf1, "***", 3) != 0)
{
pBufferUnicode = reinterpret_cast<UTF8 *>(nbuf1);
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
dbref to = mux_atoi64(pBufferUnicode);
auto& lst = mail_storage[to];
lst.emplace_back();
mail& m = lst.back();
m.to = to;
m.from = getref(fp);
m.number = getref(fp);
MessageReferenceInc(m.number);
pBufferLatin1 = reinterpret_cast<char *>(getstring_noalloc(fp, true, &nBufferLatin1));
pBufferUnicode = ConvertToUTF8(pBufferLatin1, &nBufferUnicode);
m.tolist.assign(reinterpret_cast<const char *>(pBufferUnicode), nBufferUnicode);
pBufferLatin1 = reinterpret_cast<char *>(getstring_noalloc(fp, true, &nBufferLatin1));
pBufferUnicode = ConvertToUTF8(pBufferLatin1, &nBufferUnicode);
m.time.assign(reinterpret_cast<const char *>(pBufferUnicode), nBufferUnicode);
pBufferLatin1 = reinterpret_cast<char *>(getstring_noalloc(fp, true, &nBufferLatin1));
pBufferUnicode = ConvertToUTF8(pBufferLatin1, &nBufferUnicode);
m.subject.assign(reinterpret_cast<const char *>(pBufferUnicode), nBufferUnicode);
m.read = getref(fp);
m.sqlite_id = -1;
p = fgets(nbuf1, sizeof(nbuf1), fp);
}
p = fgets(nbuf1, sizeof(nbuf1), fp);
2018-10-03 17:54:51 +00:00
while ( nullptr != p
2012-03-16 15:52:41 -07:00
&& strncmp(nbuf1, "+++", 3) != 0)
{
pBufferUnicode = reinterpret_cast<UTF8 *>(nbuf1);
int64_t number = mux_atoi64(pBufferUnicode);
pBufferLatin1 = reinterpret_cast<char *>(getstring_noalloc(fp, true, &nBufferLatin1));
pBufferUnicode = ConvertToUTF8(pBufferLatin1, &nBufferUnicode);
new_mail_message(pBufferUnicode, number);
p = fgets(nbuf1, sizeof(nbuf1), fp);
}
2012-03-16 15:52:41 -07:00
p = fgets(nbuf1, sizeof(nbuf1), fp);
2018-10-03 17:54:51 +00:00
if ( nullptr != p
2012-03-16 15:52:41 -07:00
&& strcmp(nbuf1, "*** Begin MALIAS ***\n") == 0)
{
malias_read(fp, true);
}
else
{
Log.WriteString(T("ERROR: Couldnt find Begin MALIAS." ENDLINE));
2012-03-16 15:52:41 -07:00
}
}
// A mail alias description can be any combination of upper-case letters,
// lower-case letters, digits, blanks, and symbols. ANSI is permitted.
// Length is limited to SIZEOF_MALIASDESC-1. Visual width is limited to
// WIDTHOF_MALIASDESC. Case is preserved.
//
UTF8 *MakeCanonicalMailAliasDesc
(
feat(#2136): flip fargs to const UTF8 * const — and convert every site the compiler surfaced 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>
2026-08-06 14:34:23 -06:00
const UTF8 *pMailAliasDesc,
size_t *pnValidMailAliasDesc,
bool *pbValidMailAliasDesc,
size_t *pnVisualWidth
)
{
*pnValidMailAliasDesc = 0;
*pbValidMailAliasDesc = false;
*pnVisualWidth = 0;
if (!pMailAliasDesc)
{
2018-10-03 17:54:51 +00:00
return nullptr;
}
// Remove all '\r\n\t' from the string.
// Terminate any ANSI in the string.
//
Convert all static scratch buffers to thread_local 43 static scratch-buffer arrays across 21 files (5 in mux/src, 38 in mux/modules/) changed from `static` to `thread_local`. Under the current single-threaded evaluator this is a zero-behavior-change swap — `thread_local` storage has the same lifetime and zero-allocation properties as `static` — but each thread gets its own copy, which makes these functions safe for a future multi-threaded evaluator without any locking. Read-only constant tables (`aRadix64`, `aRadixPenn36`, `aRadixPenn64`, `Empty`) left as `static` because they are immutable shared data. Affected areas: net.cpp — queue_string co_buf, trimmed_site, dump_users NameField signals.cpp — signal_desc stubslave.cpp — Stub_PipePump attrcache.cpp — sqlite_attr_buf boolexp.cpp — parsestore command.cpp — preserve_cmd, SpaceCompressCommand, LowerCaseCommand comsys.cpp — NewTitle, Buffer, temp db.cpp — tbuff, Buffer (x2) flags.cpp — buff funceval.cpp — textbuff functions.cpp — TimeBuffer64, TimeBuffer80, Buffer help.cpp — Line, Buffer mail.cpp — aFolders, Buffer, res, szFittedMailAliasDesc match.cpp — buffer player.cpp — szSalt, buf (x2), buff plusemail.cpp — buf predicates.cpp — Buf (x2), pName session.cpp — szFittedDoing set.cpp — pRestrictedKeyText unparse.cpp — buf, boolexp_buf mail_mod.cpp — result, res, buf All 21 files verified with g++ -std=c++17 -fsyntax-only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:21:14 -06:00
thread_local UTF8 szFittedMailAliasDesc[SIZEOF_MALIASDESC+1];
mux_field nValidMailAliasDesc = StripTabsAndTruncate
( pMailAliasDesc,
szFittedMailAliasDesc,
SIZEOF_MALIASDESC,
WIDTHOF_MALIASDESC
);
*pnValidMailAliasDesc = nValidMailAliasDesc.m_byte;
*pbValidMailAliasDesc = true;
*pnVisualWidth = nValidMailAliasDesc.m_column;
return szFittedMailAliasDesc;
}
static void malias_read(FILE *fp, bool bConvert)
{
int count = getref(fp);
if (count <= 0)
{
return;
}
LBuf buffer = LBuf_Src("malias_read");
harden: reset malias table counts on allocation failure Follow-up to 315cfb078 (Grok review). Three malias allocation sites set the count/capacity globals *before* the allocation and left them stale when the allocation failed, corrupting the malias table state: 1. malias_read (DB load): `ma_size = ma_top = i` was set from the (attacker-controlled) file count before `new malias_t*[i]`. On a huge count the allocation throws, is caught, malias stays null, and the function returns with ma_top still huge. Post-load and runtime paths that loop to ma_top / index malias[] then walk a null pointer (crash) or spin a huge loop (DoS). Reachable from a crafted mail.db -- same threat model as the prior commits. This is the state the 315cfb078 message under-described as a merely "bounded memory-DoS". 2. @malias add, first-alias branch: ma_size was set to MA_INC before the allocation; on failure it stayed nonzero with malias null, so the next add skipped the (re)allocation and dereferenced malias[ma_top]. 3. @malias add, grow branch: ma_size was incremented by MA_INC before allocating the new array; on failure malias still pointed at the smaller old array while ma_size claimed the larger capacity, so subsequent adds wrote past the real allocation -- a heap overflow. Fix: on each allocation failure, reset the count/capacity to match the array that actually exists (0 for the two fresh-alloc paths; restore the pre-grow size for the grow path). The SQLite load path already sets its counts only after a successful allocation and needed no change. Verified by full rebuild + smoke suite (1264/1264, 0 crashes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:49:49 -06:00
// Build into a temporary so that on any error (truncated file, OOM mid-load)
// the unique_ptr destructors (and malias_t dtor) automatically clean up
// anything allocated so far. Only commit on full or best-effort success.
std::vector<std::unique_ptr<malias_t>> tmp;
tmp.reserve(count);
for (int i = 0; i < count; i++)
{
// Format is: "%d %d\n", &(m->owner), &(m->numrecep)
//
if (!fgets(reinterpret_cast<char *>(buffer.get()), LBUF_SIZE, fp))
{
// Truncated flatfile: commit whatever we successfully read so far.
STARTLOG(LOG_BUGS, "BUG", "MAIL");
log_text(T("Unexpected end of file. Mail bag truncated."));
ENDLOG;
break;
}
auto m = std::make_unique<malias_t>();
UTF8 *p = reinterpret_cast<UTF8 *>(strchr(reinterpret_cast<char *>(buffer.get()), ' '));
m->owner = 0;
int numrecep = 0;
if (p)
{
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
m->owner = mux_atoi64(buffer);
numrecep = mux_atoi64(p + 1);
}
// The format of @malias name is "N:<name>\n".
//
size_t nLen = GetLineTrunc(buffer, LBUF_SIZE, fp);
buffer[nLen - 1] = '\0'; // Get rid of trailing '\n'.
UTF8 *pBufferUnicode;
if (bConvert)
{
size_t nBufferUnicode;
pBufferUnicode = ConvertToUTF8(reinterpret_cast<char *>(buffer.get()), &nBufferUnicode);
}
else
{
pBufferUnicode = buffer;
}
size_t nMailAlias;
bool bMailAlias;
UTF8 *pMailAlias = MakeCanonicalMailAlias(pBufferUnicode + 2,
&nMailAlias,
&bMailAlias);
if (bMailAlias)
{
m->name.assign(reinterpret_cast<const char *>(pMailAlias), nMailAlias);
}
else
{
m->name = "Invalid";
}
// The format of the description is "D:<description>\n"
//
nLen = GetLineTrunc(buffer, LBUF_SIZE, fp);
if (bConvert)
{
size_t nBufferUnicode;
pBufferUnicode = ConvertToUTF8(reinterpret_cast<char *>(buffer.get()), &nBufferUnicode);
}
else
{
pBufferUnicode = buffer;
}
size_t nMailAliasDesc;
bool bMailAliasDesc;
size_t nVisualWidth;
UTF8 *pMailAliasDesc = MakeCanonicalMailAliasDesc(pBufferUnicode + 2,
&nMailAliasDesc,
&bMailAliasDesc,
&nVisualWidth);
if (bMailAliasDesc)
{
m->desc.assign(reinterpret_cast<const char *>(pMailAliasDesc), nMailAliasDesc);
m->desc_width = nVisualWidth;
}
else
{
m->desc = "Invalid Desc";
m->desc_width = 12;
}
if (numrecep > 0)
{
// Clamp a hostile/corrupt recipient count.
if (numrecep > (LBUF_SIZE + 1) / 2)
harden: fix five memory-safety/DoS bugs in database load paths Audit of the DB load paths (flatfile reader, mail/malias loader, SQLite attribute bulk-load) under the malicious/corrupt-database threat model -- the same class as #834-843. Five distinct bugs, all in code the June hardening (#806/#808/#841/#843) did not reach; each is reachable only from crafted or tampered database content, not from normal gameplay. Verified by full rebuild + smoke suite (1264/1264, 0 crashes), which exercises flatfile export/import and SQLite attribute reads end to end. 1. getstring_noalloc: static buffer overflow (lib/dbutil.cpp) The escaped-string reader mis-accumulated its output-byte count: `nOutput = pOutput - p` overwrote the count from prior escape emits instead of adding to it. On the multi-fgets refill path this under-decrements nBufferLeft, so the `nBufferLeft <= 0` guard never trips and the next fgets writes past the 2*LBUF_SIZE+20 static buffer. Legitimate attributes (<= LBUF) never take the refill path, so this only fires on a crafted quoted string > ~64KB. Fix: accumulate (+=). 2. make_numlist / malias_read: stack buffer overflow (modules/engine/mail.cpp) malias_read read the recipient count (numrecep) straight from the file and pushed that many dbrefs into m->list with no cap, making m->list.size() attacker-controlled. make_numlist then copied all of m->list into the fixed stack array aRecip[(LBUF_SIZE+1)/2] with no bound on nRecip -- a crafted mail.db with numrecep > 16384 overflows the stack (with attacker-chosen dbrefs) the next time any player mails the alias. Fix: clamp numrecep to (LBUF_SIZE+1)/2 at load (also bounds the reserve() that could otherwise exhaust memory on an INT_MAX count), and defensively bound the copy loop in make_numlist. 3. SQLite bulk-load attribute value: heap buffer overflow (modules/engine/sqlitedb.cpp) GetAllAttributes/GetBuiltinAttributes passed the raw column blob length to the cache with no clamp, unlike the write path (cache_put) and the standalone read path (GetAttribute), both of which clamp to LBUF_SIZE. A value blob written directly into the SQLite file therefore flows unclamped to atr_get_str_LEN's `memcpy(s, buff, (*pLen)+1)` into a fixed LBUF_SIZE buffer, overflowing the heap on first read of the attribute (Name/look/examine/get). Fix: clamp len to LBUF_SIZE in both bulk-load functions, mirroring the existing clamps. 4. get_list: infinite loop + unbounded log on truncated flatfile (modules/engine/db_rw.cpp) get_list had no EOF case: at end-of-file getc() returns EOF, falls to default, and calls getstring_noalloc(), which makes no progress at EOF (ungetc(EOF) is a no-op, fgets returns NULL). The for(;;) then spins forever, pegging a core and emitting log lines. Trigger: a flatfile whose last object's attribute list is truncated before its '<' terminator. Fix: add a case EOF that aborts the load. 5. getboolexp1: BOOLEXP subtree leak on malformed v2 lock (modules/engine/db_rw.cpp) Three error paths in the v2 lock parser returned TRUE_BOOLEXP without freeing the partially built node/subtree (bad connective, missing ')', EOF mid lock-string). Bounded and single-shot (db_read aborts the whole load on corruption; nesting capped at 1024), but still a leak on crafted input. Fix: free the appropriate node/subtree at each site, matching the partial-construction state. Also traced and dropped a sixth candidate (SQLite attribute owner read unvalidated): the attribute owner is never used to index db[] -- only compared, read for its flags, or re-stored -- so a corrupt owner is harmless. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:52:53 -06:00
{
numrecep = (LBUF_SIZE + 1) / 2;
harden: fix five memory-safety/DoS bugs in database load paths Audit of the DB load paths (flatfile reader, mail/malias loader, SQLite attribute bulk-load) under the malicious/corrupt-database threat model -- the same class as #834-843. Five distinct bugs, all in code the June hardening (#806/#808/#841/#843) did not reach; each is reachable only from crafted or tampered database content, not from normal gameplay. Verified by full rebuild + smoke suite (1264/1264, 0 crashes), which exercises flatfile export/import and SQLite attribute reads end to end. 1. getstring_noalloc: static buffer overflow (lib/dbutil.cpp) The escaped-string reader mis-accumulated its output-byte count: `nOutput = pOutput - p` overwrote the count from prior escape emits instead of adding to it. On the multi-fgets refill path this under-decrements nBufferLeft, so the `nBufferLeft <= 0` guard never trips and the next fgets writes past the 2*LBUF_SIZE+20 static buffer. Legitimate attributes (<= LBUF) never take the refill path, so this only fires on a crafted quoted string > ~64KB. Fix: accumulate (+=). 2. make_numlist / malias_read: stack buffer overflow (modules/engine/mail.cpp) malias_read read the recipient count (numrecep) straight from the file and pushed that many dbrefs into m->list with no cap, making m->list.size() attacker-controlled. make_numlist then copied all of m->list into the fixed stack array aRecip[(LBUF_SIZE+1)/2] with no bound on nRecip -- a crafted mail.db with numrecep > 16384 overflows the stack (with attacker-chosen dbrefs) the next time any player mails the alias. Fix: clamp numrecep to (LBUF_SIZE+1)/2 at load (also bounds the reserve() that could otherwise exhaust memory on an INT_MAX count), and defensively bound the copy loop in make_numlist. 3. SQLite bulk-load attribute value: heap buffer overflow (modules/engine/sqlitedb.cpp) GetAllAttributes/GetBuiltinAttributes passed the raw column blob length to the cache with no clamp, unlike the write path (cache_put) and the standalone read path (GetAttribute), both of which clamp to LBUF_SIZE. A value blob written directly into the SQLite file therefore flows unclamped to atr_get_str_LEN's `memcpy(s, buff, (*pLen)+1)` into a fixed LBUF_SIZE buffer, overflowing the heap on first read of the attribute (Name/look/examine/get). Fix: clamp len to LBUF_SIZE in both bulk-load functions, mirroring the existing clamps. 4. get_list: infinite loop + unbounded log on truncated flatfile (modules/engine/db_rw.cpp) get_list had no EOF case: at end-of-file getc() returns EOF, falls to default, and calls getstring_noalloc(), which makes no progress at EOF (ungetc(EOF) is a no-op, fgets returns NULL). The for(;;) then spins forever, pegging a core and emitting log lines. Trigger: a flatfile whose last object's attribute list is truncated before its '<' terminator. Fix: add a case EOF that aborts the load. 5. getboolexp1: BOOLEXP subtree leak on malformed v2 lock (modules/engine/db_rw.cpp) Three error paths in the v2 lock parser returned TRUE_BOOLEXP without freeing the partially built node/subtree (bad connective, missing ')', EOF mid lock-string). Bounded and single-shot (db_read aborts the whole load on corruption; nesting capped at 1024), but still a leak on crafted input. Fix: free the appropriate node/subtree at each site, matching the partial-construction state. Also traced and dropped a sixth candidate (SQLite attribute owner read unvalidated): the attribute owner is never used to index db[] -- only compared, read for its flags, or re-stored -- so a corrupt owner is harmless. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:52:53 -06:00
}
m->list.reserve(numrecep);
for (int j = 0; j < numrecep; j++)
{
int k = getref(fp);
m->list.push_back(k);
}
}
tmp.push_back(std::move(m));
}
// Commit (replace) the table with what we loaded.
malias = std::move(tmp);
}
static void load_malias(FILE *fp, bool bConvert)
{
UTF8 buffer[200];
getref(fp);
if ( fgets(reinterpret_cast<char *>(buffer), sizeof(buffer), fp)
&& strcmp(reinterpret_cast<char *>(buffer), "*** Begin MALIAS ***\n") == 0)
{
malias_read(fp, bConvert);
}
else
{
Log.WriteString(T("ERROR: Couldnt find Begin MALIAS." ENDLINE));
return;
}
}
void load_mail(FILE *fp)
{
UTF8 nbuf1[8];
// Read the version number.
//
if (!fgets(reinterpret_cast<char *>(nbuf1), sizeof(nbuf1), fp))
{
return;
}
if (strncmp(reinterpret_cast<char *>(nbuf1), "+V6", 3) == 0)
{
// Started v6 on 2007-MAR-13.
//
load_mail_V6(fp);
}
else if (strncmp(reinterpret_cast<char *>(nbuf1), "+V5", 3) == 0)
{
load_mail_V5(fp);
}
}
void check_mail_expiration(void)
{
if (nullptr != mudstate.pIMailControl)
{
MUX_RESULT mr = mudstate.pIMailControl->ExpireMail();
if (MUX_SUCCEEDED(mr))
{
return;
}
}
// Negative values for expirations never expire.
//
if (0 > mudconf.mail_expiration)
{
return;
}
dbref thing;
int expire_secs = mudconf.mail_expiration * 86400;
CLinearTimeAbsolute ltaNow;
ltaNow.GetLocal();
CLinearTimeAbsolute ltaMail;
DO_WHOLE_DB(thing)
{
if (No_Mail_Expire(thing))
{
continue;
}
MailList ml(thing);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if (M_Safe(mp))
{
continue;
}
const UTF8 *pMailTimeStr = utf8(mp->time);
if (!ltaMail.SetString(pMailTimeStr))
{
continue;
}
CLinearTimeDelta ltd(ltaMail, ltaNow);
if (ltd.ReturnSeconds() <= expire_secs)
{
continue;
}
// Delete this one.
//
ml.RemoveItem();
}
}
}
void check_mail(dbref player, int folder, bool silent)
{
if (nullptr != mudstate.pIMailControl)
{
MUX_RESULT mr = mudstate.pIMailControl->CheckMail(player, folder, silent);
if (MUX_SUCCEEDED(mr))
{
return;
}
}
// Check for new @mail
//
int rc; // Read messages.
int uc; // Unread messages.
int cc; // Cleared messages.
int gc; // urgent messages.
// Just count messages
//
count_mail(player, folder, &rc, &uc, &cc);
urgent_mail(player, folder, &gc);
#ifdef MAIL_ALL_FOLDERS
fix(nls): split the folder summary so each count can be pluralised (#1717) The half of #1717 that could not be converted mechanically. MAIL: %d messages in folder %d [%s] (%d unread, %d cleared). Four independent counts in one sentence are four independent plural decisions, and MN_(s, p, n) selects one form from one n -- in Russian each of those nouns needs its own. No marking fixes that; the sentence has to become several. Now three lines, each governed by one count: en 1 message in folder 0 [unnamed]. 1 unread message. 0 cleared messages. es 1 mensaje en la carpeta 0 [unnamed]. 1 mensaje sin leer. 0 mensajes borrados. ko 폴더 0 [unnamed]에 메시지가 1개 있습니다. 읽지 않은 메시지가 1개 있습니다. 삭제된 메시지가 0개 있습니다. Spanish inflects; Korean uses its single form with the counter after the noun and reorders %2$d / %3$s ahead of %1$d, which is why %N$ (#1623) is load-bearing here rather than decorative. It also fixes the English: the old line read "1 messages". Fixed lines rather than suppressing zeros. A player checking their own mailbox gets the same three lines every time, and "0 cleared" is information rather than noise; output whose shape varies with content is harder for staff scripts than output that does not. All four call sites -- both engine and both module -- converted together so the implementations do not drift (#1614). ## @stats deliberately left alone %d objects = %d rooms, %d exits, %d things, %d players. (%d garbage) Same defect, different audience: a staff diagnostic scanned at a glance, where six lines of nearly-always-nonzero counts would be worse than the one line they replace, and the translation gain is negligible. Recorded rather than converted so the next reader knows it was considered. ## Catalogue fallout .pot 27 -> 30 plural entries, 977 msgids. All three catalogues msgmerge'd; xx repaired to 977/977 (each form prefixed from its own msgid, not msgmerge's copy of the old one); es and ko given real translations. es.po 279 -> 282/977 (28.9%) ko.po 310 -> 313/977 (32.0%) tests/comsys_conformance went red on the baseline, as designed -- the delta showed the new lines as CONTEXT rather than divergence, so engine and module changed in step. Re-blessed after reading it. make test: Smoke 1561 x3, conformance PASSED, tests/nls 7, ko 4, plural 43. TESTEXIT=0. Refs #1419, #1614, #1622, #1623, #1717. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 16:09:40 -06:00
// Three lines rather than one (#1717). Four independent counts in one
// sentence cannot be pluralised: MN_(s, p, n) chooses one form from one
// n, and in Russian each of those nouns needs its own. Split so every
// count governs its own sentence, which is the only shape gettext can
// express -- and which also fixes "1 messages" in English.
//
raw_notify(player,
fix(nls): split the folder summary so each count can be pluralised (#1717) The half of #1717 that could not be converted mechanically. MAIL: %d messages in folder %d [%s] (%d unread, %d cleared). Four independent counts in one sentence are four independent plural decisions, and MN_(s, p, n) selects one form from one n -- in Russian each of those nouns needs its own. No marking fixes that; the sentence has to become several. Now three lines, each governed by one count: en 1 message in folder 0 [unnamed]. 1 unread message. 0 cleared messages. es 1 mensaje en la carpeta 0 [unnamed]. 1 mensaje sin leer. 0 mensajes borrados. ko 폴더 0 [unnamed]에 메시지가 1개 있습니다. 읽지 않은 메시지가 1개 있습니다. 삭제된 메시지가 0개 있습니다. Spanish inflects; Korean uses its single form with the counter after the noun and reorders %2$d / %3$s ahead of %1$d, which is why %N$ (#1623) is load-bearing here rather than decorative. It also fixes the English: the old line read "1 messages". Fixed lines rather than suppressing zeros. A player checking their own mailbox gets the same three lines every time, and "0 cleared" is information rather than noise; output whose shape varies with content is harder for staff scripts than output that does not. All four call sites -- both engine and both module -- converted together so the implementations do not drift (#1614). ## @stats deliberately left alone %d objects = %d rooms, %d exits, %d things, %d players. (%d garbage) Same defect, different audience: a staff diagnostic scanned at a glance, where six lines of nearly-always-nonzero counts would be worse than the one line they replace, and the translation gain is negligible. Recorded rather than converted so the next reader knows it was considered. ## Catalogue fallout .pot 27 -> 30 plural entries, 977 msgids. All three catalogues msgmerge'd; xx repaired to 977/977 (each form prefixed from its own msgid, not msgmerge's copy of the old one); es and ko given real translations. es.po 279 -> 282/977 (28.9%) ko.po 310 -> 313/977 (32.0%) tests/comsys_conformance went red on the baseline, as designed -- the delta showed the new lines as CONTEXT rather than divergence, so engine and module changed in step. Re-blessed after reading it. make test: Smoke 1561 x3, conformance PASSED, tests/nls 7, ko 4, plural 43. TESTEXIT=0. Refs #1419, #1614, #1622, #1623, #1717. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 16:09:40 -06:00
tprintf(MN_("MAIL: %d message in folder %d [%s].",
"MAIL: %d messages in folder %d [%s].", rc + uc),
rc + uc, folder, get_folder_name(player, folder)));
raw_notify(player, tprintf(MN_("MAIL: %d unread message.",
"MAIL: %d unread messages.", uc), uc));
raw_notify(player, tprintf(MN_("MAIL: %d cleared message.",
"MAIL: %d cleared messages.", cc), cc));
#else // MAIL_ALL_FOLDERS
if (rc + uc > 0)
{
fix(nls): split the folder summary so each count can be pluralised (#1717) The half of #1717 that could not be converted mechanically. MAIL: %d messages in folder %d [%s] (%d unread, %d cleared). Four independent counts in one sentence are four independent plural decisions, and MN_(s, p, n) selects one form from one n -- in Russian each of those nouns needs its own. No marking fixes that; the sentence has to become several. Now three lines, each governed by one count: en 1 message in folder 0 [unnamed]. 1 unread message. 0 cleared messages. es 1 mensaje en la carpeta 0 [unnamed]. 1 mensaje sin leer. 0 mensajes borrados. ko 폴더 0 [unnamed]에 메시지가 1개 있습니다. 읽지 않은 메시지가 1개 있습니다. 삭제된 메시지가 0개 있습니다. Spanish inflects; Korean uses its single form with the counter after the noun and reorders %2$d / %3$s ahead of %1$d, which is why %N$ (#1623) is load-bearing here rather than decorative. It also fixes the English: the old line read "1 messages". Fixed lines rather than suppressing zeros. A player checking their own mailbox gets the same three lines every time, and "0 cleared" is information rather than noise; output whose shape varies with content is harder for staff scripts than output that does not. All four call sites -- both engine and both module -- converted together so the implementations do not drift (#1614). ## @stats deliberately left alone %d objects = %d rooms, %d exits, %d things, %d players. (%d garbage) Same defect, different audience: a staff diagnostic scanned at a glance, where six lines of nearly-always-nonzero counts would be worse than the one line they replace, and the translation gain is negligible. Recorded rather than converted so the next reader knows it was considered. ## Catalogue fallout .pot 27 -> 30 plural entries, 977 msgids. All three catalogues msgmerge'd; xx repaired to 977/977 (each form prefixed from its own msgid, not msgmerge's copy of the old one); es and ko given real translations. es.po 279 -> 282/977 (28.9%) ko.po 310 -> 313/977 (32.0%) tests/comsys_conformance went red on the baseline, as designed -- the delta showed the new lines as CONTEXT rather than divergence, so engine and module changed in step. Re-blessed after reading it. make test: Smoke 1561 x3, conformance PASSED, tests/nls 7, ko 4, plural 43. TESTEXIT=0. Refs #1419, #1614, #1622, #1623, #1717. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 16:09:40 -06:00
raw_notify(player,
tprintf(MN_("MAIL: %d message in folder %d [%s].",
"MAIL: %d messages in folder %d [%s].", rc + uc),
rc + uc, folder, get_folder_name(player, folder)));
raw_notify(player, tprintf(MN_("MAIL: %d unread message.",
"MAIL: %d unread messages.", uc), uc));
raw_notify(player, tprintf(MN_("MAIL: %d cleared message.",
"MAIL: %d cleared messages.", cc), cc));
}
else if (!silent)
{
// Blank lines stay T("") (#1443); prose is M_ without embedded CR (#1419).
//
raw_notify(player, T(""));
raw_notify(player, M_("MAIL: You have no mail."));
raw_notify(player, T(""));
}
if (gc > 0)
{
fix(nls): plural morphology baked into msgids, and a fragment that could not be translated (#1717) Found while translating mail (#1419). The tell was a line wrong in English before translation is even involved: MAIL: 1 messages in folder 0 [unnamed] (1 unread, 0 cleared). MN_() exists for this (#1622) and #1655 converted the @mail/stats family; this set was missed. A translator cannot work around it -- the msgid has no msgid_plural, so there is no second form for a catalogue to supply, and es must pick one wording for all counts while ru and pl need three and can express none of them. The fix is in the C++, not the .po. ## Converted, 11 sites mail.cpp, mail_mod.cpp mailbox is full; URGENT MAIL urgent messages walkdb.cpp universe contains; objects @chowned; objects marked object.cpp objects @chowned to you wiz.cpp You toaded ... objects @chowned command.cpp at most N players logged in The mail module duplicates are converted alongside the engine's so the two implementations do not drift (#1614). ## One was a different bug than the issue described walkdb.cpp built its message as M_("%d objects %smarked") with %s = "" or "un". That is fragment assembly, not a missing plural: a translator handed that %s cannot use it, because the prefix is English morphology rather than a word. Split into two whole sentences, each pluralised -- the #1575 / #1588 rule. Now renders "1 object marked" / "8 objects unmarked" instead of a %s a catalogue has no way to fill. ## Two deliberately NOT converted MAIL: %d messages in folder %d [%s] (%d unread, %d cleared). %d objects = %d rooms, %d exits, %d things, %d players. (%d garbage) MN_(s, p, n) selects one form from one n. Four independent counts are four independent plural decisions, and in Russian each noun needs its own form. These want rewording, which is a design call rather than a mechanical conversion, so they are left and recorded rather than half-fixed. ## Catalogue fallout .pot 18 -> 27 plural entries; all three catalogues msgmerge'd. xx is `complete` policy, so its nine new entries had to be filled and unfuzzied or the guard fails -- which it duly did, first try. msgmerge had copied the old singular-only text into BOTH forms, so msgstr[0] said "messages"; each form is now prefixed from its own msgid. es and ko received real translations for all nine. es.po 190 -> 198/976 (20.3%) ko.po 229 -> 236/976 (24.2%) ## Verified end to end, at n=1 and n>1 en 1 object marked 8 objects marked es 1 objeto marcado 8 objetos marcados ko 사물 1개를 표시했습니다 사물 8개를 표시했습니다 xx [xx] 1 object marked [xx] 8 objects marked Korean renders identically for both, which is correct: it declares nplurals=1, so the catalogue's own rule is being honoured rather than English's imposed on it. The unconverted multi-count line still reads "1 messages", as documented. make test: Smoke 1561 x3, tests/nls 7, ko 4, plural 43. TESTEXIT=0. Refs #1419, #1575, #1588, #1614, #1622, #1655, #1702. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:29:08 -06:00
raw_notify(player, tprintf(MN_(
"URGENT MAIL: You have %d urgent message in folder %d [%s].",
"URGENT MAIL: You have %d urgent messages in folder %d [%s].", gc),
gc, folder, get_folder_name(player, folder)));
}
#endif // MAIL_ALL_FOLDERS
}
static void do_malias_send
(
dbref player,
UTF8 *tolist,
UTF8 *listto,
UTF8 *senderlistto,
UTF8 *subject,
int number,
mail_flag flags,
bool silent
)
{
int nResult;
malias_t *m = get_malias(player, tolist, &nResult);
if (nResult == GMA_INVALIDFORM)
{
raw_notify(player, tprintf(M_("MAIL: I cant figure out from %s who you want to mail to."), tolist));
return;
}
else if (nResult == GMA_NOTFOUND)
{
raw_notify(player, tprintf(M_("MAIL: Alias %s not found."), tolist));
return;
}
// Parse the player list.
//
dbref vic;
int k;
for (k = 0; k < static_cast<int>(m->list.size()); k++)
{
vic = m->list[k];
if (isPlayer(vic))
{
send_mail(player, m->list[k], (m->list[k] == player) ? senderlistto : listto, subject, number, flags, silent);
}
else
{
// Complain about it.
//
UTF8 *pMail = tprintf(M_("Alias Error: Bad Player %d for %s"), vic, tolist);
int iMail = add_mail_message(player, pMail);
if (iMail != NOTHING)
{
send_mail(GOD, GOD, listto, subject, iMail, 0, silent);
MessageReferenceDec(iMail);
}
}
}
}
static void do_malias_create(dbref player, UTF8 *alias, UTF8 *tolist)
{
int nResult;
get_malias(player, alias, &nResult);
if (nResult == GMA_INVALIDFORM)
{
raw_notify(player, M_("MAIL: What alias do you want to create?."));
return;
}
else if (nResult == GMA_FOUND)
{
raw_notify(player,
tprintf(M_("MAIL: Mail Alias %s already exists."), alias));
return;
}
auto pt = std::make_unique<malias_t>();
// Parse the player list.
//
UTF8 *head = tolist;
UTF8 *tail, spot;
UTF8 *buff;
dbref target;
int added = 0;
while (head && *head)
{
while (*head == ' ')
{
head++;
}
tail = head;
while (*tail && *tail != ' ')
{
if (*tail == '"')
{
head++;
tail++;
while (*tail && *tail != '"')
{
tail++;
}
}
if (*tail)
{
tail++;
}
}
tail--;
if (*tail != '"')
{
tail++;
}
spot = *tail;
*tail = '\0';
// Now locate a target.
//
if (!mux_stricmp(head, T("me")))
{
target = player;
}
else if (*head == '#')
{
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
target = mux_atoi64(head + 1);
}
else
{
target = lookup_player(player, head, true);
}
if (!Good_obj(target) || !isPlayer(target))
{
raw_notify(player, M_("MAIL: No such player."));
}
else
{
buff = unparse_object(player, target, false);
raw_notify(player,
tprintf(M_("MAIL: %s added to alias %s"), buff, alias));
pt->list.push_back(target);
added++;
free_lbuf(buff);
}
// Get the next recip.
//
*tail = spot;
head = tail;
if (*head == '"')
{
head++;
}
}
size_t nValidMailAlias;
bool bValidMailAlias;
UTF8 *pValidMailAlias = MakeCanonicalMailAlias(
alias + 1, &nValidMailAlias, &bValidMailAlias);
if (!bValidMailAlias)
{
raw_notify(player, M_("MAIL: Invalid mail alias."));
// pt (and any recipients pushed) will be cleaned up automatically
// when it goes out of scope.
return;
}
// The Mail Alias Description is a superset of the Mail Alias,
// so, the following code is not necessary unless the specification
// of the Mail Alias Description becomes more restrictive at some
// future time.
//
UTF8 *pValidMailAliasDesc = pValidMailAlias;
size_t nValidMailAliasDesc = nValidMailAlias;
pt->name.assign(reinterpret_cast<const char *>(pValidMailAlias), nValidMailAlias);
pt->owner = player;
pt->desc.assign(reinterpret_cast<const char *>(pValidMailAliasDesc), nValidMailAliasDesc);
pt->desc_width = nValidMailAliasDesc;
malias.push_back(std::move(pt));
sqlite_wt_sync_all_aliases();
raw_notify(player, tprintf(M_("MAIL: Alias set %s defined."), alias));
}
static void do_malias_list(dbref player, UTF8 *alias)
{
int nResult;
malias_t *m = get_malias(player, alias, &nResult);
if (nResult == GMA_NOTFOUND)
{
raw_notify(player, tprintf(M_("MAIL: Alias %s not found."), alias));
return;
}
if (nResult != GMA_FOUND)
{
return;
}
if (!ExpMail(player) && (player != m->owner) && !(God(m->owner)))
{
raw_notify(player, M_("MAIL: Permission denied."));
return;
}
UTF8 *buff = alloc_lbuf("do_malias_list");
UTF8 *bp = buff;
safe_tprintf_str(buff, &bp, M_("MAIL: Alias *%s: "), m->name.c_str());
for (int i = static_cast<int>(m->list.size()) - 1; i > -1; i--)
{
const UTF8 *p = Moniker(m->list[i]);
if (strchr(reinterpret_cast<const char *>(p), ' '))
{
safe_chr('"', buff, &bp);
safe_str(p, buff, &bp);
safe_chr('"', buff, &bp);
}
else
{
safe_str(p, buff, &bp);
}
safe_chr(' ', buff, &bp);
}
*bp = '\0';
raw_notify(player, buff);
free_lbuf(buff);
}
static void do_malias_list_all(dbref player)
{
std::vector<malias_t*> visible;
visible.reserve(malias.size());
for (size_t i = 0; i < malias.size(); i++)
{
malias_t *m = malias[i].get();
if (GOD == m->owner || m->owner == player || God(player))
{
visible.push_back(m);
}
}
// Use a comparator on raw pointers for the temp view.
auto cmp = [](malias_t* a, malias_t* b) {
return mux_stricmp(utf8(a->name), utf8(b->name)) < 0;
};
std::sort(visible.begin(), visible.end(), cmp);
// Mail alias list schema (#1667 Phase 4 B1) — shared with module:
// name 12 / description 40 / owner 15 via mux_table_*.
//
static const size_t kAliasNameCols = 12;
static const size_t kAliasDescCols = 40;
static const size_t kAliasOwnerCols = 15;
2007-08-21 21:50:41 -07:00
bool notified = false;
for (malias_t *m : visible)
{
if (!notified)
{
UTF8 header[LBUF_SIZE];
size_t pos = 0;
pos = mux_table_append_ljust(header, sizeof(header), pos,
M_("Name"), kAliasNameCols);
pos = mux_table_append_bytes(header, sizeof(header), pos, " ");
pos = mux_table_append_ljust(header, sizeof(header), pos,
M_("Description"), kAliasDescCols);
pos = mux_table_append_bytes(header, sizeof(header), pos, " ");
pos = mux_table_append_ljust(header, sizeof(header), pos,
M_("Owner"), kAliasOwnerCols);
raw_notify(player, header);
notified = true;
}
UTF8 line[LBUF_SIZE];
size_t pos = 0;
pos = mux_table_append_ljust(line, sizeof(line), pos,
reinterpret_cast<const UTF8 *>(m->name.c_str()), kAliasNameCols);
pos = mux_table_append_bytes(line, sizeof(line), pos, " ");
pos = mux_table_append_ljust(line, sizeof(line), pos,
reinterpret_cast<const UTF8 *>(m->desc.c_str()), kAliasDescCols);
pos = mux_table_append_bytes(line, sizeof(line), pos, " ");
pos = mux_table_append_ljust(line, sizeof(line), pos,
Moniker(m->owner), kAliasOwnerCols);
raw_notify(player, line);
}
raw_notify(player, M_("***** End of Mail Aliases *****"));
}
static void do_malias_switch(dbref player, UTF8 *a1, UTF8 *a2)
{
if (a1 && *a1)
{
if (a2 && *a2)
{
do_malias_create(player, a1, a2);
}
else
{
do_malias_list(player, a1);
}
}
else
{
do_malias_list_all(player);
}
}
static void do_mail_cc(dbref player, UTF8 *arg, bool bBlind)
{
if (!(Flags2(player) & PLAYER_MAILS))
{
raw_notify(player, M_("MAIL: No mail message in progress."));
return;
}
if (!arg || !*arg)
{
raw_notify(player, M_("MAIL: I do not know whom you want to mail."));
return;
}
UTF8 *tolist = make_numlist(player, arg, bBlind);
if (!tolist)
{
return;
}
UTF8 *fulllist = alloc_lbuf("do_mail_cc");
UTF8 *bp = fulllist;
safe_str(tolist, fulllist, &bp);
const UTF8 *pPlayerMailTo = atr_get_raw(player, A_MAILTO);
if (pPlayerMailTo)
{
safe_chr(' ', fulllist, &bp);
safe_str(pPlayerMailTo, fulllist, &bp);
}
*bp = '\0';
atr_add_raw(player, A_MAILTO, fulllist);
UTF8 *names = make_namelist(player, fulllist);
raw_notify(player, tprintf(M_("MAIL: You are sending mail to %s."), names));
free_lbuf(names);
free_lbuf(tolist);
free_lbuf(fulllist);
}
static void mail_to_list(dbref player, UTF8 *list, UTF8 *subject, UTF8 *message, int flags, bool silent)
{
if (!list)
{
return;
}
if (!*list)
{
free_lbuf(list);
return;
}
// Construct a tolist which excludes all the Blind Carbon Copy (BCC)
// recipients, and a senderlist which includes all recipients (so the
// sender's copy retains the full BCC information).
//
UTF8 *tolist = alloc_lbuf("mail_to_list");
UTF8 *p = tolist;
UTF8 *senderlist = alloc_lbuf("mail_to_list.senderlist");
UTF8 *sp = senderlist;
UTF8 *tail;
UTF8 *head = list;
while (*head)
{
while (*head == ' ')
{
head++;
}
tail = head;
while ( *tail
&& *tail != ' ')
{
if (*tail == '"')
{
head++;
tail++;
while ( *tail
&& *tail != '"')
{
tail++;
}
}
if (*tail)
{
tail++;
}
}
tail--;
if (*tail != '"')
{
tail++;
}
// Append every token to senderlist (including BCC entries).
//
if (sp != senderlist)
{
safe_chr(' ', senderlist, &sp);
}
memcpy(sp, head, tail - head);
sp += tail - head;
if (*head != '!')
{
if (p != tolist)
{
safe_chr(' ', tolist, &p);
}
memcpy(p, head, tail-head);
p += tail-head;
}
// Get the next recipient.
//
head = tail;
if (*head == '"')
{
head++;
}
}
*p = '\0';
*sp = '\0';
int number = add_mail_message(player, message);
if (number != NOTHING)
{
UTF8 spot;
head = list;
while (*head)
{
while (' ' == *head)
{
head++;
}
tail = head;
while ( *tail
&& *tail != ' ')
{
if (*tail == '"')
{
head++;
tail++;
while ( *tail
&& *tail != '"')
{
tail++;
}
}
if (*tail)
{
tail++;
}
}
tail--;
if (*tail != '"')
{
tail++;
}
spot = *tail;
*tail = '\0';
if (*head == '!')
{
head++;
}
if (*head == '*')
{
do_malias_send(player, head, tolist, senderlist, subject, number, flags, silent);
}
else
{
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
dbref target = mux_atoi64(head);
if ( Good_obj(target)
&& isPlayer(target))
{
send_mail(player, target, (target == player) ? senderlist : tolist, subject, number, flags, silent);
}
}
// Get the next recipient.
//
*tail = spot;
head = tail;
if (*head == '"')
{
head++;
}
}
MessageReferenceDec(number);
}
free_lbuf(senderlist);
free_lbuf(tolist);
free_lbuf(list);
}
static void do_mail_quick(dbref player, UTF8 *arg1, UTF8 *arg2)
{
if (!arg1 || !*arg1)
{
raw_notify(player, M_("MAIL: I dont know who you want to mail."));
return;
}
if (!arg2 || !*arg2)
{
raw_notify(player, M_("MAIL: No message."));
return;
}
if (Flags2(player) & PLAYER_MAILS)
{
raw_notify(player, M_("MAIL: Mail message already in progress."));
return;
}
if ( !Wizard(player)
&& ThrottleMail(player))
{
raw_notify(player, M_("MAIL: Too much @mail sent recently."));
return;
}
UTF8 *bufDest = alloc_lbuf("do_mail_quick");
UTF8 *bpSubject = bufDest;
mux_strncpy(bpSubject, arg1, LBUF_SIZE-1);
parse_to(&bpSubject, '/', 1);
if (!bpSubject)
{
raw_notify(player, M_("MAIL: No subject."));
free_lbuf(bufDest);
return;
}
mail_to_list(player, make_numlist(player, bufDest, false), bpSubject, arg2, 0, false);
free_lbuf(bufDest);
}
// ---------------------------------------------------------------------------
// do_mail_send_softcode: Entry point for mailsend() softcode function.
//
// Returns: 1 on success, or a static error string on failure.
//
const UTF8 *do_mail_send_softcode(dbref player, UTF8 *recipients, UTF8 *subject, UTF8 *message)
{
// #1191: When the mail module owns the store, send through SoftcodeSend
// so headers/bodies land in the same map as @mail commands.
//
if (nullptr != mudstate.pIMailControl)
{
const UTF8 *err = nullptr;
MUX_RESULT mr = mudstate.pIMailControl->SoftcodeSend(player,
recipients, subject, message, &err);
if (MUX_SUCCEEDED(mr))
{
return nullptr;
}
return (nullptr != err) ? err : S_("#-1 MAIL SEND FAILED");
}
// Resolve to the owning player — softcode may run on a non-player object.
//
player = Owner(player);
if (!Good_obj(player) || !isPlayer(player))
{
return S_("#-1 NOT A PLAYER");
}
if (!recipients || !*recipients)
{
return S_("#-1 NO RECIPIENTS");
}
if (!subject || !*subject)
{
return S_("#-1 NO SUBJECT");
}
if (!message || !*message)
{
return S_("#-1 NO MESSAGE");
}
if (Flags2(player) & PLAYER_MAILS)
{
return S_("#-1 MAIL ALREADY IN PROGRESS");
}
if ( !Wizard(player)
&& ThrottleMail(player))
{
return S_("#-1 TOO MUCH MAIL SENT");
}
UTF8 *numlist = make_numlist(player, recipients, false);
if (!numlist || !*numlist)
{
if (numlist)
{
free_lbuf(numlist);
}
return S_("#-1 NO VALID RECIPIENTS");
}
mail_to_list(player, numlist, subject, message, 0, true);
return nullptr;
}
static void do_expmail_stop(dbref player, int flags)
{
if ((Flags2(player) & PLAYER_MAILS) != PLAYER_MAILS)
{
raw_notify(player, M_("MAIL: No message started."));
return;
}
dbref aowner;
dbref aflags;
UTF8 *tolist = atr_get("do_expmail_stop.3854", player, A_MAILTO, & aowner, &aflags);
if (*tolist == '\0')
{
raw_notify(player, M_("MAIL: No recipients."));
free_lbuf(tolist);
}
else
{
UTF8 *pMailMsg = atr_get("do_expmail_stop.3862", player, A_MAILMSG, &aowner, &aflags);
if (*pMailMsg == '\0')
{
raw_notify(player, M_("MAIL: The body of this message is empty. Use - to add to the message."));
free_lbuf(tolist);
}
else
{
UTF8 *mailsub = atr_get("do_expmail_stop.3870", player, A_MAILSUB, &aowner, &aflags);
UTF8 *mailflags = atr_get("do_expmail_stop.3871", player, A_MAILFLAGS, &aowner, &aflags);
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
mail_to_list(player, tolist, mailsub, pMailMsg, flags | mux_atoi64(mailflags), false);
free_lbuf(mailflags);
free_lbuf(mailsub);
Flags2(player) &= ~PLAYER_MAILS;
}
free_lbuf(pMailMsg);
}
}
static void do_expmail_abort(dbref player)
{
Flags2(player) &= ~PLAYER_MAILS;
raw_notify(player, M_("MAIL: Message aborted."));
}
void do_prepend(dbref executor, dbref caller, dbref enactor, int eval, int key, UTF8 *text, const UTF8 *cargs[], int ncargs)
{
UNUSED_PARAMETER(key);
UNUSED_PARAMETER(cargs);
UNUSED_PARAMETER(ncargs);
if (Flags2(executor) & PLAYER_MAILS)
{
if ( !text
|| !*text)
{
raw_notify(executor, M_("No text prepended."));
return;
}
UTF8 *bufText = alloc_lbuf("do_prepend");
UTF8 *bpText = bufText;
mux_exec(text+1, LBUF_SIZE-1, bufText, &bpText, executor, caller, enactor,
2018-10-03 17:54:51 +00:00
eval|EV_STRIP_CURLY|EV_FCHECK|EV_EVAL, nullptr, 0);
*bpText = '\0';
dbref aowner;
int aflags;
UTF8 *oldmsg = atr_get("do_prepend.3915", executor, A_MAILMSG, &aowner, &aflags);
if (*oldmsg)
{
UTF8 *newmsg = alloc_lbuf("do_prepend");
UTF8 *bp = newmsg;
safe_str(bufText, newmsg, &bp);
safe_chr(' ', newmsg, &bp);
safe_str(oldmsg, newmsg, &bp);
*bp = '\0';
atr_add_raw(executor, A_MAILMSG, newmsg);
free_lbuf(newmsg);
}
else
{
atr_add_raw(executor, A_MAILMSG, bufText);
}
free_lbuf(bufText);
free_lbuf(oldmsg);
size_t nLen;
atr_get_raw_LEN(executor, A_MAILMSG, &nLen);
raw_notify(executor, tprintf(M_("%d/%d characters prepended."), nLen, LBUF_SIZE-1));
}
else
{
raw_notify(executor, M_("MAIL: No message in progress."));
}
}
void do_postpend(dbref executor, dbref caller, dbref enactor, int eval, int key, UTF8 *text, const UTF8 *cargs[], int ncargs)
{
UNUSED_PARAMETER(key);
UNUSED_PARAMETER(cargs);
UNUSED_PARAMETER(ncargs);
if ( text[1] == '-'
&& text[2] == '\0')
{
do_expmail_stop(executor, 0);
return;
}
if (Flags2(executor) & PLAYER_MAILS)
{
if ( !text
|| !*text)
{
raw_notify(executor, M_("No text added."));
return;
}
UTF8 *bufText = alloc_lbuf("do_prepend");
UTF8 *bpText = bufText;
mux_exec(text+1, LBUF_SIZE-1, bufText, &bpText, executor, caller, enactor,
2018-10-03 17:54:51 +00:00
eval|EV_STRIP_CURLY|EV_FCHECK|EV_EVAL, nullptr, 0);
*bpText = '\0';
dbref aowner;
int aflags;
UTF8 *oldmsg = atr_get("do_postpend.3978", executor, A_MAILMSG, &aowner, &aflags);
if (*oldmsg)
{
UTF8 *newmsg = alloc_lbuf("do_postpend");
UTF8 *bp = newmsg;
safe_str(oldmsg, newmsg, &bp);
safe_chr(' ', newmsg, &bp);
safe_str(bufText, newmsg, &bp);
*bp = '\0';
atr_add_raw(executor, A_MAILMSG, newmsg);
free_lbuf(newmsg);
}
else
{
atr_add_raw(executor, A_MAILMSG, bufText);
}
free_lbuf(bufText);
free_lbuf(oldmsg);
size_t nLen;
atr_get_raw_LEN(executor, A_MAILMSG, &nLen);
raw_notify(executor, tprintf(M_("%d/%d characters added."), nLen, LBUF_SIZE-1));
}
else
{
raw_notify(executor, M_("MAIL: No message in progress."));
}
}
static void do_edit_msg(dbref player, UTF8 *from, UTF8 *to)
{
if (Flags2(player) & PLAYER_MAILS)
{
dbref aowner;
int aflags;
UTF8 *msg = atr_get("do_edit_msg.4014", player, A_MAILMSG, &aowner, &aflags);
UTF8 *result = replace_string(from, to, msg);
atr_add(player, A_MAILMSG, result, aowner, aflags);
raw_notify(player, M_("Text edited."));
free_lbuf(result);
free_lbuf(msg);
}
else
{
raw_notify(player, M_("MAIL: No message in progress."));
}
}
static void do_mail_proof(dbref player)
{
if (!(Flags2(player) & PLAYER_MAILS))
{
raw_notify(player, M_("MAIL: No message in progress."));
return;
}
dbref aowner;
int aflags;
UTF8 *mailto = atr_get("do_mail_proof.4038", player, A_MAILTO, &aowner, &aflags);
UTF8 *pMailMsg = atr_get("do_mail_proof.4039", player, A_MAILMSG, &aowner, &aflags);
UTF8 *names = make_namelist(player, mailto);
UTF8 szSubjectBuffer[MBUF_SIZE];
StripTabsAndTruncate( atr_get_raw(player, A_MAILSUB), szSubjectBuffer,
MBUF_SIZE-1, 35);
UTF8 szFromName[MBUF_SIZE];
trimmed_name(player, szFromName, 16, 16, 0);
raw_notify(player, DASH_LINE);
raw_notify(player, tprintf(M_("From: %s Subject: %s\nTo: %s"),
szFromName, szSubjectBuffer, names));
raw_notify(player, DASH_LINE);
raw_notify(player, pMailMsg);
raw_notify(player, DASH_LINE);
free_lbuf(pMailMsg);
free_lbuf(names);
free_lbuf(mailto);
}
static void do_malias_desc(dbref player, UTF8 *alias, UTF8 *desc)
{
int nResult;
malias_t *m = get_malias(player, alias, &nResult);
if (nResult == GMA_NOTFOUND)
{
raw_notify(player, tprintf(M_("MAIL: Alias %s not found."), alias));
return;
}
if (nResult != GMA_FOUND)
{
return;
}
if ( m->owner != GOD
|| ExpMail(player))
{
size_t nValidMailAliasDesc;
bool bValidMailAliasDesc;
size_t nVisualWidth;
UTF8 *pValidMailAliasDesc = MakeCanonicalMailAliasDesc
( desc,
&nValidMailAliasDesc,
&bValidMailAliasDesc,
&nVisualWidth
);
if (bValidMailAliasDesc)
{
m->desc.assign(reinterpret_cast<const char *>(pValidMailAliasDesc), nValidMailAliasDesc);
m->desc_width = nVisualWidth;
sqlite_wt_sync_all_aliases();
raw_notify(player, M_("MAIL: Description changed."));
}
else
{
raw_notify(player, M_("MAIL: Description is not valid."));
}
}
else
{
raw_notify(player, M_("MAIL: Permission denied."));
}
}
static void do_malias_chown(dbref player, UTF8 *alias, UTF8 *owner)
{
if (!ExpMail(player))
{
raw_notify(player, M_("MAIL: You cannot do that!"));
return;
}
int nResult;
malias_t *m = get_malias(player, alias, &nResult);
if (nResult == GMA_NOTFOUND)
{
raw_notify(player, tprintf(M_("MAIL: Alias %s not found."), alias));
return;
}
if (nResult != GMA_FOUND)
{
return;
}
dbref no = lookup_player(player, owner, true);
if (no == NOTHING)
{
raw_notify(player, M_("MAIL: I do not see that here."));
return;
}
m->owner = no;
sqlite_wt_sync_all_aliases();
raw_notify(player, M_("MAIL: Owner changed for alias."));
}
static void do_malias_add(dbref player, UTF8 *alias, UTF8 *person)
{
int nResult;
malias_t *m = get_malias(player, alias, &nResult);
if (nResult == GMA_NOTFOUND)
{
raw_notify(player, tprintf(M_("MAIL: Alias %s not found."), alias));
return;
}
else if (nResult != GMA_FOUND)
{
return;
}
dbref thing = NOTHING;
if (*person == '#')
{
thing = parse_dbref(person + 1);
if (!isPlayer(thing))
{
raw_notify(player, M_("MAIL: Only players may be added."));
return;
}
}
if (thing == NOTHING)
{
thing = lookup_player(player, person, true);
}
if (thing == NOTHING)
{
raw_notify(player, M_("MAIL: I do not see that person here."));
return;
}
if ((m->owner == GOD) && !ExpMail(player))
{
raw_notify(player, M_("MAIL: Permission denied."));
return;
}
for (size_t i = 0; i < m->list.size(); i++)
{
if (m->list[i] == thing)
{
raw_notify(player, M_("MAIL: That person is already on the list."));
return;
}
}
m->list.push_back(thing);
sqlite_wt_sync_all_aliases();
raw_notify(player, tprintf(M_("MAIL: %s added to %s"), Moniker(thing), m->name.c_str()));
}
static void do_malias_remove(dbref player, UTF8 *alias, UTF8 *person)
{
int nResult;
malias_t *m = get_malias(player, alias, &nResult);
if (nResult == GMA_NOTFOUND)
{
raw_notify(player, tprintf(M_("MAIL: Alias %s not found."), alias));
return;
}
if (nResult != GMA_FOUND)
{
return;
}
if ((m->owner == GOD) && !ExpMail(player))
{
raw_notify(player, M_("MAIL: Permission denied."));
return;
}
dbref thing = NOTHING;
if (*person == '#')
{
thing = parse_dbref(person + 1);
}
if (thing == NOTHING)
{
thing = lookup_player(player, person, true);
}
if (thing == NOTHING)
{
raw_notify(player, M_("MAIL: I do not see that person here."));
return;
}
auto it = std::find(m->list.begin(), m->list.end(), thing);
bool ok = (it != m->list.end());
if (ok)
{
m->list.erase(it);
sqlite_wt_sync_all_aliases();
raw_notify(player, tprintf(M_("MAIL: %s removed from alias %s."),
Moniker(thing), alias));
}
else
{
raw_notify(player, tprintf(M_("MAIL: %s is not a member of alias %s."),
Moniker(thing), alias));
}
}
static void do_malias_rename(dbref player, UTF8 *alias, UTF8 *newname)
{
int nResult;
malias_t *m = get_malias(player, newname, &nResult);
if (nResult == GMA_FOUND)
{
raw_notify(player, M_("MAIL: That name already exists!"));
return;
}
if (nResult != GMA_NOTFOUND)
{
return;
}
m = get_malias(player, alias, &nResult);
if (nResult == GMA_NOTFOUND)
{
raw_notify(player, M_("MAIL: I cannot find that alias!"));
return;
}
if (nResult != GMA_FOUND)
{
return;
}
if (!ExpMail(player) && !(m->owner == player))
{
raw_notify(player, M_("MAIL: Permission denied."));
return;
}
size_t nValidMailAlias;
bool bValidMailAlias;
UTF8 *pValidMailAlias = MakeCanonicalMailAlias
( newname+1,
&nValidMailAlias,
&bValidMailAlias
);
if (bValidMailAlias)
{
m->name.assign(reinterpret_cast<const char *>(pValidMailAlias), nValidMailAlias);
sqlite_wt_sync_all_aliases();
raw_notify(player, M_("MAIL: Mailing Alias renamed."));
}
else
{
raw_notify(player, M_("MAIL: Alias is not valid."));
}
}
static void do_malias_delete(dbref player, UTF8 *alias)
{
int nResult;
malias_t *m = get_malias(player, alias, &nResult);
if (nResult == GMA_NOTFOUND)
{
raw_notify(player, tprintf(M_("MAIL: Alias %s not found."), alias));
return;
}
if (nResult != GMA_FOUND)
{
return;
}
for (auto it = malias.begin(); it != malias.end(); ++it)
{
if (it->get() == m)
{
if ((m->owner == player) || ExpMail(player))
{
malias.erase(it);
sqlite_wt_sync_all_aliases();
raw_notify(player, M_("MAIL: Alias Deleted."));
return;
}
break;
}
}
raw_notify(player, tprintf(M_("MAIL: Alias %s not found."), alias));
}
static void do_malias_adminlist(dbref player)
{
if (!ExpMail(player))
{
do_malias_list_all(player);
return;
}
// Mail alias admin list (#1667 Phase 4 B2) — same widths as B1 plus
// a 4-column index prefix.
//
static const size_t kAliasNumCols = 4;
static const size_t kAliasNameCols = 12;
static const size_t kAliasDescCols = 40;
static const size_t kAliasOwnerCols = 15;
{
UTF8 header[LBUF_SIZE];
size_t pos = 0;
pos = mux_table_append_ljust(header, sizeof(header), pos,
M_("Num"), kAliasNumCols);
pos = mux_table_append_bytes(header, sizeof(header), pos, " ");
pos = mux_table_append_ljust(header, sizeof(header), pos,
M_("Name"), kAliasNameCols);
pos = mux_table_append_bytes(header, sizeof(header), pos, " ");
pos = mux_table_append_ljust(header, sizeof(header), pos,
M_("Description"), kAliasDescCols);
pos = mux_table_append_bytes(header, sizeof(header), pos, " ");
pos = mux_table_append_ljust(header, sizeof(header), pos,
M_("Owner"), kAliasOwnerCols);
raw_notify(player, header);
}
for (size_t i = 0; i < malias.size(); i++)
{
malias_t *m = malias[i].get();
UTF8 line[LBUF_SIZE];
size_t pos = 0;
UTF8 numbuf[8];
mux_sprintf(numbuf, sizeof(numbuf), T("%d"), static_cast<int>(i));
pos = mux_table_append_ljust(line, sizeof(line), pos,
numbuf, kAliasNumCols);
pos = mux_table_append_bytes(line, sizeof(line), pos, " ");
pos = mux_table_append_ljust(line, sizeof(line), pos,
reinterpret_cast<const UTF8 *>(m->name.c_str()), kAliasNameCols);
pos = mux_table_append_bytes(line, sizeof(line), pos, " ");
pos = mux_table_append_ljust(line, sizeof(line), pos,
reinterpret_cast<const UTF8 *>(m->desc.c_str()), kAliasDescCols);
pos = mux_table_append_bytes(line, sizeof(line), pos, " ");
pos = mux_table_append_ljust(line, sizeof(line), pos,
Moniker(m->owner), kAliasOwnerCols);
raw_notify(player, line);
}
raw_notify(player, M_("***** End of Mail Aliases *****"));
}
static void do_malias_status(dbref player)
{
if (!ExpMail(player))
{
raw_notify(player, M_("MAIL: Permission denied."));
}
else
{
raw_notify(player, tprintf(M_("MAIL: Number of mail aliases defined: %d"),
static_cast<int>(malias.size())));
raw_notify(player, tprintf(M_("MAIL: Vector capacity: %d"),
static_cast<int>(malias.capacity())));
}
}
XFUNCTION(fun_malias)
{
UNUSED_PARAMETER(fp);
UNUSED_PARAMETER(caller);
UNUSED_PARAMETER(enactor);
UNUSED_PARAMETER(eval);
UNUSED_PARAMETER(cargs);
UNUSED_PARAMETER(ncargs);
dbref target;
if (nfargs == 0)
{
target = executor;
}
else
{
target = match_thing_quiet(executor, fargs[0]);
if (!Good_obj(target))
{
safe_match_result(target, buff, bufc);
return;
}
}
if ( target != executor
&& !ExpMail(executor))
{
safe_noperm(buff, bufc);
return;
}
ITL pContext;
ItemToList_Init(&pContext, buff, bufc);
for (size_t i = 0; i < malias.size(); i++)
{
malias_t *m = malias[i].get();
if (m->owner == target)
{
if (!ItemToList_AddString(&pContext, utf8(m->name)))
{
break;
}
}
}
ItemToList_Final(&pContext);
}
static void malias_cleanup1(malias_t *m, dbref target)
{
m->list.erase(
std::remove_if(m->list.begin(), m->list.end(),
[target](dbref j) { return !Good_obj(j) || j == target; }),
m->list.end());
}
void malias_cleanup(dbref player)
{
// Remove destroyed player from alias membership lists.
for (size_t i = 0; i < malias.size(); i++)
{
malias_cleanup1(malias[i].get(), player);
}
// Delete aliases owned by the destroyed player. Iterate backwards.
for (int i = static_cast<int>(malias.size()) - 1; i >= 0; i--)
{
malias_t *m = malias[i].get();
if (m->owner == player)
{
malias.erase(malias.begin() + i);
}
}
sqlite_wt_sync_all_aliases();
}
static void do_mail_retract1(dbref player, UTF8 *name, UTF8 *msglist)
{
dbref target = lookup_player(player, name, true);
if (target == NOTHING)
{
raw_notify(player, M_("MAIL: No such player."));
return;
}
struct mail_selector ms;
if (!parse_msglist(msglist, &ms, target))
{
return;
}
int i = 0, j = 0;
MailList ml(target);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if (mail_from_player(player, mp))
{
i++;
if (mail_match(mp, ms, i))
{
j++;
if (Unread(mp))
{
ml.RemoveItem();
raw_notify(player, M_("MAIL: Mail retracted."));
}
else
{
raw_notify(player, M_("MAIL: That message has been read."));
}
}
}
}
if (!j)
{
// Ran off the end of the list without finding anything.
//
raw_notify(player, M_("MAIL: No matching messages."));
}
}
static void do_mail_retract(dbref player, UTF8 *name, UTF8 *msglist)
{
if (*name == '*')
{
int pnResult;
malias_t *m = get_malias(player, name, &pnResult);
if (pnResult == GMA_NOTFOUND)
{
raw_notify(player, tprintf(M_("MAIL: Mail alias %s not found."), name));
return;
}
if (pnResult == GMA_FOUND)
{
for (size_t i = 0; i < m->list.size(); i++)
{
do_mail_retract1(player, tprintf(T("#%d"), m->list[i]), msglist);
}
}
}
else
{
do_mail_retract1(player, name, msglist);
}
}
void do_malias
(
dbref executor,
dbref caller,
dbref enactor,
int eval,
int key,
int nargs,
UTF8 *arg1,
UTF8 *arg2,
const UTF8 *cargs[],
int ncargs
)
{
UNUSED_PARAMETER(caller);
UNUSED_PARAMETER(enactor);
UNUSED_PARAMETER(eval);
UNUSED_PARAMETER(nargs);
UNUSED_PARAMETER(cargs);
UNUSED_PARAMETER(ncargs);
if (nullptr != mudstate.pIMailControl)
{
// #1198: with the module loaded, never fall through into the engine
// malias store (dual-store split).
//
MUX_RESULT mr = mudstate.pIMailControl->MaliasCommand(executor, key, arg1, arg2);
if (MUX_SUCCEEDED(mr))
{
return;
}
if (MUX_E_NOTIMPLEMENTED == mr)
{
raw_notify(executor, M_("MAIL: That @malias command is not available."));
}
return;
}
switch (key)
{
case 0:
do_malias_switch(executor, arg1, arg2);
break;
case MALIAS_DESC:
do_malias_desc(executor, arg1, arg2);
break;
case MALIAS_CHOWN:
do_malias_chown(executor, arg1, arg2);
break;
case MALIAS_ADD:
do_malias_add(executor, arg1, arg2);
break;
case MALIAS_REMOVE:
do_malias_remove(executor, arg1, arg2);
break;
case MALIAS_DELETE:
do_malias_delete(executor, arg1);
break;
case MALIAS_RENAME:
do_malias_rename(executor, arg1, arg2);
break;
case 7:
// empty
break;
case MALIAS_LIST:
do_malias_adminlist(executor);
break;
case MALIAS_STATUS:
do_malias_status(executor);
}
}
void do_mail
(
dbref executor,
dbref caller,
dbref enactor,
int eval,
int key,
int nargs,
UTF8 *arg1,
UTF8 *arg2,
const UTF8 *cargs[],
int ncargs
)
{
UNUSED_PARAMETER(caller);
UNUSED_PARAMETER(enactor);
UNUSED_PARAMETER(eval);
UNUSED_PARAMETER(nargs);
UNUSED_PARAMETER(cargs);
UNUSED_PARAMETER(ncargs);
if (nullptr != mudstate.pIMailControl)
{
// #1198: with the module loaded, never fall through into engine
// mail_storage — that mutates a second world for the same process.
//
MUX_RESULT mr = mudstate.pIMailControl->MailCommand(executor, key, arg1, arg2);
if (MUX_SUCCEEDED(mr))
{
return;
}
if (MUX_E_NOTIMPLEMENTED == mr)
{
raw_notify(executor, M_("MAIL: That @mail command is not available."));
}
return;
}
// Only @mail/quick is allowed from non-player executors. All other
// @mail subcommands use interactive state (composing buffer, folder
// selection, etc.) that only makes sense in a player session.
//
if ( (key & ~MAIL_QUOTE) != MAIL_QUICK
&& !isPlayer(executor))
{
return;
}
switch (key & ~MAIL_QUOTE)
{
case 0:
do_mail_stub(executor, arg1, arg2);
break;
case MAIL_STATS:
do_mail_stats(executor, arg1, 0);
break;
case MAIL_DSTATS:
do_mail_stats(executor, arg1, 1);
break;
case MAIL_FSTATS:
do_mail_stats(executor, arg1, 2);
break;
case MAIL_DEBUG:
do_mail_debug(executor, arg1, arg2);
break;
case MAIL_NUKE:
do_mail_nuke(executor);
break;
case MAIL_FOLDER:
do_mail_change_folder(executor, arg1, arg2);
break;
case MAIL_LIST:
do_mail_list(executor, arg1, arg2, false);
break;
case MAIL_READ:
do_mail_read(executor, arg1, arg2);
break;
case MAIL_CLEAR:
do_mail_clear(executor, arg1);
break;
case MAIL_UNCLEAR:
do_mail_unclear(executor, arg1);
break;
case MAIL_PURGE:
do_mail_purge(executor);
break;
case MAIL_FILE:
do_mail_file(executor, arg1, arg2);
break;
case MAIL_TAG:
do_mail_tag(executor, arg1);
break;
case MAIL_UNTAG:
do_mail_untag(executor, arg1);
break;
case MAIL_FORWARD:
do_mail_fwd(executor, arg1, arg2);
break;
case MAIL_REPLY:
do_mail_reply(executor, arg1, false, key);
break;
case MAIL_REPLYALL:
do_mail_reply(executor, arg1, true, key);
break;
case MAIL_SEND:
do_expmail_stop(executor, 0);
break;
case MAIL_EDIT:
do_edit_msg(executor, arg1, arg2);
break;
case MAIL_URGENT:
do_expmail_stop(executor, M_URGENT);
break;
case MAIL_ALIAS:
do_malias_create(executor, arg1, arg2);
break;
case MAIL_ALIST:
do_malias_list_all(executor);
break;
case MAIL_PROOF:
do_mail_proof(executor);
break;
case MAIL_ABORT:
do_expmail_abort(executor);
break;
case MAIL_QUICK:
do_mail_quick(executor, arg1, arg2);
break;
case MAIL_REVIEW:
do_mail_review(executor, arg1, arg2);
break;
case MAIL_RETRACT:
do_mail_retract(executor, arg1, arg2);
break;
case MAIL_CC:
do_mail_cc(executor, arg1, false);
break;
case MAIL_SAFE:
do_mail_safe(executor, arg1);
break;
2026-03-18 12:56:16 -06:00
case MAIL_UNSAFE:
do_mail_unsafe(executor, arg1);
break;
case MAIL_BCC:
do_mail_cc(executor, arg1, true);
break;
case MAIL_NEXT:
do_mail_next(executor);
break;
}
}
struct mail *MailList::FirstItem(void)
{
ensure_mail_softcode_sync();
auto it = mail_storage.find(m_player);
if (it == mail_storage.end() || it->second.empty())
{
m_mi = {};
m_miEnd = {};
m_bRemoved = false;
return nullptr;
}
auto& lst = it->second;
m_mi = lst.begin();
m_miEnd = lst.end();
m_bRemoved = false;
return &*m_mi;
}
struct mail *MailList::NextItem(void)
{
if (!m_bRemoved)
{
if (m_mi != m_miEnd)
{
++m_mi;
if (m_mi == m_miEnd)
{
m_mi = m_miEnd;
}
}
}
m_bRemoved = false;
if (m_mi == m_miEnd)
return nullptr;
return &*m_mi;
}
bool MailList::IsEnd(void)
{
return (m_mi == m_miEnd);
}
MailList::MailList(dbref player)
{
m_player = player;
m_bRemoved = false;
}
void MailList::RemoveItem(void)
{
if (m_mi == m_miEnd || NOTHING == m_player)
{
return;
}
auto it = mail_storage.find(m_player);
if (it == mail_storage.end())
return;
auto& lst = it->second;
sqlite_wt_delete_mail(&*m_mi);
MessageReferenceDec(m_mi->number);
auto next_it = lst.erase(m_mi);
m_bRemoved = true;
// If this was the last message, clear the iterators and erase the map
// entry. Clearing is the point: leaving m_mi/m_miEnd pointing into an
// erased std::list makes IsEnd()/NextItem() dereference invalidated
// iterators (purge, expire, retract-unread, mail-debug). RemoveAll()
// clears the same pair, but erases first; here we must test lst.empty()
// first, so the list has to stay alive until after the test -- hence
// clear, then erase.
//
if (lst.empty())
{
m_mi = {};
m_miEnd = {};
mail_storage.erase(it);
}
else
{
m_mi = next_it;
}
}
void MailList::AppendItem(mail &&miNew)
{
auto& lst = mail_storage[m_player];
lst.push_back(std::move(miNew));
}
void MailList::RemoveAll(void)
{
auto it = mail_storage.find(m_player);
if (it == mail_storage.end())
return;
auto& lst = it->second;
for (auto& m : lst)
{
MessageReferenceDec(m.number);
}
sqlite_wt_delete_all_mail(m_player);
mail_storage.erase(it);
m_mi = {};
m_miEnd = {};
}
static void ListMailInFolderNumber(dbref player, int folder_num, UTF8 *msglist)
{
int original_folder = player_folder(player);
set_player_folder(player, folder_num);
struct mail_selector ms;
if (!parse_msglist(msglist, &ms, player))
{
return;
}
int i = 0;
raw_notify(player, tprintf(T(FOLDER_LINE), folder_num));
MailList ml(player);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
if (Folder(mp) == folder_num)
{
i++;
if (mail_match(mp, ms, i))
{
size_t nSize = MessageFetchSize(mp->number);
UTF8 szFromName[MBUF_SIZE];
trimmed_name(mp->from, szFromName, 16, 16, 0);
UTF8 line[LBUF_SIZE];
format_mail_list_line_sub(line, sizeof(line),
status_chars(mp), i, nSize, szFromName, utf8(mp->subject));
raw_notify(player, line);
}
}
}
raw_notify(player, DASH_LINE);
set_player_folder(player, original_folder);
}
static void ListMailInFolder(dbref player, UTF8 *folder_name, UTF8 *msglist)
{
int folder = 0;
2018-10-03 17:54:51 +00:00
if ( nullptr == folder_name
|| '\0' == folder_name[0])
{
folder = player_folder(player);
}
else
{
folder = parse_folder(player, folder_name);
}
2007-09-03 17:57:33 -07:00
if (-1 == folder)
{
raw_notify(player, M_("MAIL: No such folder."));
return;
}
ListMailInFolderNumber(player, folder, msglist);
}
void do_folder
(
dbref executor,
dbref caller,
dbref enactor,
int eval,
int key,
int nargs,
UTF8 *arg1,
UTF8 *arg2,
const UTF8 *cargs[],
int ncargs
)
{
UNUSED_PARAMETER(caller);
UNUSED_PARAMETER(enactor);
UNUSED_PARAMETER(eval);
UNUSED_PARAMETER(cargs);
UNUSED_PARAMETER(ncargs);
if (nullptr != mudstate.pIMailControl)
{
// #1198: with the module loaded, never fall through into the engine store.
//
MUX_RESULT mr = mudstate.pIMailControl->FolderCommand(executor, key, nargs, arg1, arg2);
if (MUX_SUCCEEDED(mr))
{
return;
}
if (MUX_E_NOTIMPLEMENTED == mr)
{
raw_notify(executor, M_("MAIL: That folder command is not available."));
}
return;
}
switch (key)
{
2007-09-03 17:57:33 -07:00
case FOLDER_FILE:
do_mail_file(executor, arg1, arg2);
break;
2007-09-03 17:57:33 -07:00
case FOLDER_LIST:
ListMailInFolder(executor, arg1, arg2);
break;
2007-09-03 17:57:33 -07:00
case FOLDER_READ:
do_mail_read(executor, arg1, arg2);
break;
2007-09-03 17:57:33 -07:00
case FOLDER_SET:
do_mail_change_folder(executor, arg1, arg2);
break;
default:
2018-10-03 17:54:51 +00:00
if ( nullptr == arg1
|| '\0' == arg1[0])
2007-09-03 17:57:33 -07:00
{
DoListMailBrief(executor);
}
else if (2 == nargs)
{
do_mail_read(executor, arg1, arg2);
2007-09-03 17:57:33 -07:00
}
else
{
do_mail_change_folder(executor, arg1, arg2);
2007-09-03 17:57:33 -07:00
}
break;
}
}
// ---------------------------------------------------------------------------
// SQLite mail bulk sync and load (Phase 1).
// ---------------------------------------------------------------------------
static void clear_runtime_mail_data(void)
{
mail_storage.clear();
mail_list.clear();
mudstate.mail_db_top = 0;
malias.clear();
}
2026-03-05 15:20:32 -07:00
bool sqlite_sync_mail(void)
{
CSQLiteDB &sqldb = g_pSQLiteBackend->GetDB();
if (!sqldb.Begin())
2026-03-05 15:20:32 -07:00
{
return false;
}
if (!sqldb.ClearMailTables())
2026-03-05 15:20:32 -07:00
{
sqldb.Rollback();
2026-03-05 15:20:32 -07:00
return false;
}
// Sync mail bodies first (mail_headers.body_number references
// mail_bodies.number via foreign key).
//
for (int i = 0; i < mudstate.mail_db_top; i++)
{
if (0 < mail_list[i].m_nRefs)
{
if (!sqldb.SyncMailBody(i, utf8(mail_list[i].m_pMessage)))
2026-03-05 15:20:32 -07:00
{
sqldb.Rollback();
return false;
}
}
}
// Sync mail headers.
//
dbref thing;
DO_WHOLE_DB(thing)
{
if (isPlayer(thing))
{
MailList ml(thing);
struct mail *mp;
for (mp = ml.FirstItem(); !ml.IsEnd(); mp = ml.NextItem())
{
mp->sqlite_id = sqldb.InsertMailHeaderReturningId(
mp->to, mp->from, mp->number,
utf8(mp->tolist), utf8(mp->time), utf8(mp->subject), mp->read);
2026-03-05 15:20:32 -07:00
if (mp->sqlite_id < 0)
{
sqldb.Rollback();
return false;
}
}
}
}
// Sync mail aliases.
//
for (size_t i = 0; i < malias.size(); i++)
{
malias_t *m = malias[i].get();
// Serialize member list to space-separated string.
//
LBuf members_buf = LBuf_Src("sync_mail_alias");
UTF8 *bp = members_buf;
for (size_t j = 0; j < m->list.size(); j++)
{
if (j > 0)
{
safe_chr(' ', members_buf, &bp);
}
safe_str(tprintf(T("%d"), m->list[j]), members_buf, &bp);
}
*bp = '\0';
if (!sqldb.SyncMailAlias(m->owner, utf8(m->name), utf8(m->desc),
2026-03-05 15:20:32 -07:00
static_cast<int>(m->desc_width), members_buf))
{
sqldb.Rollback();
return false;
}
}
if (!sqldb.PutMeta("mail_db_top", mudstate.mail_db_top))
{
sqldb.Rollback();
return false;
}
if (!sqldb.Commit())
2026-03-05 15:20:32 -07:00
{
sqldb.Rollback();
2026-03-05 15:20:32 -07:00
return false;
}
return true;
}
int sqlite_load_mail(void)
{
mudstate.bSQLiteLoading = true;
CSQLiteDB &sqldb = g_pSQLiteBackend->GetDB();
int mail_top = 0;
CSQLiteDB::MetaGetResult mail_top_meta = sqldb.GetMetaEx("mail_db_top", &mail_top);
if (CSQLiteDB::MetaGetResult::Error == mail_top_meta)
{
mudstate.bSQLiteLoading = false;
return -1;
}
if (CSQLiteDB::MetaGetResult::Found != mail_top_meta)
{
mudstate.bSQLiteLoading = false;
return 0;
}
clear_runtime_mail_data();
mail_db_grow(mail_top + 1);
// Load mail bodies first (they must exist before headers reference them).
//
2026-03-05 15:20:32 -07:00
if (!sqldb.LoadAllMailBodies([](int number, const UTF8 *message)
{
new_mail_message(const_cast<UTF8 *>(message), number);
2026-03-05 15:20:32 -07:00
}))
{
clear_runtime_mail_data();
2026-03-05 15:20:32 -07:00
mudstate.bSQLiteLoading = false;
return -1;
2026-03-05 15:20:32 -07:00
}
// Load mail headers.
//
2026-03-05 15:20:32 -07:00
if (!sqldb.LoadAllMailHeaders([](int64_t rowid, int to_player, int from_player,
int body_number, const UTF8 *tolist, const UTF8 *time_str,
const UTF8 *subject, int read_flags)
{
auto& lst = mail_storage[to_player];
lst.emplace_back();
mail& m = lst.back();
m.to = to_player;
m.from = from_player;
m.number = body_number;
MessageReferenceInc(m.number);
m.tolist.assign(reinterpret_cast<const char *>(tolist ? tolist : reinterpret_cast<const UTF8 *>("")));
m.time.assign(reinterpret_cast<const char *>(time_str ? time_str : reinterpret_cast<const UTF8 *>("")));
m.subject.assign(reinterpret_cast<const char *>(subject ? subject : reinterpret_cast<const UTF8 *>("")));
m.read = read_flags;
m.sqlite_id = rowid;
2026-03-05 15:20:32 -07:00
}))
{
clear_runtime_mail_data();
2026-03-05 15:20:32 -07:00
mudstate.bSQLiteLoading = false;
return -1;
2026-03-05 15:20:32 -07:00
}
// Load mail aliases.
//
std::vector<std::unique_ptr<malias_t>> alias_vec;
auto free_alias_vec = [&alias_vec]()
{
alias_vec.clear();
};
2026-03-05 15:20:32 -07:00
if (!sqldb.LoadAllMailAliases([&alias_vec](int owner, const UTF8 *name,
const UTF8 *desc, int desc_width, const UTF8 *members)
{
auto m = std::make_unique<malias_t>();
m->owner = owner;
m->name = name ? reinterpret_cast<const char *>(name) : "";
m->desc = desc ? reinterpret_cast<const char *>(desc) : "";
m->desc_width = desc_width;
// Parse space-separated member list.
//
m->list.clear();
if (members && members[0] != '\0')
{
LBuf buf = LBuf_Src("load_mail_alias");
mux_strncpy(buf, members, LBUF_SIZE - 1);
UTF8 *p = buf;
while (*p)
{
while (*p == ' ') p++;
if (*p == '\0') break;
fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373) 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>
2026-07-26 10:03:45 -06:00
m->list.push_back(mux_atoi64(p));
while (*p && *p != ' ') p++;
}
}
alias_vec.push_back(std::move(m));
2026-03-05 15:20:32 -07:00
}))
{
free_alias_vec();
clear_runtime_mail_data();
2026-03-05 15:20:32 -07:00
mudstate.bSQLiteLoading = false;
return -1;
2026-03-05 15:20:32 -07:00
}
if (!alias_vec.empty())
{
malias.reserve(alias_vec.size());
for (auto& up : alias_vec)
{
malias.push_back(std::move(up));
}
alias_vec.clear();
}
mudstate.bSQLiteLoading = false;
return 1;
}