Files
PostfixAdminRS/scripts/validate-release-static.py
T
2026-11-03 20:05:17 +01:00

273 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
errors: list[str] = []
def fail(message: str) -> None:
errors.append(message)
def load_toml(path: Path):
try:
with path.open('rb') as handle:
return tomllib.load(handle)
except Exception as exc:
fail(f'{path.relative_to(ROOT)}: invalid TOML: {exc}')
return {}
cargo = load_toml(ROOT / 'Cargo.toml')
toolchain = load_toml(ROOT / 'rust-toolchain.toml')
package = cargo.get('package', {})
if package.get('edition') != '2024':
fail('Cargo.toml must use edition 2024')
if package.get('rust-version') != '1.99.1':
fail('Cargo.toml rust-version must be 1.99.1')
if package.get('version') != '0.0.2':
fail('Cargo.toml version must be 0.0.2 for this release')
if toolchain.get('toolchain', {}).get('channel') != '1.99.1':
fail('rust-toolchain.toml must pin 1.99.1')
for path in ROOT.glob('config*.toml'):
load_toml(path)
for path in (ROOT / 'src/web/i18n').glob('*.json'):
try:
json.loads(path.read_text(encoding='utf-8'))
except Exception as exc:
fail(f'{path.relative_to(ROOT)}: invalid JSON: {exc}')
for kind, extension in [('css', '.css'), ('js', '.js')]:
directory = ROOT / f'src/web/{kind}'
manifest = directory / 'manifest.txt'
entries = []
for raw in manifest.read_text(encoding='utf-8').splitlines():
name = raw.strip()
if not name or name.startswith('#'):
continue
if '/' in name or '\\' in name or '..' in name or not name.endswith(extension):
fail(f'{manifest.relative_to(ROOT)}: unsafe entry {name!r}')
continue
entries.append(name)
if not (directory / name).is_file():
fail(f'{manifest.relative_to(ROOT)}: missing {name}')
actual = sorted(p.name for p in directory.glob(f'*{extension}'))
if sorted(entries) != actual:
fail(f'{manifest.relative_to(ROOT)} does not list exactly all {extension} files: manifest={sorted(entries)}, files={actual}')
index = (ROOT / 'src/web/index.html').read_text(encoding='utf-8')
if re.search(r'<script(?![^>]*\bsrc=)[^>]*>', index, flags=re.I):
fail('index.html contains an inline script')
if re.search(r'\sstyle\s*=', index, flags=re.I):
fail('index.html contains inline style attributes')
main = (ROOT / 'src/main.rs').read_text(encoding='utf-8')
csp_match = re.search(r'content-security-policy.*?HeaderValue::from_static\("([^"]+)"\)', main, flags=re.S)
if not csp_match:
fail('main.rs: CSP header not found')
else:
csp = csp_match.group(1)
if "'unsafe-inline'" in csp or "'unsafe-eval'" in csp:
fail('CSP must not contain unsafe-inline/unsafe-eval')
for path in ROOT.rglob('*'):
if not path.is_file() or any(part in {'target', 'dist', '.git'} for part in path.parts):
continue
if path.suffix.lower() == '.php':
fail(f'PHP file present: {path.relative_to(ROOT)}')
rust_text = '\n'.join(p.read_text(encoding='utf-8', errors='ignore') for p in (ROOT / 'src').rglob('*.rs'))
for forbidden in ['Command::new("sh")', 'Command::new("bash")', '.arg("-c")']:
if forbidden in rust_text:
fail(f'forbidden shell execution marker: {forbidden}')
required_markers = [
'php_crypt:MD5',
'hash_internal_secret',
'/auth/recovery/request',
'/alias-domains',
'/dkim/signing',
'/security/totp-exceptions',
'/security/app-passwords',
'/aliases/delete-inactive/{domain}',
'/preferences/{key}',
'SUPPORTED_SCHEMES',
'database.dev_mock',
'quota_level_med_pct',
'quota_level_high_pct',
'generate_password',
'show_password',
'content-security-policy',
]
project_text = rust_text + '\n' + (ROOT / 'src/routes/mod.rs').read_text(encoding='utf-8')
for marker in required_markers:
if marker not in project_text:
fail(f'missing expected compatibility marker: {marker}')
user_config = (ROOT / 'config.from-user.toml').read_text(encoding='utf-8')
if re.search(r'^password\s*=\s*"[^"\n]+"', user_config, flags=re.M):
fail('config.from-user.toml contains a database password')
env_example = (ROOT / '.env.example').read_text(encoding='utf-8')
if 'POSTFIXADMIN_RS__DATABASE__PASSWORD=CHANGE_ME' not in env_example:
fail('.env.example must contain a non-secret placeholder')
js_text = '\n'.join(p.read_text(encoding='utf-8', errors='ignore') for p in (ROOT / 'src/web/js').glob('*.js'))
if 'style=' in js_text or '.style.' in js_text:
fail('frontend JS contains inline style mutations that are blocked by CSP')
for forbidden in ['prompt(', 'confirm(', 'alert(']:
if forbidden in js_text:
fail(f'native browser dialog remains in frontend: {forbidden}')
for marker in ['/rspamd-dkim/generate', 'selector_map', 'path_map', 'RspamdDkimConfig']:
if marker not in project_text + '\n' + (ROOT / 'src/config.rs').read_text(encoding='utf-8'):
fail(f'missing Rspamd DKIM integration marker: {marker}')
release_002_markers = {
'src/routes/vacation.rs': ['sync_vacation_alias', 'vacation_address', 'validate_dates'],
'src/routes/aliases.rs': ['technical', 'internal_vacation', 'can_edit_description'],
'src/web/js/10-pages.js': ['vacationMailboxPicker', 'Aliasy techniczne', 'local_part'],
'packaging/postfixadmin-rs-rspamd-setup': ['postfixadmin-rspamd', 'systemctl restart postfixadmin-rs rspamd'],
}
for relative, markers in release_002_markers.items():
text_value = (ROOT / relative).read_text(encoding='utf-8')
for marker in markers:
if marker not in text_value:
fail(f'{relative}: missing 0.0.2 regression marker: {marker}')
node = shutil.which('node')
if node:
for path in sorted((ROOT / 'src/web/js').glob('*.js')):
result = subprocess.run([node, '--check', str(path)], capture_output=True, text=True)
if result.returncode:
fail(f'{path.relative_to(ROOT)}: node --check failed: {result.stderr.strip()}')
for script in [ROOT / 'dev.sh', ROOT / 'scripts/build-release.sh', ROOT / 'scripts/package-release.sh', ROOT / 'scripts/build-deb.sh', ROOT / 'packaging/postfixadmin-rs-rspamd-setup']:
result = subprocess.run(['bash', '-n', str(script)], capture_output=True, text=True)
if result.returncode:
fail(f'{script.relative_to(ROOT)}: bash -n failed: {result.stderr.strip()}')
# UI asset cache busting: the SVG sprite is immutable only under a content-hashed URL.
ui_rs = (ROOT / 'src/ui.rs').read_text(encoding='utf-8')
build_rs = (ROOT / 'build.rs').read_text(encoding='utf-8')
core_js = (ROOT / 'src/web/js/00-core.js').read_text(encoding='utf-8')
for marker in ['icons.{ASSET_ID}.svg', 'pa-rs-asset']:
if marker not in ui_rs + index + core_js:
fail(f'missing versioned icon asset marker: {marker}')
if 'src/web/icons.svg' not in build_rs or 'let icons = fs::read' not in build_rs:
fail('asset id must include icons.svg content')
# Local Debian packaging must stay Docker-based and emit only into ./packages.
deb_script = (ROOT / 'scripts/build-deb.sh').read_text(encoding='utf-8')
for marker in ['docker run --rm', 'debian:12-slim', 'dpkg-deb --root-owner-group', 'OUT="$ROOT/packages"']:
if marker not in deb_script:
fail(f'missing Debian packaging marker: {marker}')
# Development safety: mock DB must not be accepted as a production backend.
config_rs = (ROOT / 'src/config.rs').read_text(encoding='utf-8')
if 'database.dev_mock is allowed only with environment = development' not in config_rs:
fail('missing development mock environment guard')
if 'database.dev_mock requires database.backend = sqlite' not in config_rs:
fail('missing development mock SQLite guard')
passwords_rs = (ROOT / 'src/passwords.rs').read_text(encoding='utf-8')
for marker in ['verifies_postfixadmin_reference_vectors', 'verifies_postfixadmin_b64_crypt_vectors', 'php_crypt:MD5', 'courier:md5raw', 'ARGON2ID.B64']:
if marker not in passwords_rs:
fail(f'missing password compatibility marker/test: {marker}')
# sqlx::Any + MySQL compatibility: PostfixAdmin stores flags as TINYINT(1),
# which the SQLx Any bridge does not expose directly. Keep the normalization
# helper and make sure the login path uses it.
db_rs = (ROOT / 'src/db.rs').read_text(encoding='utf-8')
for marker in ['pub fn cast_i64(&self, expr: &str)', 'CAST({expr} AS SIGNED)', 'self.flag("active", "active")', 'self.flag("superadmin", "superadmin")']:
if marker not in db_rs:
fail(f'missing MySQL TINYINT/sqlx::Any compatibility marker: {marker}')
# UI internationalization / development tunnel safety.
config_dev = load_toml(ROOT / 'config.dev.toml')
if config_dev.get('default_language') != 'en':
fail('config.dev.toml must default to English')
for lang in ['en', 'pl']:
if not (ROOT / f'src/web/i18n/{lang}.json').is_file():
fail(f'missing UI language catalog: {lang}')
if 'initLanguage' not in js_text or 'languageSelect' not in js_text:
fail('frontend language selector/i18n bootstrap is missing')
branding_text = '\n'.join(p.read_text(encoding='utf-8', errors='ignore') for p in (ROOT / 'src/web').rglob('*') if p.is_file())
if ('· bez ' + 'PHP') in branding_text or ('Panel poczty bez ' + 'PHP') in branding_text:
fail('deprecated PHP-free branding text remains')
if 'POSTFIXADMIN_RS_DEV_DB_MODE' not in (ROOT / 'dev.sh').read_text(encoding='utf-8'):
fail('development SSH DB mode is missing')
if 'POSTFIXADMIN_RS_DEV_DB_WRITES' not in (ROOT / 'dev.sh').read_text(encoding='utf-8'):
fail('development production-DB write opt-in is missing')
if 'read_only' not in config_rs or 'Database is read-only in this development session' not in project_text:
fail('read-only production DB guard is missing')
# Mailbox-user UX and optional ManageSieve integration.
sieve_rs = (ROOT / 'src/routes/sieve.rs').read_text(encoding='utf-8')
auth_rs = (ROOT / 'src/auth.rs').read_text(encoding='utf-8')
routes_mod = (ROOT / 'src/routes/mod.rs').read_text(encoding='utf-8')
dev_sh = (ROOT / 'dev.sh').read_text(encoding='utf-8')
pages_js = (ROOT / 'src/web/js/10-pages.js').read_text(encoding='utf-8')
core_js = (ROOT / 'src/web/js/00-core.js').read_text(encoding='utf-8')
for marker in ['/sieve/status', '/sieve/scripts/{name}', '/sieve/check']:
if marker not in routes_mod:
fail(f'missing ManageSieve route: {marker}')
for marker in ['openSieveRuleBuilder', 'buildSieveRule', 'mergeSieveRequirements']:
if marker not in pages_js:
fail(f'missing ManageSieve visual rule builder marker: {marker}')
for marker in ['SieveConfig', 'allow_insecure_auth', 'timeout_secs', 'read_only']:
if marker not in config_rs:
fail(f'missing ManageSieve config marker: {marker}')
for marker in ['mail_password: Option<Arc<str>>', 'pub fn mail_password', 'update_mail_password']:
if marker not in auth_rs:
fail(f'missing in-memory ManageSieve session credential marker: {marker}')
for marker in ['POSTFIXADMIN_RS_DEV_SIEVE_ENABLED', 'POSTFIXADMIN_RS_DEV_SIEVE_LOCAL_PORT', 'POSTFIXADMIN_RS_DEV_SIEVE_WRITES']:
if marker not in dev_sh:
fail(f'missing ManageSieve development tunnel marker: {marker}')
for marker in ['POSTFIXADMIN_RS_DEV_RSPAMD_PUBLIC_SYNC', 'sync-rspamd-dkim-readonly.py', 'rspamd-remote-public']:
if marker not in dev_sh:
fail(f'missing read-only Rspamd DKIM SSH sync marker: {marker}')
if 'common_record' in config_rs or 'common_selector' in config_rs or 'common_record' in user_config or 'common_selector' in user_config:
fail('shared/common DKIM configuration must not exist; DKIM is per-domain')
for marker in ['/dkim/records', 'user_read_only', 'read_only_dns_records']:
if marker not in project_text + '\n' + pages_js + '\n' + config_rs:
fail(f'missing mailbox-user read-only DKIM marker: {marker}')
sync_script = ROOT / 'scripts/sync-rspamd-dkim-readonly.py'
if not sync_script.is_file():
fail('missing public-only Rspamd DKIM SSH sync helper')
else:
result = subprocess.run([sys.executable, '-m', 'py_compile', str(sync_script)], capture_output=True, text=True)
if result.returncode:
fail(f'{sync_script.relative_to(ROOT)}: py_compile failed: {result.stderr.strip()}')
for marker in ["['mailboxes', 'mail', 'Skrzynki', S.me.kind === 'admin']", "S.me.kind === 'user'", 'readonlyDomain(selected)', "api('/mailboxes')", "api('/aliases')", 'vacationUserForm', 'passwordForm']:
if marker not in core_js + '\n' + pages_js:
fail(f'missing mailbox-user UX marker: {marker}')
if 'Sesja: HttpOnly' in branding_text or 'Session: HttpOnly' in branding_text or 'SameSite=Strict, Secure w produkcji' in branding_text:
fail('technical session-security blurb must not remain in the frontend')
if 'Aktywne moduły' in pages_js:
# Allowed only in the administrator branch after the explicit user return.
user_branch = pages_js.find("if (S.me.kind === 'user')")
module_panel = pages_js.find('Aktywne moduły')
if user_branch < 0 or module_panel < user_branch:
fail('mailbox-user overview exposes the active-modules panel')
if 'ManageSieve credentials are not available; sign in again' not in sieve_rs:
fail('ManageSieve routes must require a fresh in-memory mailbox credential')
if errors:
for error in errors:
print(f'ERROR: {error}', file=sys.stderr)
print(f'static validation: FAILED ({len(errors)} issue(s))', file=sys.stderr)
raise SystemExit(1)
print('static validation: OK')