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.
This commit is contained in:
@@ -20,6 +20,16 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
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:
|
build:
|
||||||
# Either runner (host1 or host2): the build needs no docker socket.
|
# Either runner (host1 or host2): the build needs no docker socket.
|
||||||
runs-on: light
|
runs-on: light
|
||||||
|
|||||||
@@ -657,7 +657,7 @@ fn map_dns_server(dns_server: &DnsServerBootstrap) -> Option<registry::schema::s
|
|||||||
// FreeBSD keeps variable application data under /var/db (hier(7))
|
// FreeBSD keeps variable application data under /var/db (hier(7))
|
||||||
// rather than FHS /var/lib.
|
// rather than FHS /var/lib.
|
||||||
const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") {
|
const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") {
|
||||||
"/var/db/stalwart/"
|
"/var/db/inbuxa/"
|
||||||
} else {
|
} else {
|
||||||
"/var/lib/inbuxa/"
|
"/var/lib/inbuxa/"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ impl<T: SessionStream> Session<T> {
|
|||||||
Request::Help { .. } => {
|
Request::Help { .. } => {
|
||||||
trc::event!(Smtp(SmtpEvent::Help), SpanId = self.data.session_id,);
|
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?;
|
.await?;
|
||||||
}
|
}
|
||||||
Request::Helo { host } => {
|
Request::Helo { host } => {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const SPAN_MAX_HOLD: u64 = 60 * 60 * 24; // 1 day
|
|||||||
pub(crate) static COLLECTOR_THREAD: LazyLock<Arc<CollectorThread>> = LazyLock::new(|| {
|
pub(crate) static COLLECTOR_THREAD: LazyLock<Arc<CollectorThread>> = LazyLock::new(|| {
|
||||||
Arc::new(
|
Arc::new(
|
||||||
Builder::new()
|
Builder::new()
|
||||||
.name("stalwart-collector".to_string())
|
.name("inbuxa-collector".to_string())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
Collector::default().collect();
|
Collector::default().collect();
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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.
|
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
|
## record-compat.py
|
||||||
|
|
||||||
Records what the `*_compat` tests compare against, from the Enterprise
|
Records what the `*_compat` tests compare against, from the Enterprise
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# String literals allowed to keep the upstream project's name.
|
||||||
|
# Read by tools/fork/name-check.py. One per line: path<TAB>literal 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 "<meta name=\\\"oauth-client-id\\\" content=\\\"stalwart-webui\\\" />"
|
||||||
|
|
||||||
|
# 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 " [email protected] "
|
||||||
|
crates/common/src/network/webpush.rs "MAILTO:[email protected]"
|
||||||
|
crates/common/src/network/webpush.rs "[email protected]"
|
||||||
|
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:[email protected]"
|
||||||
|
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"
|
||||||
Executable
+145
@@ -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 "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())
|
||||||
Reference in New Issue
Block a user