#!/usr/bin/env python3 """Static/offline release checks for RustBox. These checks intentionally avoid pretending to replace `cargo test`: they validate configuration, SQL migrations, Docker examples, query-catalog parity and release metadata in environments where the Rust toolchain or external services are absent. Release version and migration versions are discovered from the repository instead of being duplicated here, so adding V052/V053 does not require editing this validator. """ from __future__ import annotations import base64 import pathlib import re import sqlite3 import subprocess import sys import tomllib try: import yaml except ImportError: # pragma: no cover - validation host dependency yaml = None ROOT = pathlib.Path(__file__).resolve().parents[1] errors: list[str] = [] checks: list[str] = [] def ok(message: str) -> None: checks.append(message) def require(condition: bool, message: str) -> None: if condition: ok(message) else: errors.append(message) def read(rel: str) -> str: return (ROOT / rel).read_text(encoding="utf-8") def extract_section(text: str, begin: str, end: str) -> str: require(begin in text and end in text, f"schema markers present: {begin} / {end}") if begin not in text or end not in text: return "" return text.split(begin, 1)[1].split(end, 1)[0] MIGRATION_DIALECTS = (("SQLITE", "sqlite"), ("MYSQL", "mysql"), ("PGSQL", "pgsql")) MIGRATION_FILE_RE = re.compile(r"^V(?P\d+)__(?P[A-Za-z0-9_]+)\.sql$") def migration_label(version: int) -> str: return f"V{version:03d}" def parse_base_schema_version(pool_text: str) -> int: match = re.search( r"pub\s+const\s+BASE_SCHEMA_VERSION\s*:\s*u32\s*=\s*(\d+)\s*;", pool_text, ) require(match is not None, "runtime declares BASE_SCHEMA_VERSION") return int(match.group(1)) if match else 0 def discover_migrations(dialect: str) -> list[tuple[int, str]]: migration_dir = ROOT / "migrations" / dialect found: list[tuple[int, str]] = [] for path in sorted(migration_dir.glob("V*.sql")): match = MIGRATION_FILE_RE.fullmatch(path.name) require(match is not None, f"migration filename is valid: {dialect}/{path.name}") if match is None: continue found.append((int(match.group("version")), path.name)) versions = [version for version, _ in found] require(len(versions) == len(set(versions)), f"migration versions are unique for {dialect}") return sorted(found, key=lambda item: item[0]) def migration_containing(dialect: str, *needles: str) -> tuple[int, str] | None: matches: list[tuple[int, str]] = [] for version, filename in MIGRATIONS[dialect]: text = read(f"migrations/{dialect}/{filename}") if all(needle in text for needle in needles): matches.append((version, filename)) return matches[-1] if matches else None # Repository-derived release and migration metadata. pool = read("src/db/pool.rs") BASE_SCHEMA_VERSION = parse_base_schema_version(pool) MIGRATIONS = {dialect: discover_migrations(dialect) for _, dialect in MIGRATION_DIALECTS} ALL_MIGRATION_VERSIONS = sorted({version for items in MIGRATIONS.values() for version, _ in items}) LATEST_SCHEMA_VERSION = max([BASE_SCHEMA_VERSION, *ALL_MIGRATION_VERSIONS]) NEXT_SCHEMA_VERSION = LATEST_SCHEMA_VERSION + 1 # Release/toolchain metadata. cargo = tomllib.loads(read("Cargo.toml")) PROJECT_VERSION = cargo["package"]["version"] RUST_VERSION = cargo["package"]["rust-version"] require(bool(re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", PROJECT_VERSION)), f"Cargo package version is valid: {PROJECT_VERSION}") require(bool(RUST_VERSION), f"Cargo rust-version is declared: {RUST_VERSION}") toolchain = tomllib.loads(read("rust-toolchain.toml")) require(toolchain["toolchain"]["channel"] == RUST_VERSION, f"rust-toolchain pins Cargo rust-version {RUST_VERSION}") require((ROOT / "Cargo.lock").is_file(), "Cargo.lock is retained for reproducible application builds") lock = tomllib.loads(read("Cargo.lock")) lock_rustbox = next((pkg for pkg in lock.get("package", []) if pkg.get("name") == "rustbox"), {}) require(lock_rustbox.get("version") == PROJECT_VERSION, f"Cargo.lock RustBox package version matches {PROJECT_VERSION}") require(cargo["dependencies"].get("encoding_rs") == "0.8", "direct charset decoder dependency is pinned") require("encoding_rs" in lock_rustbox.get("dependencies", []), "Cargo.lock records direct charset decoder dependency") require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script generates Cargo.lock when missing") release_script = read("scripts/build-release.sh") release_script_flat = " ".join(release_script.replace("\\", " ").split()) require("cargo test --locked" in release_script_flat, "release script requires the full Rust test suite") require("cargo clippy --locked --all-targets -- -D warnings" in release_script_flat, "release script requires warning-free Clippy") require("cargo build --release --locked" in release_script_flat, "release script requires a locked release build") package_script = read("scripts/package-release.sh") require("./scripts/build-release.sh" in package_script, "release packaging runs the full build gate before creating artifacts") require("make_zip.py" in package_script and "sha256sum" in package_script, "release packaging creates ZIP and SHA256 only after the build gate") require("cargo generate-lockfile" in read(".github/workflows/ci.yml"), "CI regenerates Cargo.lock before --locked checks") require("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate") require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate") require(read("README.md").startswith(f"# RustBox {PROJECT_VERSION}"), f"README identifies RustBox {PROJECT_VERSION}") require(read("RELEASE-MANIFEST.txt").startswith(f"RustBox {PROJECT_VERSION}"), f"release manifest identifies RustBox {PROJECT_VERSION}") require(read("RELEASE-VALIDATION.txt").startswith(f"RustBox {PROJECT_VERSION}"), f"release validation identifies RustBox {PROJECT_VERSION}") require(read("docs/IMPLEMENTATION_STATUS.md").startswith(f"# RustBox {PROJECT_VERSION}"), f"implementation status identifies RustBox {PROJECT_VERSION}") bootstrap_js = read("src/web/js/00-bootstrap.js") bindings_js = read("src/web/js/95-bindings-boot.js") realtime_js = read("src/web/js/80-settings-realtime.js") mail_actions_js = read("src/web/js/60-mail-actions-search.js") auth_js = read("src/web/js/20-preferences-auth.js") reader_js = read("src/web/js/40-messages-reader.js") compose_js = read("src/web/js/50-compose.js") compose_css = read("src/web/css/40-compose.css") mail_css = read("src/web/css/30-mail.css") require("perPage: 300" in bootstrap_js and "messages_per_page: 300" in bootstrap_js and 'message_loading_mode: "pagination"' in bootstrap_js, "browser mailbox defaults to 300-message pagination") require(re.search(r"default_per_page\(\)\s*->\s*u32\s*\{\s*300\s*\}", read("src/routes/messages/types.rs")) is not None, "folder message API default page size is 300") require('1..=300' in read("src/routes/messages/mod.rs"), "folder message API accepts up to 300 messages per page") require('20..=300' in read("src/db/mail_preferences.rs") and '"pagination" | "progressive" | "all"' in read("src/db/mail_preferences.rs"), "mailbox preferences accept 300-message pages and the three loading modes") require('const appShortcut = keyboardIdle && !event.ctrlKey && !event.metaKey && !event.altKey;' in bindings_js, "single-key shortcuts preserve Ctrl/Cmd/Alt browser shortcuts") require('realtimeNotificationsReady' in realtime_js and 'suppressRealtimeNotificationsForFolder' in realtime_js, "realtime new-mail notification gating is present") require('id="account-switcher"' in read("src/web/index.html") and 'renderAccountSwitcher' in auth_js and 'state.accounts.length > 1' in auth_js, "multi-account quick switcher appears only for multiple signed-in accounts") require('id="account-notification-list"' in read("src/web/index.html") and 'notificationAccountEnabled' in auth_js and 'notificationPrefsByAccount' in bootstrap_js and 'loadCombinedNotificationPreferences' in realtime_js, "browser session can select multiple accounts for combined notifications") require('realtimeFolderIsDrafts' in realtime_js and 'if (realtimeFolderIsDrafts(data?.folder)) return true;' in realtime_js, "realtime new-mail notifications are suppressed for Drafts folders") require('const draftsFolder = syncImap' in compose_js and 'suppressRealtimeNotificationsForFolder(draftsFolder)' in compose_js and 'releaseRealtimeSuppression();' in compose_js, "manual IMAP draft save suppresses its own realtime new-mail notification") require('suppressRealtimeNotificationsForFolder(notificationDestination)' in mail_actions_js, "bulk destination notifications are suppressed") require('realtimeNotificationSuppressedMessages: new Map()' in bootstrap_js and 'suppressRealtimeNotificationForMessage' in realtime_js and 'consumeRealtimeMessageNotificationSuppression' in realtime_js and 'suppressIntentionalMoveNotification' in mail_actions_js, "intentional message moves suppress the matching realtime new-mail event by destination UID") require('finishAuthenticatedStartup({ showLoginToast = false } = {})' in auth_js and 'finishAuthenticatedStartup({ showLoginToast: true })' in auth_js and bindings_js.count('finishAuthenticatedStartup();') >= 1, "login success toast is limited to real login, not refresh/session restoration") require('${csp}' in reader_js, "message preview srcdoc explicitly declares UTF-8") require('function resizeReaderMessageFrame()' in reader_js and 'new ResizeObserver(scheduleReaderMessageFrameResize)' in reader_js and '--reader-message-frame-height' in mail_css and 'min-height: 68vh' not in mail_css, "HTML message preview uses the full remaining reader height instead of fixed 68vh") require('function sanitizeMessagePreviewHtml(html)' in bootstrap_js and 'if (!node.hasAttribute("target")) node.setAttribute("target", "_blank");' in bootstrap_js and '!["_self", "_parent", "_top"].includes(target)' in bootstrap_js and 'rel.add("noopener");' in bootstrap_js and 'rel.add("noreferrer");' in bootstrap_js and 'sanitizeMessagePreviewHtml(detail.html)' in reader_js, "message preview links default to safe new-window navigation without overriding explicit targets") require('sourceDoc.head.querySelectorAll("style")' in bootstrap_js and 'messagePreviewBodyPresentation' in reader_js and 'iframe.style.background = previewTheme.surface;' in reader_js and 'background: var(--reader-bg);' in mail_css, "adaptive HTML-mail preview preserves head CSS/body presentation and never exposes a hard-coded white iframe canvas") require('attachmentContentUrl(folder, uid, attachment)' in reader_js and 'frame.src = attachmentContentUrl(folder, uid, attachment);' in reader_js and 'URL.createObjectURL(blob)' in reader_js, "PDF preview uses authenticated inline URL while other attachment previews retain blob handling") require('function composeAttachmentInsertionRange' in compose_js and 'target.collapse(false);' in compose_js and 'range.insertNode(inserted);' in compose_js and 'document.execCommand("insertHTML"' not in compose_js, "compose attachment embedding uses non-destructive DOM Range insertion") require('position: fixed;' in compose_css and '.compose-image-actions' in compose_css and 'grid-template-rows: auto auto auto minmax(240px, 1fr) auto auto auto auto;' in compose_css, "inline image actions float without consuming a composer grid row") require('function beginInlineImageResize' in compose_js and 'data-image-resize="nw"' in read("src/web/index.html") and '.compose-image-resize-handle' in compose_css, "selected inline images expose pointer resize handles") require('application/x-rustbox-inline-image' in bindings_js and 'function moveInlineImageToRange' in compose_js, "inline images can be repositioned by drag and drop") require('clipboardData' in bindings_js and 'uploadInlineImage(images, range)' in bindings_js, "clipboard images are uploaded and embedded inline") require('.compose-attachment-kind' in compose_css and 'Image attachment' in compose_js and 'Inline image' in compose_js, "composer distinguishes embedded images from regular attachments") require("function mergeComposeAttachments" in compose_js and "await refreshComposeAttachmentsFromServer(id);" in compose_js and "attachments: mergeComposeAttachments(local.attachments, server.attachments)" in compose_js, "composer preserves multiple attachments and draft recovery merges authoritative server attachment metadata") require("const attachments = mergeComposeAttachments(state.composeAttachments);" in compose_js and "Inline link" in compose_js and "detachComposeAttachmentFromBody" in compose_js, "composer attachment strip keeps inline and regular attachments visible together") require('if (state.composeUploading) {' in compose_js and 'scheduleUnusedInlineAttachmentCleanup();' in compose_js and 'staleSignatureIds' in compose_js, "signature CID cleanup waits for in-flight uploads before deleting apparently unreferenced signature images") require('function preferredComposeIdentity()' in read("src/web/js/72-openpgp-identities.js") and 'activeAccountEmail().toLowerCase()' in read("src/web/js/72-openpgp-identities.js") and 'if (preferred) select.value = String(preferred.id);' in read("src/web/js/72-openpgp-identities.js") and 'return state.identities.find((identity) => identity.id === id) || preferredComposeIdentity();' in read("src/web/js/72-openpgp-identities.js"), "composer resolves a concrete active/default identity before inserting signatures") require('const hasAccountIdentity = Boolean(normalizedAccountEmail)' in read("src/web/js/72-openpgp-identities.js") and 'if (!hasAccountIdentity) {' in read("src/web/js/72-openpgp-identities.js"), "composer does not duplicate the active account address when a matching identity exists") require('const command = key === "z" ? (event.shiftKey ? "redo" : "undo")' in bindings_js and 'document.execCommand(command, false, null);' in bindings_js, "composer supports Ctrl/Cmd+Z undo and redo shortcuts") require('.message-pagination {' in mail_css and 'grid-template-columns: 30px 30px auto 30px 30px;' in mail_css and '.pagination-arrow {' in mail_css and '.load-more {' in mail_css and 'width: 100%;' in mail_css.split('.load-more {', 1)[1].split('}', 1)[0], "message pagination uses compact first/previous/next/last controls while progressive-load controls span the message list width") require('id="compose-fields-toggle"' in read("src/web/index.html") and 'function setComposeFieldsCollapsed' in compose_js and 'function toggleComposeFields' in compose_js and '.compose-dialog.compose-fields-collapsed .compose-addresses' in compose_css and 'setComposeFieldsCollapsed(false)' in bindings_js, "composer addressing fields can collapse to maximize editor space and reopen on invalid recipient input") require('id="compose-save-draft"' in read("src/web/index.html") and '$("#compose-save-draft").addEventListener("click", saveComposeDraftToImap);' in bindings_js and 'saveCurrentDraft(false, true, true)' in compose_js, "composer exposes an explicit manual IMAP draft save before cancel") require('rememberSignatureRange' in bindings_js and 'selectedSignatureRange' in bindings_js and 'if (!range || range.collapsed) return null;' in bindings_js and 'state.identitySignatureRange = null;' in bindings_js and '.signature-editor-toolbar button' in bindings_js and 'event.preventDefault();' in bindings_js and 'restoreEditorRange(range, signatureEditor);' in bindings_js, "signature formatting preserves real selections and refuses collapsed-caret commands that would enable sticky typing styles") require('id="identity-signature-html-dialog"' in read("src/web/index.html") and 'id="identity-signature-html-source"' in read("src/web/index.html") and 'id="identity-signature-html-preview-editor"' in read("src/web/index.html") and 'openIdentitySignatureHtmlEditor' in read("src/web/js/72-openpgp-identities.js") and 'insertIdentitySignatureHtmlImage' in read("src/web/js/72-openpgp-identities.js"), "signature management exposes sanitized raw-HTML import with visual editing and image insertion") require('.identity-signature-grid textarea,' in read("src/web/css/50-settings.css") and 'height: 240px;' in read("src/web/css/50-settings.css") and '.identity-signature-grid .signature-editor-shell {' in read("src/web/css/50-settings.css"), "plain-text and HTML signature controls use the same fixed editor height") require('messageLoadPending: 0' in bootstrap_js and 'function beginMessageLoadActivity()' in realtime_js and 'function endMessageLoadActivity()' in realtime_js and reader_js.count('beginMessageLoadActivity();') >= 3 and reader_js.count('endMessageLoadActivity();') >= 3, "mail loading spinner covers list pagination/progressive/all loading and individual message retrieval") require('function selectMessageRange(message)' in read("src/web/js/30-folders-tags.js") and 'if (event.shiftKey)' in reader_js and 'setMessageSelectionAnchor(message);' in reader_js, "Shift-click selects a contiguous visible-message range from the last selection anchor") require('id="page-first"' in read("src/web/index.html") and 'id="page-last"' in read("src/web/index.html") and 'function goToMailboxPage(targetPage)' in reader_js and 'goToMailboxPage(mailboxPageCount() - 1)' in bindings_js, "mailbox pagination can jump directly to the first and last page") index_html = read("src/web/index.html") require('
HTML signature / footer' in index_html and 'aria-labelledby="identity-signature-label"' in index_html and '