diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d5d3bbf..a3f719b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,9 +35,15 @@ jobs: - uses: actions/checkout@v4 - name: Install build dependencies + # zlib1g-dev is REQUIRED, not optional: CMakeLists.txt does + # find_package(ZLIB REQUIRED) for the zlib_stream module, and the + # dev package is what ships zlib.h + the libz.so link. The runtime + # zlib1g is present on every box (half of userspace links it), but + # build-essential does NOT depend on zlib1g-dev - so configure fails + # without this line. Keep in sync with smoke.yml + docker/Dockerfile. run: | sudo apt-get update - sudo apt-get install -y build-essential cmake libssl-dev + sudo apt-get install -y build-essential cmake libssl-dev zlib1g-dev - name: Configure run: cmake -B build -DCMAKE_BUILD_TYPE=Release @@ -129,7 +135,7 @@ jobs: done apt-get install -y --no-install-recommends \ build-essential perl wget ca-certificates \ - patchelf file git + patchelf file git zlib1g-dev - name: Install CMake ${{ env.CMAKE_VERSION }} (Kitware aarch64 binary) # Bullseye's stock cmake (3.18) is too old for cmake_minimum_required(3.20). @@ -291,6 +297,7 @@ jobs: mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-make mingw-w64-ucrt-x86_64-openssl + mingw-w64-ucrt-x86_64-zlib zip - name: Configure diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 7b846364..327ff037 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -46,6 +46,9 @@ jobs: - name: Run bloom unit test run: lua5.4 tests/unit/bloom_test.lua + - name: Run sysinfo unit test + run: lua5.4 tests/unit/sysinfo_test.lua + # Phase 1b of #82 HTTP API: router unit tests cover the pure-Lua # bits (constant_time_eq, schema validator, envelope helpers, # token resolution, request-id shape, register guards). @@ -112,13 +115,18 @@ jobs: - name: Run lang unit test run: lua5.4 tests/unit/lang_test.lua - # #301 PR-2: usr_share lang-key consistency. Scans the plugin - # source for every lang.X reference and asserts X is defined in - # both .lang.de and .lang.en. Catches typos like the pre-fix - # lang.msg_minmax / msg_sharelimits drift that silently dropped - # the German translation. - - name: Run usr_share lang unit test - run: lua5.4 tests/unit/usr_share_lang_test.lua + # Repo-wide plugin lang-key consistency. For every bundled plugin + # that ships lang files, scans the source for each lang.X reference + # and asserts X is defined in both .lang.de and .lang.en. Catches + # the drift class that silently kills a translation: the lookup + # returns nil, the `or ""` fallback fires, and no error is + # ever raised. Supersedes the former usr_share-only test (#301 PR-2, + # lang.msg_minmax vs msg_sharelimits) - usr_share is one of the ~68 + # plugins swept here, so coverage is a strict superset. Generalised + # after the same bug reappeared in etc_cmdlog (lang.failmsg1/2 vs + # msg_denied/msg_nofile), which the one-plugin test could not see. + - name: Run plugin lang unit test + run: lua5.4 tests/unit/plugin_lang_test.lua # #327 etc_aliases: HTTP API surface + reload-safety. Exercises # all four +addalias reject branches (bad_alias / conflict_command @@ -366,6 +374,10 @@ jobs: shell: msys2 {0} run: lua5.4 tests/unit/bloom_test.lua + - name: Run sysinfo unit test + shell: msys2 {0} + run: lua5.4 tests/unit/sysinfo_test.lua + - name: Run http_router unit test shell: msys2 {0} run: lua5.4 tests/unit/http_router_test.lua @@ -406,9 +418,9 @@ jobs: shell: msys2 {0} run: lua5.4 tests/unit/lang_test.lua - - name: Run usr_share lang unit test + - name: Run plugin lang unit test shell: msys2 {0} - run: lua5.4 tests/unit/usr_share_lang_test.lua + run: lua5.4 tests/unit/plugin_lang_test.lua - name: Run etc_aliases unit test shell: msys2 {0} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3665fabf..8282eddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ land on `release/3.1.x` per ### Documentation +- **new [`CONTRIBUTING.md`](CONTRIBUTING.md) - a contributor entry point, closing a real doc gap.** Three external PRs ([#437](https://github.com/luadch-ng/luadch/pull/437) / [#438](https://github.com/luadch-ng/luadch/pull/438) / [#439](https://github.com/luadch-ng/luadch/pull/439)) all arrived against `master`, and correctly so from the outside: nothing documented the GitFlow-A branch policy, `master` is the repository's default branch (so GitHub offers it), and `README.md` literally described 3.2.x as "active development (`master`)" without mentioning `dev` at all. The new page leads with the branch table (`dev` = target this, `master` = release substrate, `release/3.1.x` = security backports only), records that a mistargeted PR just gets retargeted by a maintainer with the contributor's authorship intact (no close-and-reopen), routes vulnerability reports to [`docs/SECURITY.md`](docs/SECURITY.md), and links out for the rest - build, tests, the core restricted-env `use "X"` contract, the plugin sandbox, the companion scripts repo - rather than duplicating them; the engineering how-to stays in [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md). `README.md` gains a `dev`-branch pointer directly under the release-lines table (the sentence that was actively pointing contributors at the wrong branch) plus `CONTRIBUTING.md` and the previously-unlisted `docs/DEVELOPMENT.md` in its documentation list; `docs/DEVELOPMENT.md`'s orientation table gains a "your first PR" row. Docs-only, no code change. 3.2.x only. + - Contributor-doc currency + pattern capture (post-#398 webhook arc). **`CLAUDE.md`:** latest-release marker v3.1.12 -> **v3.1.13**; the §3 module map gains `core/hmac.lua` (HMAC-SHA256, #398) and `core/geoip_update.lua` (was omitted); the §5 current-era note re-framed from "bug triage" to feature-work + triage; §8 gains a "drop-in plugin patch as a lighter alternative to a 3.1.x release" note (the `usr_uptime` v0.10.1 case). **`docs/DEVELOPMENT.md`:** six durable patterns added - `scope="none"` custom-auth HTTP routes, verifying a signed body with `hmac.sha256` + dynamically-named per-instance secret keys, bots-at-module-load / routes-in-`onStart` (reload safety), bulky operator config in its own `cfg/.tbl`, the `getpeername()`-nil-after-accept guard (#401), and poll-don't-fixed-settle for a smoke assertion whose hub state installs asynchronously and fails open (#408). **`docs/HTTP_API.md`:** the §4.4 `none` scope row and the §5.1 plugin-registration `scope` arg now document `scope="none"` for plugin-owned auth (`etc_webhook` is the first user); the `meta` key list gains `plugin`. Plus: two unit tests that existed but were never wired into CI (`etc_blocklist_test`, `cmd_usercleaner_orphan_test` - both pass) are now registered on both smoke legs, so `tests/README`'s "the whole set runs in CI" holds. Docs / test-wiring only. 3.2.x only. - [#364](https://github.com/luadch-ng/luadch/issues/364) (Sopor) - **clarified the `kill_wrong_ips` wording so it no longer reads as if it relates to CGNAT traffic/download blocking.** `kill_wrong_ips` only governs the login-time *mismatch* kick (client-advertised INF IP vs authenticated TCP source); it does not affect how the hub blocks users - e.g. the Traffic Manager, which on 3.x decides on level / share / account-nick, not IP (the actual fix for the #364 CGNAT block-bypass reported on 2.24). The old wording listed bare "CGNAT users" as a `kill_wrong_ips = false` beneficiary and claimed peers "reach them correctly in CGNAT-egress scenarios", which both overstated the effect (genuine carrier-NAT users get no inbound active-mode P2P regardless) and invited conflation with #364. Tightened in `docs/SECURITY.md`, the `kill_wrong_ips` `cfg_defaults` comment, `examples/cfg/cfg.tbl`, and the Breaking entry above: the beneficiary is now described by the actual mismatch mechanism (stale VPN IP, hand-set wrong WAN IP, dual-stack family mismatch), with an explicit "does not change how the hub blocks users" note. Docs-only, no code change. 3.2.x only. @@ -61,10 +63,34 @@ land on `release/3.1.x` per ### Refactors +- [#447](https://github.com/luadch-ng/luadch/issues/447) PR 6 - **plugins: remove 18 dead `lang` locals and the 22 language keys they orphaned** (62 lines across 35 files), and close the hole in the guard that let them accumulate. Each removed local is a `local msg_x = lang.msg_x or ""` binding that its own plugin declares and never reads: `cmd_accinfo` (`ucmd_menu_ct1`, `ucmd_menu_ct3`), `cmd_ban` (`msg_import`, `msg_ban_added`), `cmd_hubstats` (`msg_empty_tbl`), `cmd_myinf` (`msg_unknown`), `cmd_reg` (`msg_import`), `cmd_upgrade` (`ucmd_level`), `cmd_userinfo` (`msg_denied`), `etc_blocklist` and `etc_whitelist` (`msg_add_failed` / `msg_export_fail` / `msg_import_fail` each), `etc_clientblocker` (`msg_client_not_allowed`, `msg_err`), `etc_trafficmanager` (`msg_notregged`). Their keys go from **both** `.lang.en` and `.lang.de`, along with four keys that were already orphaned with no local at all: `cmd_reg`'s `ucmd_passwort` (a German key name in the English file, left behind when `+reg` stopped prompting for a password and started generating one) and `etc_userlogininfo`'s `msg_ccpm_1/2/3` (the CCPM row is gone from the `msg_info` template). **None of the 18 is a swallowed error path**, which is the thing worth checking before deleting a message template rather than after: `cmd_userinfo`'s denial is live via `msg_god` at the hierarchy check (`cmd_userinfo_permission` maps a level to the highest level it may inspect - there is no flat "you may not use this" concept to print `msg_denied` for); `cmd_ban` announces through `report.send( ..., message )`; `etc_blocklist` / `etc_whitelist` **do** report add/export/import failures, just with the raw error string the helper returns rather than through these templates - so the OK paths are translatable and the failure paths are English-only, a real i18n gap that removing an unwired template neither creates nor fixes ([#456](https://github.com/luadch-ng/luadch/issues/456)); `etc_clientblocker`'s silence on a missing `.tbl` is deliberate and commented as such - the source marks the missing-file case as the canonical first-run path rather than an error condition, with no opchat feed, from the #81 seed-on-missing arc, which is what left `msg_err` stranded; and `etc_trafficmanager` uses its regged lookup only to derive `target_level`, so being unregistered was never an error to announce. **Derived twice, independently** (§1a.4), because the tracker's list is neither: it says 17 locals and 40 lines. Both are wrong - a subagent's per-file lexer and a second detector reusing the shipped guard's validated comment-stripper each independently returned the **same 18**, from the same 1021 declarations, and 18 decls + 18 `.en` + 18 `.de` cannot be 40 either way. The tracker most likely lost `cmd_accinfo`'s two `ucmd_menu_ct*` to an `[a-z_]+` identifier pattern that silently skips digits - the same class of miss that made PR 5's `sha256` invisible. **The four dead plugin exports the tracker also lists here are deliberately NOT removed** (`etc_report.broadcast`, `etc_usercommands.format`, `bot_opchat.bot`, `cmd_ban.bans_path`), because the criterion behind them - "not in `docs/PLUGIN_API.md` §8's contract table" - does not survive reading §8, which closes with *"See each plugin's source for the full exported surface"*: the table is explicitly a sample, not an enumeration. It documents 7 of the 18 bundled plugins that export anything, and the names missing from it include `etc_usercommands.add` (**210 call sites in the companion `luadch-ng/scripts` repo alone**), `cmd_ban.del`, `cmd_help.reg` and `etc_hubcommands.has` / `list` - so "absent from §8" would condemn the single most-used plugin export in the ecosystem. This is PR 3a's `getstats` lesson in reverse: non-enumeration cannot mean non-contract. The repo's actual convention is the `_` prefix (`etc_blocklist._parse_add_args` and friends are test seams); all four targets are bare names, three are changelogged deliberate features (`bot_opchat` v0.15 *"expose bot object via interface"*, `etc_report` v0.04 *"renamed old send() function to broadcast()"* - a level-**range** broadcast that `send` cannot do), and `etc_usercommands.format` is documented in its own header line 7 (`usage: user.write( ucmd.format( ... ) )`). Confirmed unused across both this repo and the companion repo (whose 27 import sites call only `.send`, `.feed`, `.add` and `.bans`), but unused-in-tree is the expected state of a public plugin API, not evidence against it. **`tests/unit/plugin_lang_test.lua` now asserts both directions.** The #442 guard only checked that every `lang.X` the source reads exists in the lang files; a key nothing reads was invisible to it, which is how 22 of them accumulated. It also under-scanned: its `lang%.([%w_]+)` pattern cannot see `lang. msg_notfound` (whitespace after the dot, which Lua accepts and `usr_uptime.lua:143` is written with), so that key was unguarded, and a reverse check without `%s*` would have reported it as orphaned and deleted a **live** German translation - the exact #442 bug, introduced by the cleanup meant to prevent it. Both directions now tolerate the whitespace. Provably fails pre-fix (§1a.7): on the unpatched tree the new assertion reports 8 failures (`cmd_reg ucmd_passwort` and `etc_userlogininfo msg_ccpm_1/2/3`, each in `.en` and `.de`); patched, 4280/4280 checks pass over 68 plugins. **No `scriptversion` bumps**: per `docs/DEVELOPMENT.md` a bump signals a semantic change (behaviour, cfg keys, wire surface) and is how the companion repo syncs - this has none of the three, and none of the 12 plugins exists there. Behaviour-neutral by construction: every removed binding was unreachable from its own file. 3.2.x only, not backported. + +- [#447](https://github.com/luadch-ng/luadch/issues/447) PR 5 - **remove 61 dead file-scope locals across 24 core modules** (88 lines, counting their orphaned assignments, two commented-out remnants and two section headers left labelling nothing). Mostly stdlib and module aliases that outlived their consumers (`table_insert`, `string_char`, `os_date`, `debug_traceback`, ...). Six were bind-late `local X` / `X = ...` pairs, assigned and never read: `out_put` in `util` and in `cfg_users`, `out_error` in `cfg_secret`, `types_adcstr` and `_cfgbackup` in `cfg`, `_integer` in `adc`. (`cfg_secret`'s `out_put` is **live** - five calls - and stays; only its `out_error` was dead.) `_integer` lost its consumer when `adc`'s `_regex.integer` was rewritten inline for upstream luadch/luadch#241. `whitelist`'s `_rebuild_indices` was copy-pasted from `blocklist`, whose only caller is a `bulk_replace` that whitelist deliberately does not have. `cfg`'s **`_cfgbackup`** is not an unfinished feature - it is a **finished one, deliberately disabled**: `cfg.lua` once wrote timestamped `cfg.tbl.backup.[dd.mm.yy.HH.MM.SS]` copies on every `cfg.set`; that call was commented out in 2016 and [#272](https://github.com/luadch-ng/luadch/pull/272) deleted the remnant, orphaning `_cfgbackup` **and** `cfg`'s `os_date`, which is why both die here together. No data is at risk - `cfg.set` still persists through `util_savetable( _settings, "settings", _cfgfile )`; only the backup copies stopped, ten years ago. Derived by a purpose-built detector rather than the tracker's line list: comments stripped first (a bare grep counts the English word - the `stop` trap), every file-scope local counted, iterated to a fixpoint so cascade orphans surface. It found four the tracker's list did not - `adc`'s `debug`, `cfg`'s `types`, `hbri`'s `cfg` (each orphaned only by removing its last consumer, invisible to a single pass) and `hub.lua`'s `tonumber`, missed by PR 1 because that PR worked from the same short list. It also needed two corrections of its own, both caught in review: `X = X,` in a return table is an **export**, not an assignment (it was flagging live functions until that rule went in), and `local function X` parsed as a local named `function` - which hid `whitelist`'s `_rebuild_indices` until a reviewer named it. **Load order is unchanged**, the one thing that could have broken: `use "X"` on an unloaded core module triggers its `loadscript`, so dropping a `use` can move a module's load. Checked against the `_core` array - `mem` sits at position 2, ahead of `util` (4), `out` (7) and `adc` (18); `debug` is a stdlib global with no `core/debug.lua` shadow; `hbri` is not in `_core` and is only pulled at runtime from `hub.lua`'s `login`. The one real case is `cfg`'s `use "types"` (cfg 5, types 28, so cfg genuinely triggers that load today): it resolves because **`cfg.lua` loads `cfg_defaults` itself**, which does `use "types"` - about 50 lines further into the same load. Nothing between the two touches types, and types.lua is passive at load and exports no `init`. Six removals were all-or-nothing: deleting only the `local` turns its assignment into a bare global write, which `core/init.lua`'s restricted env rejects **at hub load** - the #353 class, invisible to `luac -p` and to the unit tests. Proven absent by a bytecode scan of every patched file: zero `GETTABUP` / `SETTABUP _ENV ""` for any removed name, i.e. no dangling read, no captured closure, no stray global write. Hub boots, full smoke suite green. Two exports are now **orphaned in-tree but deliberately kept** - `util.handlebom` and `cfg_secret.is_active`, whose only consumers were aliases removed here; both live on plugin-reachable tables, so #447's conservative-on-contract-surface rule keeps them. `core/http_events.lua`'s `_max_size` is left alone on purpose: it is read only by its own clamp, so the `http_events_buffer_size` cfg key may do nothing - a behaviour question, not dead code. 3.2.x only, not backported. + +- [#447](https://github.com/luadch-ng/luadch/issues/447) PR 4 - **`core/util.lua` + `core/adc.lua`: remove unreachable branches** (33 lines from util.lua, 2 from adc.lua). All no-ops today, but each is a landmine for whoever touches the function next. Proven behaviour-neutral by loading the old and new `util.lua` side by side under Lua 5.4.8 and comparing both versions' output across every input class - **all 35 cases identical**; `git diff -w` collapses the util.lua change to pure deletions plus one edit. (1) **`util.encode` / `util.decode` / `util.trimstring`'s dead type guards.** `local str = tostring( str )` sits above each, and `tostring` never returns nil (`tostring(nil)` is the truthy string `"nil"`), so the guard is always satisfied and the error path is dead. Verified at runtime: `util.encode( nil )` returns the encoding of `"nil"` on both old and new code, never `(nil, err)`. Worse, encode/decode's dead branch read **`tbl`** - undeclared in that scope, a fossil of a table-taking ancestor's signature - so had it ever fired under `core/init.lua`'s restricted env it would have raised "attempt to read undeclared var" instead of returning `(nil, err)`. Disarmed only by being unreachable. The always-true `if` goes with each, and the orphaned `err` locals with those. **Behaviour is preserved exactly**: all three still stringify whatever they are given. That is a real hole - `util.trimstring( nil )` returns the string `"nil"`, and the companion repo's `etc_requests` feeds it unvalidated chat input - but closing it is a breaking change on sandbox-exposed functions and needs its own PR with tests, a PLUGIN_API update and a coordinated `etc_requests` fix, since hardening `trimstring` would turn that plugin's silent misbehaviour into a crash. Tracked in [#453](https://github.com/luadch-ng/luadch/issues/453). (2) **Four unreachable `type( x ) ~= "number"` guards** in `formatseconds`, `formatbytes` and `difftime` (x2). Each sits after `local x = tonumber( x )` **and** an `if not x then return nil, err end` - and since `tonumber` returns a number or nil and nothing else, those two guards fire on identical inputs, verified across every input class. The type check is not a missing validation, it is the redundant second copy of one; `if not x` is the live guard and stays. This **supersedes the merge rationale on [#439](https://github.com/luadch-ng/luadch/pull/439)**, which kept the guards as a latent trap for "the moment that `tonumber` rebind is ever refactored away": that reasoning does not hold, because in that scenario `if not x` is the guard that survives and the type check is not the safety net it appears to be. Note #439 (@Kcchouette) corrected the expression in `formatseconds` and `formatbytes` **only** - and correctly (`not type( x ) == "number"` really does parse as `( not type( x ) ) == "number"`); `difftime`'s two guards date to the SourceForge import and were always written correctly. `formatbytes`'s commented-out original combined guard goes too. **Untouched:** `convertepochdate` and `generatepass` use the same shadow but have **no** preceding nil-guard, so their type checks are reachable and load-bearing - `generatepass`'s is precisely what stops `nil < 0` from raising. (3) **`core/adc.lua`: duplicate `FC` / `TO` keys** in the **`STA`** named-parameter table (not `SCH`, which this arc's tracker misnamed and which has no `FC` at all), declared once for base STA and again under the ASCH block. Unlike the [#438](https://github.com/luadch-ng/luadch/pull/438) `bool` duplicate, both writes store the identical value, so last-wins changes nothing today - the trap is future-tense: changing the base `FC` would be silently overridden back by the ASCH copy, and the edit would look like it did nothing. 3.2.x only, not backported. + +- [#447](https://github.com/luadch-ng/luadch/issues/447) PR 3b - **`core/server.lua`: remove the caller-less outbound-TCP wrapper and the broken STARTTLS seam** (139 lines; the file is now 1148, down from 1331 before PR 3a). Two clusters, both dead since long before this fork, plus the two accessors PR 3a deferred. (1) **`addclient` + `wrapclient` + `socket_tcp`.** `addclient` was exported but called by nothing in-tree - and never plugin-reachable either, since `server` is absent from `core/scripts.lua`'s `SANDBOX_GLOBALS` (`http_client` **is** in it). `wrapclient` and `socket_tcp` were reachable only through `addclient`. It was also **broken**: on the `timeout` retry path it discarded `wrapclient`'s return, so `handler` stayed nil while `err` was still `"timeout"` from the `connect` probe - it returned `nil, "timeout", id` for a connect that was in fact proceeding, and `wrapclient` had already armed the deferred wrap that would dispatch it. A caller would have abandoned a live connection as failed. `wrapclient` additionally called `listeners.failure(...)` unguarded. Outbound TCP is not lost: `core/http_client.lua` does its own non-blocking connect over `socket.tcp` + `server.addtimer`, and that is not just the established pattern but the one every outbound consumer already chose - `etc_regserver_announce` (the [#279](https://github.com/luadch-ng/luadch/issues/279) hublist announcer), `etc_blocklist_feeds`, `etc_proxydetect` and `etc_status_push` all go through `http_client`; none ever used `addclient`. Note `core/server.lua`'s own comment (from [#188](https://github.com/luadch-ng/luadch/pull/188)) recorded that `addclient` was knowingly kept and bug-fixed while caller-less, on "a divergent broken copy is a defect" grounds - that is a keep-it-correct decision, not a keep-it decision. (2) **The deferred-TLS `handler.starttls` branch.** Never called from anywhere - and not a seam worth preserving, because it never worked. Where the functioning `startssl` path installs the handshake coroutine as the event-loop entry point (`handler.readbuffer = handshake`, `handler.sendbuffer = handshake`), `starttls` instead wrote `handler.receivedata` / `handler.dispatchdata` - two field names **the event loop never reads**, assigned from `handler.handshake`, which **does not exist** (the handshake coroutine is a local, not a handler field), so they got `nil` besides. It left `readbuffer` / `sendbuffer` pointing at the **plaintext** `handle_read_event` / `handle_write_event`. A caller would have got a TLS-wrapped socket driven by plaintext handlers, stuck mid-handshake because the handshake could never resume across a readiness event. ADC has no STARTTLS anyway (`adcs://` is TLS from connect); this is Prosody `net.server_select` / XMPP heritage that no ADC path could ever call. Its dead companions go too: `needtls` (written twice, never read) and `wrapconnection`'s `local shutdown` (assigned twice, never called - not to be confused with `wrapserver`'s `handler.shutdown`, which is **live** via `hub.lua`'s `_servers` sweep). Also swept: `changetimeout`, declared and never assigned - the same defect class PR 3a removed `stop` for, missed by that pass. The `else` branch's `handler.readbuffer` / `handler.sendbuffer` assignments are **kept**: `sslctx`-without-`startssl` is unreachable from `hub.lua` today, but `addserver` takes the two independently, and removing the whole branch would leave such a connection with no read/write handlers at all - worse than the plaintext it gets now. (3) **`handler.socket` and `handler.id` on the `wrapserver` handler**, per PR 3a's correction: that object reaches no plugin (`add_server_handler` is a `hub.lua` local and `addserver`'s return lives in a private `_servers` table), and `hub.lua` calls only `shutdown` on it. The identically-named `wrapconnection` accessors are contract surface via `user:client()` and stay. Behaviour-neutral: builds, boots, full smoke suite green - including the TLS login path immediately adjacent to the removed branch. 3.2.x only, not backported. + +- [#447](https://github.com/luadch-ng/luadch/issues/447) PR 3a - **`core/server.lua`: remove 44 lines of unambiguously dead, core-internal code.** Two exported functions with zero callers anywhere: `getsettings` / `changesettings` (the runtime knobs they read and wrote - `_selecttimeout`, `_sleeptime`, the buffer caps, the idle timeouts - keep their module-level initialisers and every real read, so they are now the constants they already were in practice, since nothing ever called the setter), and `stats`, whose module-wide `_readtraffic` / `_sendtraffic` counters were read **only** there: dropping them also removes two additions from the read and send hot paths. The per-connection `readtraffic` / `sendtraffic` and their accessor `handler.getstats` are **untouched and live** (`cmd_userinfo.lua:212`) - the two pairs are easy to confuse. Plus five dead module locals (`table_remove` - `tick()` calls `table.remove` directly; `socket_bind`; `signal` + `signal_set` + `signal_get`; `stop`, declared and never assigned) and the unused `disconnect` binding in `wrapclient` / `wrapserver` (`wrapconnection`'s own binding is live - do not confuse those either). Removing `local signal = use "signal"` does not shift the module load order: `signal` is loaded well before `server` in `_core`, so the call only ever read an already-loaded `_global` entry and never reached `loadscript`. **Deliberately NOT removed, contra the audit's own note:** the unused `wrapconnection` handler accessors `id`, `dispatch`, `disconnect`, `bufferlen` and `pattern`. The audit argued they are unreachable because `server` is absent from `SANDBOX_GLOBALS`. That forecloses `server.X` access, but it is a non-sequitur for an object handed to a plugin by reference: `user:client()` returns `wrapconnection`'s handler verbatim, and `docs/PLUGIN_API.md` documents it both under §"Network" ("Underlying socket handler") and in its type glossary, where `handler` is the only type NOT linked to an enumerated method section - it is defined as "a wrapped socket from `core/server.lua`", i.e. the doc delegates the surface to the source. The decisive evidence is `getstats`: it appears **nowhere** in `docs/`, yet `cmd_userinfo.lua` depends on it through exactly that route. So non-enumeration cannot mean non-contract, or `getstats` would be equally removable and `+userinfo` would break. `bufferlen` and `pattern` are setters, so a third-party plugin may well be tuning buffers through them. #447's conservative-on-contract-surface rule keeps all five. Note the audit's list also spans two DIFFERENT objects: `handler.socket`, and a second `handler.id`, live on the `wrapserver` handler, which no plugin route reaches - those are genuinely dead and left to PR 3b on their own merits, not on this contract argument. Behaviour-neutral: builds, boots, full smoke suite green. 3.2.x only, not backported. + +- [#447](https://github.com/luadch-ng/luadch/issues/447) PR 2 - **remove `core/doc.lua`** (308 lines), the auto-doc generator, and its two dead references in `core/init.lua`. The module was dead three times over: commented out of the `_core` load list, its `doc.export()` call site commented out, and **its entire 19-entry `doc.add(...)` payload sits inside a `--[[ ]]` block** - so even after uncommenting the first two, `export()` would have written a file containing nothing but the heading. Zero `use "doc"` anywhere, absent from `core/scripts.lua`'s `SANDBOX_GLOBALS` and from `docs/PLUGIN_API.md`, and its output target `docs/documentation.txt` has never existed. This reverses the 2026-05-23 housekeeping decision to keep it "for potential future re-enable" (`docs/phases/PHASE_6.md`, and the housekeeping-sweep entry further down this same section), because that premise does not survive contact with the payload: it documents `util.save` / `util.load` (the exports are `savetable` / `loadtable`; the only occurrences of `util.save`/`util.load` in the repo were doc.lua's own examples, which would crash), `server.wrapsslclient` / `server.wraptcpclient` (gone), and `server.add` / `server.closeall` / `server.loop` (today `addserver` / `killall`, and the loop is `hub.loop`). A re-enable would mean rewriting all 19 entries from scratch, and `docs/PLUGIN_API.md` already covers the ground and is maintained. Riding along: `init.lua`'s `--"test",` and `--test( )`, dangling since `core/test.lua` was deleted in #49. `install(DIRECTORY core/)` is wholesale, so no build change - but it never deletes, so an in-place upgrade leaves a stale `core/doc.lua` behind. That leftover is inert because nothing calls `use "doc"`, NOT because it is out of `_core`: `use` resolves by filename via `loadscript`, so it would happily load a leftover copy off disk if anything asked for it. Docker is exempt either way (`core/` is baked from source into a fresh runtime image, never overlaid onto a previous tree). Verified by booting the hub with the file genuinely absent from the install tree: full smoke suite green - worth doing, because CMake's install had left the old copy in place and a run against an un-cleaned tree would have proved nothing. `examples/etc/other_available_scripts/cmd_showdoc.lua` still calls `doc.collect()`; it has been non-functional since the sandbox landed (`doc` is not a sandbox global, so under the default `no_global_scripting = true` the strict env raises "attempt to read undeclared var: 'doc'") and is left alone as out-of-scope graveyard code - `examples/etc/` is not installed at all. 3.2.x only, not backported. + +- [#447](https://github.com/luadch-ng/luadch/issues/447) PR 1 - **`core/hub.lua`: remove 19 dead file-scope locals** (dead-code audit). Declared and never referenced anywhere in the repo: `tablesize`, `loadfile`, `io`, `table_concat`, four `adclib_*` aliases and `util_difftime` (their consumers moved to `hub_dispatch.lua` in Phase 6d), `types_check`, the four `_verify` / `_normal` / `_protocol` / `_identify` state-table slots (likewise living in `hub_dispatch.lua` since Phase 6d), `_G`, `_cfg_nick_change`, `killuser`, `usercount` and `isuseronline`. Riding along: `isuseronline()`'s body (defined, never called, a strict superset of the live `isnickonline` / `iscidonline` / `issidonline` trio), `killuser`'s 39 commented-out lines, and the `hub.usercount` export - never assigned in hub.lua's history (`git log -S"usercount = function" -- core/hub.lua` is empty), so the constructor's `usercount = nil` never created the key and any plugin calling it already got "attempt to call a nil value". 83 lines, behaviour-neutral: the hub builds, boots and passes the full smoke suite. Two removals were all-or-nothing - `local _G` and its `_G = _G` self-assignment (a no-op, since `_G` resolves to the nil local on both sides), and `local isuseronline` with its assignment - because deleting only the declaration turns the assignment into a bare global write, which `core/init.lua`'s restricted env rejects at hub load (the #353 class). The `nick_change` **feature** is live and untouched; only hub.lua's stale cache slot was dead. **Follow-up:** a 20th (`tonumber`) survived - PR 1 worked from the tracker's list, which was short; PR 5 above removes it. Frees 19 of the main chunk's 200 local slots, and measuring that headroom with `luac -p` disproved a claim `CLAUDE.md` and `docs/DEVELOPMENT.md` had both been carrying: hub.lua's file-scope locals were documented as frozen ("any new top-level `local` fails the build") when the file actually had 13 slots free, and now has 32. Both docs state the constraint without a number that can rot again and point at `luac -p -l` as the way to measure it, and the four in-code comments asserting the ceiling in the present tense are reworded as the historical rationale they always were (a fifth, at the `#301` i18n cache, already used the past tense and is left alone). 3.2.x only, not backported. + +- **three core cleanups from an external contributor** (@Kcchouette - [#437](https://github.com/luadch-ng/luadch/pull/437), [#438](https://github.com/luadch-ng/luadch/pull/438), [#439](https://github.com/luadch-ng/luadch/pull/439)). (1) `core/hub_user_object.lua` `user.setlevel` called `cfg_saveusers( _regusers )` **twice** - once bare, then again as the return expression - so every level change wrote `user.tbl` to disk two times; the bare call and a stale `--return true` comment are gone and the return contract is unchanged (the sibling `setrank` already had the clean shape). (2) `core/adc.lua` `_regex` declared the key `bool` twice: the first matched `_sid` (the 4-char base32 SID pattern, wrong for a boolean), the second `_bool`. A Lua table constructor applies both in order, so the last already won - the correct `_bool` variant was live all along and the first was dead code. Removing it is a no-op, but the duplicate was a trap for anyone reordering the table. (3) `core/util.lua` `formatseconds` / `formatbytes` guarded with `not type( t ) == "number"`, which parses as `( not type( t ) ) == "number"` -> `false == "number"` -> always false, so the guard never fired; now `type( t ) ~= "number"`. Also a no-op today: the `local t = tonumber( t )` rebind directly above already reduces every non-numeric argument to `nil`, which the preceding `not t` branch rejects, so the check is unreachable either way - fixed because the expression becomes a live bug the moment that rebind is refactored away (the same file already used the correct form at a third site). Net effect: (2) and (3) are behaviour-neutral hardening; (1) halves the disk writes on a level change. 3.2.x only. + - [#301](https://github.com/luadch-ng/luadch/issues/301) follow-up - move the bundled core language tables from `examples/lang/` to `lang/` at source-tree root. Matches the install-tree layout (CMake was already installing them to `lang/`), matches the precedent of `scripts/lang/` living under its consumer rather than under `examples/`, and removes a "why is this under examples/?" stumbling block for new contributors / Weblate onboarding. Pure `git mv` so blame history is preserved; CMakeLists.txt updated to point at the new source path (1 line); `tests/unit/lang_test.lua` + smoke.yml comment updated. Install output is byte-identical (verified). 3.2.x only, not backported. ### Bugfixes +- **`cmd_ban`: a negative `