From a4b091578d0a627542153b9bbc7ac39f6cfae0ff Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 22 Sep 2026 16:12:47 -0700 Subject: [PATCH] CI: fail when the upstream name appears in a new string literal tools/fork/name-check.py reads every string literal in crates/ (comments and test directories skipped) and fails on any that carries the upstream name without an entry in name-allowlist.txt. An upstream merge can bring such strings in without a conflict, so it runs on every push and PR. The first run found three the earlier sweeps missed, fixed here: the SMTP HELP reply pointed at upstream's website (now brand_url!), the event collector thread was named after upstream, and the FreeBSD default data path still said /var/db/stalwart/ where Linux already had /var/lib/inbuxa/. Two operator-visible defaults are allowlisted as open, pending a decision: the log file prefix and the SQL stores' default database and user. --- .gitea/workflows/ci.yml | 10 ++ crates/jmap/src/registry/mapping/bootstrap.rs | 2 +- crates/smtp/src/inbound/session.rs | 2 +- crates/trc/src/ipc/collector.rs | 2 +- tools/fork/README.md | 16 ++ tools/fork/name-allowlist.txt | 59 +++++++ tools/fork/name-check.py | 145 ++++++++++++++++++ 7 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 tools/fork/name-allowlist.txt create mode 100755 tools/fork/name-check.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index dce400e..ad00335 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -20,6 +20,16 @@ concurrency: cancel-in-progress: true jobs: + # The upstream name in a new string literal, typically brought in by an + # upstream merge. Seconds, and needs no toolchain. tools/fork/name-check.py. + name-check: + runs-on: light + container: + image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim + steps: + - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec + - run: python3 tools/fork/name-check.py + build: # Either runner (host1 or host2): the build needs no docker socket. runs-on: light diff --git a/crates/jmap/src/registry/mapping/bootstrap.rs b/crates/jmap/src/registry/mapping/bootstrap.rs index f295806..65a7d34 100644 --- a/crates/jmap/src/registry/mapping/bootstrap.rs +++ b/crates/jmap/src/registry/mapping/bootstrap.rs @@ -657,7 +657,7 @@ fn map_dns_server(dns_server: &DnsServerBootstrap) -> Option Session { Request::Help { .. } => { trc::event!(Smtp(SmtpEvent::Help), SpanId = self.data.session_id,); - self.write(b"250 2.0.0 Help can be found at https://stalw.art\r\n") + self.write(concat!("250 2.0.0 Help can be found at ", types::brand_url!(), "\r\n").as_bytes()) .await?; } Request::Helo { host } => { diff --git a/crates/trc/src/ipc/collector.rs b/crates/trc/src/ipc/collector.rs index a031d00..1e3f1fb 100644 --- a/crates/trc/src/ipc/collector.rs +++ b/crates/trc/src/ipc/collector.rs @@ -80,7 +80,7 @@ const SPAN_MAX_HOLD: u64 = 60 * 60 * 24; // 1 day pub(crate) static COLLECTOR_THREAD: LazyLock> = LazyLock::new(|| { Arc::new( Builder::new() - .name("stalwart-collector".to_string()) + .name("inbuxa-collector".to_string()) .spawn(move || { Collector::default().collect(); }) diff --git a/tools/fork/README.md b/tools/fork/README.md index ee2c770..0821ac0 100644 --- a/tools/fork/README.md +++ b/tools/fork/README.md @@ -21,6 +21,22 @@ at the repository root before the import is merged. It needs Python 3.12+ (for `tarfile`'s `data` filter) and git. +## name-check.py + +Fails when the upstream project's name appears in a Rust string literal that +`name-allowlist.txt` doesn't list. CI runs it on every push and pull request, +so an upstream merge can't bring the name back into what users and operators +see. Comments, copyright headers and test directories aren't checked. + +```bash +tools/fork/name-check.py # exit 1 on anything new +tools/fork/name-check.py --list # every finding, in allowlist format +``` + +Rename what it reports. If a string has to stay, such as a key-derivation +context or a wire-protocol identifier, add its `--list` line to the allowlist +under the reason it stays. + ## record-compat.py Records what the `*_compat` tests compare against, from the Enterprise diff --git a/tools/fork/name-allowlist.txt b/tools/fork/name-allowlist.txt new file mode 100644 index 0000000..a279f62 --- /dev/null +++ b/tools/fork/name-allowlist.txt @@ -0,0 +1,59 @@ +# String literals allowed to keep the upstream project's name. +# Read by tools/fork/name-check.py. One per line: pathliteral as a JSON +# string, as `name-check.py --list` prints it. Each group says why it stays; +# add a line only under a reason, or with a new one. + +# Key-derivation contexts. Renaming them invalidates every sealed OAuth token +# and client id already issued. +crates/common/src/auth/oauth/client_id.rs "stalwart-oauth-client-id-sw1" +crates/common/src/auth/oauth/token.rs "stalwart-oauth-token-sw1" + +# Keys and prefixes of data already in the store. +crates/common/src/manager/application.rs "STALWART_APP_" +crates/common/src/manager/mod.rs "STALWART_SPAM_CLASSIFIER_MODEL.lz4" +crates/common/src/manager/mod.rs "STALWART_SPAM_TRAIN_DATA.lz4" + +# The web interface's OAuth client id, which existing installs and the admin +# front end already use. The id itself, and the tests that check it. +crates/common/src/manager/first_party.rs "stalwart-webui" +crates/common/src/manager/application.rs "stalwart-webui" +crates/common/src/manager/application.rs "" + +# Wire-protocol identifiers clients already hold or negotiate: WebDAV lock and +# sync tokens, the JMAP capability, Sieve extensions. +crates/dav/src/common/lock.rs "urn:stalwart:davsync:" +crates/dav/src/common/uri.rs "urn:stalwart:" +crates/dav/src/common/uri.rs "urn:stalwart:davlock:{id:x}" +crates/dav/src/common/uri.rs "urn:stalwart:davsync:" +crates/dav/src/common/uri.rs "urn:stalwart:davsync:{id:x}" +crates/dav/src/common/uri.rs "urn:stalwart:davsync:{id:x}:{seq:x}" +crates/jmap-proto/src/request/capability.rs "urn:stalwart:jmap" +crates/registry/src/schema/enums_impl.rs "vnd.stalwart.expressions" +crates/registry/src/schema/enums_impl.rs "vnd.stalwart.while" + +# Moving an existing upstream installation over: its environment variables, +# and upstream's upgrade guide for the store conversion. +crates/types/src/branding.rs "STALWART_{name}" +crates/types/src/branding.rs "Warning: STALWART_{name} is deprecated; set INBUXA_{name} instead." +crates/store/src/build/registry.rs "⚠️ INBUXA_RECOVERY_ADMIN (or STALWART_RECOVERY_ADMIN) is set, but the" +crates/jmap/src/registry/mapping/bootstrap.rs "https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md" +crates/migration/src/lib.rs "https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md" + +# Upstream's published spam-filter rules, fetched at runtime. +crates/registry/src/schema/structs_impl.rs "https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz" + +# Test fixtures: web-push contact address parsing. +crates/common/src/network/webpush.rs " hello@stalw.art " +crates/common/src/network/webpush.rs "MAILTO:hello@stalw.art" +crates/common/src/network/webpush.rs "hello@stalw.art" +crates/common/src/network/webpush.rs "http://stalw.art" +crates/common/src/network/webpush.rs "https://stalw.art/contact" +crates/common/src/network/webpush.rs "mailto:hello@stalw.art" +crates/common/src/network/webpush.rs "stalw.art" + +# OPEN, not yet decided (2026-09-22): operator-visible defaults. The log file +# prefix (TracerLog, and the bootstrap's tracer) names files stalwart.* in +# /var/log/inbuxa/, and the SQL stores default their database and user to +# "stalwart". Changing the SQL defaults would break an install relying on them. +crates/jmap/src/registry/mapping/bootstrap.rs "stalwart" +crates/registry/src/schema/structs_impl.rs "stalwart" diff --git a/tools/fork/name-check.py b/tools/fork/name-check.py new file mode 100755 index 0000000..a430a7f --- /dev/null +++ b/tools/fork/name-check.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Coffey Labs +# SPDX-License-Identifier: AGPL-3.0-only +""" +Fail when the upstream project's name turns up in a new Rust string literal. + + tools/fork/name-check.py # check; exit 1 on anything new + tools/fork/name-check.py --list # print every finding, allowlist format + +The name belongs only in copyright notices and the lineage line. Everything +else a user or operator can see -- messages, the version string, service +names, descriptions -- carries INBUXA's. Merging an upstream release brings +new strings in with the name, and the merge itself can't tell, so this runs +in CI on every push and pull request. + +Scope: string literals in `crates/**/*.rs`, test directories excluded. +Comments are skipped, so copyright headers and doc comments never match. +Some literals have to keep the name -- key-derivation contexts, wire-protocol +identifiers, defaults that read an upstream installation -- and those are +listed in `name-allowlist.txt` beside this script, each under the reason it +stays. A finding is matched by file and literal text, not line number, so +the allowlist survives code moving around. + +When the check fails, rename the string. If it genuinely has to stay, add +the line `--list` prints for it to the allowlist under a reason. +""" +import argparse +import json +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +ALLOWLIST = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'name-allowlist.txt') +NAME = re.compile(r'stalwart|stalw\.art', re.IGNORECASE) +SKIP_DIRS = {'tests', 'benches', 'target', '.git'} +CHAR = re.compile(r"'(?:\\u\{[0-9a-fA-F]+\}|\\x[0-9a-fA-F]{2}|\\.|[^\\'\n])'") +RAW = re.compile(r'b?r(#*)"') + + +def literals(src): + """Yield the text of every string literal in `src`, comments skipped.""" + i, n = 0, len(src) + while i < n: + c = src[i] + if src.startswith('//', i): + i = src.find('\n', i) + if i < 0: + return + elif src.startswith('/*', i): + depth, i = 1, i + 2 + while i < n and depth: + if src.startswith('/*', i): + depth, i = depth + 1, i + 2 + elif src.startswith('*/', i): + depth, i = depth - 1, i + 2 + else: + i += 1 + elif c in 'br' and (m := RAW.match(src, i)) and (i == 0 or not (src[i - 1].isalnum() or src[i - 1] == '_')): + end = '"' + m.group(1) + j = src.find(end, m.end()) + if j < 0: + return + yield src[m.end():j] + i = j + len(end) + elif c == '"': + j = i + 1 + while j < n and src[j] != '"': + j += 2 if src[j] == '\\' else 1 + yield src[i + 1:j] + i = j + 1 + elif c == "'": + # A char literal, or else a lifetime / label, which is skipped. + m = CHAR.match(src, i) + i = m.end() if m else i + 1 + else: + i += 1 + + +def findings(): + found = set() + crates = os.path.join(ROOT, 'crates') + for dirpath, dirnames, filenames in os.walk(crates): + dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS) + for f in sorted(filenames): + if not f.endswith('.rs'): + continue + path = os.path.join(dirpath, f) + with open(path, encoding='utf-8', errors='replace') as fh: + for lit in literals(fh.read()): + if NAME.search(lit): + found.add((os.path.relpath(path, ROOT), lit)) + return found + + +def fmt(entry): + return f'{entry[0]}\t{json.dumps(entry[1], ensure_ascii=False)}' + + +def allowlist(): + allowed = set() + with open(ALLOWLIST, encoding='utf-8') as fh: + for n, line in enumerate(fh, 1): + line = line.rstrip('\n') + if not line.strip() or line.lstrip().startswith('#'): + continue + path, sep, lit = line.partition('\t') + try: + allowed.add((path, json.loads(lit))) + except (ValueError, TypeError): + sys.exit(f'{ALLOWLIST}:{n}: expected "pathjson string", got {line!r}') + if not sep: + sys.exit(f'{ALLOWLIST}:{n}: expected "pathjson string", got {line!r}') + return allowed + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0].strip()) + ap.add_argument('--list', action='store_true', help='print every finding in allowlist format and exit') + args = ap.parse_args() + + found = findings() + if args.list: + for entry in sorted(found): + print(fmt(entry)) + return 0 + + allowed = allowlist() + new = sorted(found - allowed) + stale = sorted(allowed - found) + for entry in stale: + # Gone from the code: harmless, but the list should shrink with it. + print(f'stale allowlist entry, no longer in the code: {fmt(entry)}') + if new: + print(f'\n{len(new)} string literal(s) carry the upstream name. Rename them, or if one must stay,') + print(f'add its line to {os.path.relpath(ALLOWLIST, ROOT)} under the reason:\n') + for entry in new: + print(fmt(entry)) + return 1 + print(f'name check: clean ({len(found)} allowlisted literal(s)).') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) -- 2.54.0