273 lines
13 KiB
Python
Executable File
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')
|