Eight conflicted files resolved, plus the lock file and the schema:
- crates/services/src/task_manager/spam_classifier.rs: upstream's rules
update now replaces existing rules, DNSBL servers, lookups and file
extensions, keeping only whether each is on. Taken, with one difference:
an object an admin edited is kept as it is. Every object an update writes
is fingerprinted (content without `enable`, SHA-256, stored under
SUBSPACE_INBUXA "Sf"), and only one that still matches is replaced.
Scores are never replaced, as upstream has it. The AU-1.10 summary record
now names what was added, replaced and kept, and the bundled rules are
marked applied only when the update fully succeeded, so a failure runs
again on the next start. The marker becomes "3.0.2+2", which runs the
update once on upgrade to fingerprint every rule still as bundled.
- crates/common/src/network/autoconfig/autodiscover.rs: upstream's rewrite
(implicit TLS first, labeled SSL), with the per-protocol switches (LP-7,
LP-14a) passed in as a filter.
- crates/store/src/backend/mysql/{search,write}.rs: upstream's chunked
deletes (no unbounded first DELETE, stop on a short chunk, halve the
chunk on the new chunk-too-large errors) inside the fork's query timeout.
- crates/smtp/src/lib.rs: the fork's queue spawn kept. It already fixed the
stall upstream fixes here (a node without outboundMta stops accepting
mail at about 1024 queued messages), and follows role changes live.
- crates/jmap/src/registry/mapping/bootstrap.rs: the log path stays
/var/log/inbuxa/; upstream's PowerDNS mapping taken.
- crates/main/Cargo.toml: the AGPL-only license kept, version 0.16.24.
- tests/src/jmap/principal/get.rs: the fork's capabilities kept.
- resources/schema/schema.json.gz: merged as JSON; upstream relabeled the
vendor Sieve extensions "(Stalwart)", kept as "(vnd.inbuxa)".
- Cargo.lock: upstream's, with the fork's crates added by Cargo.
Also:
- tests/src/smtp/inbound/spam_rules_kept.rs: an edited rule survives an
update, an unedited one is updated, rules from before fingerprints are
handled, and the audit summary says so. Upstream's own spam_rules test
passes unchanged.
- tests/src/smtp/reporting/reschedule.rs moves to port 19058; upstream's
new spam_rules test took 19057.
- tools/fork/renames.py renames the "(Stalwart)" labels and the default
log path, so neither conflicts again.
- tools/fork/notice-check.py compares against the newest snapshot in the
checked-out history instead of the upstream branch head, so moving the
branch no longer fails other open pull requests.
- tests/src/directory/issuer.rs (since v0.16.23) stays out, and is on the
build check's known list: it tests issuer-based directory routing, which
the fork doesn't have (DIR-2).
- Strip report: docs/fork/strip-reports/v0.16.24.{md,json}.
153 lines
6.5 KiB
Python
153 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
"""
|
|
The upstream name where it's an identifier, and its replacement.
|
|
|
|
tools/fork/renames.py DIR # apply to a tree; prints what changed
|
|
|
|
Clients, users and operators meet the upstream name in a few places that
|
|
aren't notices: wire-protocol names (the JMAP registry capability, WebDAV
|
|
state tokens, Sieve extensions), the web interface's OAuth client id, store keys an
|
|
operator sees as blob names, and configuration defaults (SQL database and
|
|
user, the log file prefix). docs/spec/SPEC.md §2.4 renames them all.
|
|
|
|
strip.py applies this to every upstream import, so each release arrives
|
|
already renamed and those lines never conflict in the merge. `main` was
|
|
renamed with it once, on 2026-09-22. Don't run it on `main` again: code
|
|
written since that names the old spelling on purpose (the migrations that
|
|
retire it) would be renamed too. Those strings are listed in
|
|
name-allowlist.txt instead.
|
|
|
|
Names nobody sees keep upstream's spelling: the OAuth key-derivation contexts
|
|
and the hashed application prefix. Renaming them would only destroy state.
|
|
Copyright notices and prose (`.md`, `.txt`) are never touched.
|
|
"""
|
|
import gzip
|
|
import hashlib
|
|
import base64
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Plain substrings, in code and data: (old, new, the roots it applies under).
|
|
TEXT_RENAMES = [
|
|
# Upstream's JMAP capability for its registry (`x:`) objects. Not plain
|
|
# `urn:inbuxa:jmap`, which is the fork's own capability (contract C-1);
|
|
# listed before the general prefix below so it wins.
|
|
('urn:stalwart:jmap', 'urn:inbuxa:jmap:registry', None),
|
|
('urn:stalwart:', 'urn:inbuxa:', None),
|
|
('vnd.stalwart.', 'vnd.inbuxa.', None),
|
|
('(vnd.stalwart)', '(vnd.inbuxa)', None),
|
|
('stalwart-webui', 'inbuxa-webui', None),
|
|
('STALWART_SPAM_', 'INBUXA_SPAM_', None),
|
|
# Configuration defaults, as upstream's generated registry code spells
|
|
# them. Server code only: the tests use the same spelling for fixtures
|
|
# that must match their containers and identity provider (database users,
|
|
# passwords, an OIDC audience), and name their databases explicitly.
|
|
('"stalwart".to_string()', '"inbuxa".to_string()', ('crates',)),
|
|
('"/var/log/stalwart', '"/var/log/inbuxa', ('crates',)),
|
|
# The spam filter rules ship with the server (common::manager::spam_rules);
|
|
# upstream's default of fetching its latest from GitHub becomes unset.
|
|
('spam_filter_rules_url: Some("https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz".to_string()),',
|
|
'spam_filter_rules_url: None,', ('crates',)),
|
|
]
|
|
ROOTS = ('crates', 'tests', 'resources')
|
|
SKIP_SUFFIXES = {'.md', '.txt'}
|
|
TEXT_SUFFIXES = {'.rs', '.toml', '.py', '.sh', '.json', '.yml', '.yaml', '.js', '.ts', '.html', '.sieve', '.sql'}
|
|
COPYRIGHT = re.compile(r'(?i)(?:SPDX-FileCopyrightText:|\bcopyright\b|©)')
|
|
|
|
# The JSON Schema the server serves to INBUXA Admin, and its checksum.
|
|
SCHEMA = Path('resources/schema/schema.json.gz')
|
|
SCHEMA_HASH = Path('resources/schema/schema.json.sha256')
|
|
SCHEMA_RENAMES = [
|
|
('"stalwart"', '"inbuxa"'),
|
|
('vnd.stalwart', 'vnd.inbuxa'),
|
|
# Sieve extension labels, "(vnd.stalwart)" until upstream v0.16.24.
|
|
('(Stalwart)"', '(vnd.inbuxa)"'),
|
|
# The bundled spam rules: no default URL, and say what empty means.
|
|
('"spamFilterRulesUrl":"https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz",', ''),
|
|
('"URL to download spam filter rules from"',
|
|
'"URL to download spam filter rules from. Empty uses the rules bundled with the server."'),
|
|
]
|
|
|
|
|
|
def rename_line(line, root):
|
|
"""Returns (new line, [(old, new, count)]) for a line of a file under `root`."""
|
|
if COPYRIGHT.search(line):
|
|
return line, []
|
|
done = []
|
|
for old, new, roots in TEXT_RENAMES:
|
|
if roots is not None and root not in roots:
|
|
continue
|
|
if old in line:
|
|
done.append((old, new, line.count(old)))
|
|
line = line.replace(old, new)
|
|
return line, done
|
|
|
|
|
|
def schema_bytes(gz):
|
|
"""The renamed schema, gzipped deterministically, and its checksum."""
|
|
text = gzip.decompress(gz).decode('utf-8')
|
|
for old, new in SCHEMA_RENAMES:
|
|
text = text.replace(old, new)
|
|
out = gzip.compress(text.encode('utf-8'), compresslevel=9, mtime=0)
|
|
digest = base64.urlsafe_b64encode(hashlib.sha256(out).digest()).decode().rstrip('=')
|
|
return out, digest
|
|
|
|
|
|
def apply(tree):
|
|
"""Apply every rename under `tree`; returns {"old → new": {file: count}}."""
|
|
tree = Path(tree)
|
|
done = {}
|
|
|
|
def note(old, new, rel, count):
|
|
done.setdefault(f'{old} → {new}', {})
|
|
done[f'{old} → {new}'][rel] = done[f'{old} → {new}'].get(rel, 0) + count
|
|
|
|
for root in ROOTS:
|
|
base = tree / root
|
|
if not base.is_dir():
|
|
continue
|
|
for path in sorted(base.rglob('*')):
|
|
if not path.is_file() or path.suffix in SKIP_SUFFIXES:
|
|
continue
|
|
if path.suffix not in TEXT_SUFFIXES and not path.name.startswith('Dockerfile'):
|
|
continue
|
|
try:
|
|
lines = path.read_text(encoding='utf-8').split('\n')
|
|
except UnicodeDecodeError:
|
|
continue
|
|
changed = False
|
|
rel = str(path.relative_to(tree))
|
|
for n, line in enumerate(lines):
|
|
new_line, subs = rename_line(line, root)
|
|
for old, new, count in subs:
|
|
note(old, new, rel, count)
|
|
if subs:
|
|
lines[n] = new_line
|
|
changed = True
|
|
if changed:
|
|
path.write_text('\n'.join(lines), encoding='utf-8')
|
|
|
|
schema = tree / SCHEMA
|
|
if schema.is_file():
|
|
before = schema.read_bytes()
|
|
text = gzip.decompress(before).decode('utf-8')
|
|
counts = {old: text.count(old) for old, _ in SCHEMA_RENAMES}
|
|
out, digest = schema_bytes(before)
|
|
if any(counts.values()):
|
|
schema.write_bytes(out)
|
|
(tree / SCHEMA_HASH).write_text(digest, encoding='utf-8')
|
|
for old, new in SCHEMA_RENAMES:
|
|
if counts[old]:
|
|
note(old, new, str(SCHEMA), counts[old])
|
|
return {k: dict(sorted(v.items())) for k, v in sorted(done.items())}
|
|
|
|
|
|
if __name__ == '__main__':
|
|
if len(sys.argv) != 2:
|
|
sys.exit(__doc__.split('\n\n')[1])
|
|
for sub, files in apply(sys.argv[1]).items():
|
|
print(f'{sub}: ' + ', '.join(f'{f} ({n})' for f, n in files.items()))
|