Everything clients, users and operators meet now carries the fork's name, with no aliases (SPEC.md §2.4, changed here from "protocol identifiers stay"): - JMAP: upstream's registry capability is urn:inbuxa:jmap:registry, beside the fork's own urn:inbuxa:jmap. - WebDAV lock and sync tokens are urn:inbuxa:dav*; clients resync once. - Sieve: vnd.inbuxa.while and vnd.inbuxa.expressions. sieve-rs spells these into its compiler, so it's vendored (vendor/sieve-rs, 0.7.3) and patched in; a unit test fails if Cargo.lock ever moves past the vendored copy. The trusted runtime now names itself too, rather than answering sieve-rs's default. - The web interface's OAuth client is inbuxa-webui. On every start the old stalwart-webui client is removed and any application naming it is moved over. - The spam filter's blobs are INBUXA_SPAM_*; every start moves any left under the old keys, so a trained model survives. - SQL stores and log files default to inbuxa, in the code and in the schema served to the admin (checksum regenerated). - Settings are INBUXA_* only. A STALWART_* variable that's set where its INBUXA_* one isn't stops the server at startup, naming it. - The version-upgrade messages link docs.inbuxa.org's migration page, and the OpenAPI description, smtp crate metadata and web-push test fixtures lose the name. Kept on purpose, allowlisted with reasons: the OAuth key-derivation contexts (renaming them would end every session and invalidate every sealed client id) and the hashed application prefix. Also fixes a latent start-up failure: ensure_client updated an existing first-party client with a revision of 0, which the registry's assertion never matches, so adding a redirect URI or changing the webmail secret failed start-up. And the principal session test now expects legacyProtocols (C-1, added 2026-09-21), which it had missed. Tested: the server builds without warnings; common's 106 unit tests, including the vendoring check; a new integration test for the two start-up migrations; and the webdav, jmap, imap and SMTP Sieve suites.
153 lines
5.5 KiB
Python
Executable File
153 lines
5.5 KiB
Python
Executable File
#!/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` and `vendor/**/*.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()
|
|
for top in ('crates', 'vendor'):
|
|
found |= findings_under(os.path.join(ROOT, top))
|
|
return found
|
|
|
|
|
|
def findings_under(top):
|
|
found = set()
|
|
for dirpath, dirnames, filenames in os.walk(top):
|
|
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 "path<TAB>json string", got {line!r}')
|
|
if not sep:
|
|
sys.exit(f'{ALLOWLIST}:{n}: expected "path<TAB>json 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())
|