#!/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.1-dev': fail('Cargo.toml version must be 0.0.1-dev during the development phase') 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']*\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}') 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']: 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>', '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')