1103 lines
100 KiB
Python
Executable File
1103 lines
100 KiB
Python
Executable File
#!/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<version>\d+)__(?P<name>[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('<meta charset="utf-8">${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('<div class="identity-signature-field"><span id="identity-signature-label">HTML signature / footer</span>' in index_html and 'aria-labelledby="identity-signature-label"' in index_html and '<label class="identity-signature-field">' not in index_html, "HTML signature editor is not wrapped in a label that can implicitly activate the Bold toolbar button")
|
|
require('function ensureComposeEntryBeforeSignature' in read("src/web/js/72-openpgp-identities.js") and 'data-rustbox-signature-entry' in read("src/web/js/72-openpgp-identities.js") and 'compose-body-entry' in compose_css, "new visible signatures keep an editable compose body entry above the signature")
|
|
require('.compose-signature.compose-signature-new' in compose_css and 'margin-top: 2.4em;' in compose_css and 'border-top: 1px solid color-mix(in srgb, var(--line) 58%, transparent);' in compose_css and 'removeAttribute("data-rustbox-signature-entry")' in compose_js, "new-message signature spacing is scoped, the separator is subdued, and editor-only scaffolding is stripped from outgoing HTML")
|
|
require('data-panel="appearance"' in read("src/web/index.html") and 'id="pref-compact-wide-rows"' in read("src/web/index.html") and 'id="mail-pref-compact-wide-rows"' not in read("src/web/index.html") and 'saveCompactWideRowsAppearance' in auth_js and 'compact_wide_rows: true' in bootstrap_js and 'data-compact-wide-rows="true"' not in read("src/web/index.html") and '@container message-pane (min-width: 520px)' in mail_css, "wide message-row compaction is an Appearance preference, persisted, default-on, and width-gated")
|
|
display_prefs_rs = read("src/db/display_preferences.rs")
|
|
index_html = read("src/web/index.html")
|
|
require('bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760)' in bindings_js and 'value.clamp(300, 760).to_db_value()' in display_prefs_rs and 'id="pref-message-width" type="range"\n min="300" max="760"' in index_html, "message pane resize limits are aligned at 300..760 px and normalized server-side")
|
|
require('bindPaneResizer("#folder-resizer", "folderPaneWidth", 180, 420)' in bindings_js and 'value.clamp(180, 420).to_db_value()' in display_prefs_rs and 'id="pref-folder-width" type="range"\n min="180" max="420"' in index_html, "folder pane resize limits are aligned at 180..420 px and normalized server-side")
|
|
require('suppressPaneWidthValidationError' in auth_js and 'temporarilyDisablePanePreference' in auth_js and 'data.temporarilyDisabled' not in auth_js and 'handle.dataset.temporarilyDisabled = "true"' in auth_js and 'if (!suppressPaneWidthValidationError(error)) toast(error.message, "error")' in auth_js, "pane-width validation incompatibilities disable the in-view resizer without a user-facing error")
|
|
require('id="identity-signature-mode"' in read("src/web/index.html") and 'value="silent"' in read("src/web/index.html") and 'value="visible"' in read("src/web/index.html") and 'signature_insert_mode' in read("src/web/js/72-openpgp-identities.js"), "identity UI exposes silent and visible signature insertion modes")
|
|
require('id="pref-adaptive-email-colors"' in read("src/web/index.html") and 'adaptive_email_colors' in display_prefs_rs and 'adaptiveEmailColors' in auth_js and 'adaptMessagePreviewColors' in reader_js, "Appearance exposes persisted adaptive HTML-mail colors and the reader applies them")
|
|
require('id="mail-pref-loading-mode"' in index_html and 'id="message-pagination"' in index_html and 'function changeMailboxPage(delta)' in reader_js and 'function loadNextProgressivePage()' in reader_js and 'function loadAllMessages()' in reader_js and 'handleMessageListScroll' in bindings_js, "Mailbox settings expose pagination, progressive loading and load-all behavior")
|
|
require('identity.signature_insert_mode != "visible"' in read("src/routes/send.rs") and 'data-rustbox-signature' in read("src/web/js/72-openpgp-identities.js"), "silent signatures append server-side while visible signatures remain composer-owned and de-duplicatable")
|
|
require('id="compose-delete-draft"' in read("src/web/index.html") and '$("#compose-delete-draft").addEventListener("click", deleteComposeDraft);' in bindings_js and 'function deleteComposeDraft()' in compose_js and 'imap_uid_validity' in compose_js, "composer exposes permanent draft deletion with current IMAP coordinates")
|
|
require('if req.sync_imap {' in read("src/routes/drafts.rs") and 'Failed to save draft to IMAP' in read("src/routes/drafts.rs") and 'AppError::ServiceUnavailable' in read("src/routes/drafts.rs"), "manual IMAP draft save fails visibly when the IMAP snapshot cannot be appended")
|
|
drafts_rs = read("src/routes/drafts.rs")
|
|
require('.keep_bcc()' in drafts_rs and 'if valid_recipients == 0 {' in drafts_rs and 'builder = builder.envelope(envelope);' in drafts_rs, "IMAP draft snapshots allow recipient-less drafts and preserve Bcc headers")
|
|
require('db::special_folders::get_role(conn, "drafts")?' in drafts_rs and 'default_drafts_folder' in drafts_rs and 'find_folder_by_attribute(conn, "\\\\Drafts")?' in drafts_rs, "manual IMAP draft save honors configured and detected Drafts folders")
|
|
require('for common_name in ["Drafts", "Draft"]' in drafts_rs, "manual IMAP draft save recognizes common Draft and Drafts folder names")
|
|
require('get_imap_draft_handler' in drafts_rs and '/messages/{folder}/{uid}/draft-compose' in read("src/routes/mod.rs") and 'fetch_body(&creds, &folder, uid)' in drafts_rs, "saved IMAP drafts reopen from RFC822/MIME without a local draft-row dependency")
|
|
require('delete_imap_draft_source' in drafts_rs and 'Incomplete IMAP draft deletion coordinates' in drafts_rs and 'let mut attachments_deleted = 0usize;' in drafts_rs, "draft deletion verifies the IMAP source and cascades local attachment cleanup")
|
|
require('messageLooksLikeImapDraft' in reader_js and 'await openImapDraftComposer(folder, uid);' in reader_js and 'state.composeRemoteDraft = draft.imap_source' in compose_js, "IMAP Drafts messages open directly in the normal composer")
|
|
require('imap_source' in compose_js and 'imap_attachments' in compose_js and 'load_imap_source_attachments' in drafts_rs and 'remove_imap_source_snapshot' in drafts_rs, "IMAP draft edits preserve remote MIME attachments and replace the source snapshot safely")
|
|
require('Attachment::new_inline_with_name' in drafts_rs and 'Attachment::new_inline_with_name' in read("src/smtp/builder.rs"), "inline MIME parts preserve attachment filenames in saved drafts and sent messages")
|
|
require('mail_parser::MessageParser::default().parse(synthetic.as_bytes())' in read("src/imap/parse.rs") and 'decode_header_bytes' in read("src/imap/parse.rs"), "RFC 2047 header decoding uses mail-parser and tolerant envelope byte handling")
|
|
require('encoding_rs::Encoding::for_label' in read("src/imap/parse.rs") and 'decodes_polish_rfc2047_subjects' in read("src/imap/parse.rs"), "Polish UTF-8/ISO-8859-2/Windows-1250 header decoding is covered")
|
|
require('let is_pdf = response_content_type_is(&response, "application/pdf");' in read("src/routes/mod.rs") and 'HeaderValue::from_static("SAMEORIGIN")' in read("src/routes/mod.rs") and "HeaderValue::from_static(\"frame-ancestors 'self'\")" in read("src/routes/mod.rs"), "security headers allow same-origin framing for inline PDF previews only")
|
|
index_html = read("src/web/index.html")
|
|
require('class="compose-limit-summary"' in index_html and 'Per-file upload limit set with' not in index_html and 'Hard limit for the complete RFC822/MIME message' not in index_html, "Composing shows compact raw size limits without implementation details")
|
|
for rel, label in (("RELEASE-MANIFEST.txt", "release manifest"), ("migrations/README.md", "migration guide")):
|
|
migration_doc = read(rel)
|
|
require(
|
|
migration_label(BASE_SCHEMA_VERSION) in migration_doc
|
|
and migration_label(LATEST_SCHEMA_VERSION) in migration_doc
|
|
and migration_label(NEXT_SCHEMA_VERSION) in migration_doc,
|
|
f"{label} documents baseline {migration_label(BASE_SCHEMA_VERSION)}, current {migration_label(LATEST_SCHEMA_VERSION)} and next {migration_label(NEXT_SCHEMA_VERSION)}",
|
|
)
|
|
roundcube_script = read("scripts/migrate_roundcube.py")
|
|
roundcube_fallback = re.search(r"versions\s*=\s*\[\s*(\d+)\s*\]", roundcube_script)
|
|
require(
|
|
roundcube_fallback is not None and int(roundcube_fallback.group(1)) == BASE_SCHEMA_VERSION,
|
|
f"Roundcube provisioning fallback follows the {migration_label(BASE_SCHEMA_VERSION)} consolidated baseline",
|
|
)
|
|
require((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists")
|
|
require('imap_host = ""' not in read("config.dev.toml"), "development config does not override IMAP host with an invalid empty value")
|
|
require(not re.search(r"(?m)^IMAP_HOST=\s*$", read(".env.example")), "environment example does not set an invalid empty IMAP host")
|
|
dev_sh = read("dev.sh")
|
|
require('ENV_FILE="${RUSTBOX_ENV_FILE:-$ROOT/.env}"' in dev_sh and '. "$ENV_FILE"' in dev_sh, "development launcher loads project .env")
|
|
require(dev_sh.find('. "$ENV_FILE"') < dev_sh.find('cargo run --quiet --bin rustbox -- --check-config'), "development launcher loads .env before config validation")
|
|
require('warn,rustbox=debug,tower_http=info' in dev_sh, "development launcher keeps dependencies at WARN while RustBox stays DEBUG")
|
|
require('case "${RUST_LOG:-}" in' in dev_sh and 'trace)' in dev_sh and 'info)' in dev_sh, "development launcher scopes legacy/simple global RUST_LOG levels")
|
|
require('RUST_LOG=warn,rustbox=debug,tower_http=info' in read(".env.example"), "development environment example does not enable noisy dependency INFO logs")
|
|
main_rs = read("src/main.rs")
|
|
require('"debug" => "warn,rustbox=debug,tower_http=info"' in main_rs and 'EnvFilter::new(default_log_filter(log_level))' in main_rs, "config log_level controls RustBox verbosity without globally enabling dependency DEBUG/INFO")
|
|
require("pub struct SessionPolicyResponse" in read("src/routes/auth.rs") and "pgp_enabled: bool" in read("src/routes/auth.rs"), "authenticated session policy exposes OpenPGP availability")
|
|
require("setOpenPgpAvailability" in read("src/web/js/72-openpgp-identities.js"), "UI hides administratively disabled OpenPGP")
|
|
storage_rs = read("src/storage/mod.rs")
|
|
require(
|
|
".with_allow_http(storage.allow_http)" in storage_rs.split("let client_options = ClientOptions::new()", 1)[1].split("let retry =", 1)[0],
|
|
"S3 allow_http is applied to the final ClientOptions",
|
|
)
|
|
require(
|
|
".with_allow_http(storage.allow_http)" not in storage_rs.split("let mut builder = AmazonS3Builder::from_env()", 1)[1].split("if let Some(endpoint)", 1)[0],
|
|
"S3 builder does not overwrite allow_http with replacement client options",
|
|
)
|
|
require(
|
|
"s3_http_endpoint_works_when_explicitly_allowed" in storage_rs,
|
|
"S3 HTTP regression test is present",
|
|
)
|
|
config_rs = read("src/config.rs")
|
|
routes_mod = read("src/routes/mod.rs")
|
|
require("pub sieve_enabled: bool" in config_rs and "pub diagnostics_enabled: bool" in config_rs and "pub about_enabled: bool" in config_rs, "configuration exposes Sieve, diagnostics and About feature gates")
|
|
require("pub multi_account_enabled: bool" in config_rs and "multi_account_enabled: true" in config_rs and "MULTI_ACCOUNT_DISABLED" in read("src/routes/auth.rs"), "administrator can disable multi-account browser sessions and second-account login is rejected")
|
|
require("!config.multi_account_enabled" in read("src/realtime/ws.rs") and "account_id" in read("src/realtime/ws.rs") and "?account_id=" in realtime_js, "disabled multi-account mode limits realtime monitoring to the active account")
|
|
require("pub security_headers_enabled: bool" in config_rs and "security_headers_enabled: false" in config_rs, "security headers are configurable and disabled by default")
|
|
require("pub struct SyncConfig" in config_rs and "pub interval_secs: u64" in config_rs and "pub deep_index_batch_size: u32" in config_rs, "IMAP sync cadence and deep-index batch size are configurable")
|
|
require("pub deep_index: bool" in config_rs and "deep_index: false" in config_rs, "server-wide automatic deep index is configurable and production-safe by default")
|
|
system_diag_rs = read("src/routes/system_diagnostics.rs")
|
|
session_rs = read("src/auth/session.rs")
|
|
session_persistence_rs = read("src/session_store/mod.rs")
|
|
require("pub struct DiagnosticsEndpointConfig" in config_rs and "pub diagnostics_endpoint: DiagnosticsEndpointConfig" in config_rs, "administrator diagnostics endpoint has a dedicated configuration block")
|
|
require('default_diagnostics_allowed_networks' in config_rs and '"127.0.0.1/32"' in config_rs and '"::1/128"' in config_rs, "administrator diagnostics allowlist defaults to IPv4/IPv6 loopback")
|
|
require('diagnostics_endpoint.path must be an absolute /api/system/' in config_rs and 'diagnostics_endpoint.token must contain at least 24 characters' in config_rs and 'diagnostics_endpoint.basic_password must contain at least 16 characters' in config_rs, "administrator diagnostics validates namespace and authentication strength")
|
|
require('if config.diagnostics_endpoint.enabled' in routes_mod and 'get(system_diagnostics::system_diagnostics)' in routes_mod, "administrator diagnostics route is registered only when enabled")
|
|
require('diagnostics_client_ip' in system_diag_rs and 'proxy headers received from an untrusted peer' in system_diag_rs, "administrator diagnostics fails closed on untrusted reverse-proxy headers")
|
|
require('parsed_diagnostics_allowed_networks' in system_diag_rs and 'return forbidden();' in system_diag_rs, "administrator diagnostics enforces source IP/CIDR allowlist before probes")
|
|
require('constant_time_secret_eq' in system_diag_rs and 'basic_credentials' in system_diag_rs and 'bearer_token' in system_diag_rs, "administrator diagnostics supports protected token and Basic authentication")
|
|
require('HeaderValue::from_static("no-store")' in system_diag_rs and 'harden_diagnostics_response' in system_diag_rs, "administrator diagnostics responses are explicitly non-cacheable")
|
|
require('"database": db_diag' in system_diag_rs and '"storage": {' in system_diag_rs and '"search": search_status' in system_diag_rs and '"sessions": {' in system_diag_rs, "administrator diagnostics reports database, storage, search and session state")
|
|
require('bruteForceSignal' in system_diag_rs and 'diagnosticsAttackSignal' in system_diag_rs and 'MAX_SECURITY_EVENTS' in system_diag_rs, "administrator diagnostics includes bounded brute-force/access-denial telemetry")
|
|
require('pub struct SessionPersistenceDiagnostics' in session_persistence_rs and 'diagnostics_snapshot' in session_rs, "administrator diagnostics reports local and persisted session state")
|
|
require('user_hash: Option<String>' in session_rs and 'include_user_identifiers.then' in session_rs, "administrator diagnostics omits stable user identifiers unless explicitly enabled")
|
|
require('schema_aligned: Option<bool>' in read("src/db/pool.rs") and 'expected_schema_version' in read("src/db/pool.rs"), "administrator diagnostics reports database schema alignment")
|
|
require('connected_clients' in read("src/session_store/redis_store.rs") and 'used_memory_bytes' in read("src/session_store/redis_store.rs") and 'master_link_status' in read("src/session_store/redis_store.rs"), "administrator diagnostics reports Redis runtime INFO without credentials")
|
|
require('"idleConnections"' in system_diag_rs and 'diagnostics_snapshot' in read("src/realtime/idle.rs"), "administrator diagnostics reports active IMAP IDLE connections")
|
|
require('cluster_status.eq_ignore_ascii_case("green")' in read("src/search/engine.rs") and 'cluster_status.eq_ignore_ascii_case("red")' in read("src/search/engine.rs"), "administrator diagnostics treats OpenSearch health conservatively")
|
|
require('middleware::from_fn(system_diagnostics::observe_requests)' in routes_mod and routes_mod.count('if config.diagnostics_endpoint.enabled') >= 2, "administrator diagnostics telemetry middleware is installed only when the endpoint is enabled")
|
|
require('sanitize_url' in system_diag_rs and 'secretKeyConfigured' in system_diag_rs and 'passwordConfigured' in system_diag_rs and 'credentialConfigured' in system_diag_rs, "administrator diagnostics redacts configured credentials and DSN userinfo")
|
|
require('/proc/' not in system_diag_rs and 'systemMemoryTotalKiB' not in system_diag_rs and 'loadAverage' not in system_diag_rs and 'availableProcessors' not in system_diag_rs, "administrator diagnostics avoids host-specific /proc and machine-wide resource metrics")
|
|
require((ROOT / "docs/SYSTEM_DIAGNOSTICS.md").is_file() and "disabled by default" in read("docs/SYSTEM_DIAGNOSTICS.md") and "HTTP `403`" in read("docs/SYSTEM_DIAGNOSTICS.md"), "administrator diagnostics security and operations are documented")
|
|
require("if config.sieve_enabled" in routes_mod and "if config.diagnostics_enabled" in routes_mod, "disabled Sieve and diagnostics routes are not registered")
|
|
require(".fallback(|| async { axum::http::StatusCode::NOT_FOUND })" in routes_mod, "API namespace has an explicit 404 fallback before the SPA fallback")
|
|
require("if config.security_headers_enabled" in routes_mod and "add_security_headers" in routes_mod, "security-header middleware is opt-in")
|
|
ui_rs = read("src/ui.rs")
|
|
pgp_rs = read("src/routes/pgp.rs")
|
|
contacts_rs = read("src/routes/contacts.rs")
|
|
require("X_CONTENT_TYPE_OPTIONS" not in ui_rs and "x-content-type-options" not in pgp_rs and "x-content-type-options" not in contacts_rs, "security headers are centralized behind the opt-in middleware")
|
|
require("response_content_type_is" in routes_mod, "security-header middleware classifies response content types")
|
|
require("headers.insert(\n headers.insert(" not in routes_mod, "security-header middleware has no duplicated insert fragment")
|
|
require("if is_html || is_css" in routes_mod, "Referrer-Policy is limited to HTML/CSS responses")
|
|
require("if is_html {" in routes_mod, "document-only browser policies are limited to HTML responses")
|
|
require("script-src 'self'; script-src-attr 'none'" in routes_mod, "HTML CSP permits only same-origin external scripts and disables script attributes")
|
|
require("style-src 'self'; style-src-attr 'unsafe-inline'" in routes_mod and "style-src 'self' 'unsafe-inline'" not in routes_mod, "HTML CSP limits unsafe-inline to dynamic style attributes rather than stylesheet blocks")
|
|
require("sha256-" not in routes_mod and "script-src 'self' 'unsafe-inline'" not in routes_mod, "HTML CSP does not rely on inline-script hashes or unsafe-inline")
|
|
require('headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"))' in routes_mod, "API security middleware forces private responses to no-store")
|
|
require(
|
|
"HeaderValue::from_static(\"frame-ancestors 'none'\")" in routes_mod
|
|
and 'headers.insert("x-frame-options", HeaderValue::from_static("DENY"))' in routes_mod,
|
|
"browser API security profile includes explicit frame denial",
|
|
)
|
|
html_security_start = routes_mod.find("if is_html {")
|
|
html_security_end = routes_mod.find("\n response", html_security_start)
|
|
require(
|
|
html_security_start >= 0
|
|
and html_security_end > html_security_start
|
|
and routes_mod.count('"permissions-policy"') == 1
|
|
and routes_mod.count('"cross-origin-opener-policy"') == 1
|
|
and html_security_start < routes_mod.find('"permissions-policy"') < html_security_end
|
|
and html_security_start < routes_mod.find('"cross-origin-opener-policy"') < html_security_end,
|
|
"API security profile keeps Permissions-Policy and COOP on documents only",
|
|
)
|
|
require('"cross-origin-resource-policy"' in routes_mod and 'HeaderValue::from_static("same-origin")' in routes_mod, "security middleware applies same-origin CORP where appropriate")
|
|
route_tests = read("src/routes/tests.rs")
|
|
auth_routes = read("src/routes/auth.rs")
|
|
auth_js = read("src/web/js/20-preferences-auth.js")
|
|
require("security_headers_are_opt_in_and_content_type_aware" in route_tests, "security-header regression test covers content-type-aware behavior")
|
|
require("pdf_api_responses_allow_same_origin_preview_frames" in route_tests, "PDF preview framing has a same-origin security regression test")
|
|
require("auth_policy_exposes_only_login_requirements_before_authentication" in route_tests, "public auth-policy minimization has a regression test")
|
|
require("pub struct SessionPolicyResponse" in auth_routes and '.route("/session-policy", get(auth::session_policy))' in routes_mod, "feature policy is exposed only behind the authenticated session route")
|
|
require('request("/auth/session-policy"' in auth_js and "async function loadSessionPolicy()" in auth_js, "frontend loads optional feature policy only after authentication")
|
|
require("script-src 'self' 'unsafe-inline'" in route_tests, "security regression test explicitly rejects unsafe-inline script policy")
|
|
security_test_start = route_tests.find("async fn security_headers_are_opt_in_and_content_type_aware()")
|
|
security_test_end = route_tests.find("\n#[tokio::test]", security_test_start + 1)
|
|
security_test = route_tests[security_test_start:security_test_end]
|
|
require(
|
|
'api.headers()["content-security-policy"]' in security_test
|
|
and "frame-ancestors 'none'" in security_test
|
|
and 'api.headers()["x-frame-options"]' in security_test
|
|
and '"DENY"' in security_test,
|
|
"API regression test enforces browser-facing anti-framing headers",
|
|
)
|
|
require(
|
|
'api.headers().get("permissions-policy").is_none()' in route_tests
|
|
and 'api.headers().get("cross-origin-opener-policy").is_none()' in route_tests,
|
|
"API regression test omits HTML-only Permissions-Policy and COOP",
|
|
)
|
|
imap_client_rs = read("src/imap/client.rs")
|
|
idle_rs = read("src/realtime/idle.rs")
|
|
realtime_sync_rs = read("src/realtime/sync.rs")
|
|
require("config.sync.interval_secs" in realtime_sync_rs and "SYNC_INTERVAL_SECS" not in realtime_sync_rs, "periodic IMAP sync interval comes from configuration")
|
|
require("config.sync.deep_index_batch_size" in realtime_sync_rs and "DEEP_INDEX_BATCH" not in realtime_sync_rs, "automatic body-cache batch size comes from configuration")
|
|
require("config.search.deep_index" in realtime_sync_rs and "force_deep_index" in realtime_sync_rs, "server-wide deep-index setting participates in effective account behavior")
|
|
messages_route_rs = read("src/routes/messages/mod.rs")
|
|
folders_route_rs = read("src/routes/folders.rs")
|
|
folders_js = read("src/web/js/30-folders-tags.js")
|
|
reader_js = read("src/web/js/40-messages-reader.js")
|
|
realtime_js = read("src/web/js/80-settings-realtime.js")
|
|
require('uid_search("UNSEEN")' in imap_client_rs and "folder_status_extended_on_session" in imap_client_rs, "IMAP folder status has an authoritative UNSEEN search fallback")
|
|
require('uid_search("UNSEEN")' in idle_rs and "server_unseen" in idle_rs, "IDLE reconciliation counts unseen UIDs instead of using SELECT UNSEEN as a count")
|
|
require("tracing::info!(\n tracing::info!(" not in idle_rs, "IDLE handler has no duplicated tracing macro fragment")
|
|
require("set_folder_counts" in realtime_sync_rs and "status.unseen != folder.unread_count" in realtime_sync_rs, "periodic sync repairs server folder counters before fast-path skips")
|
|
require("MailEvent::FolderStatus" in messages_route_rs and "MessageFlagChange" in messages_route_rs, "message flag mutations publish targeted flag and folder-status events")
|
|
folder_counts_route_rs = read("src/routes/folder_counts.rs")
|
|
folders_db_rs = read("src/db/folders.rs")
|
|
require("pub counter_generation: u64" in folders_db_rs and "expected_generation: u64" in folders_db_rs, "folder counter CAS uses a monotonic database generation")
|
|
require("counter_generation_blocks_aba_stale_writer" in folders_db_rs, "folder counter generation has an ABA stale-writer regression test")
|
|
require("set_folder_counts_if_unchanged" in folder_counts_route_rs and "for attempt in 0..2" in folder_counts_route_rs, "post-mutation folder counters use CAS with a fresh STATUS retry")
|
|
require("reconcile_after_mutation" in messages_route_rs and "counter_snapshot" in messages_route_rs, "message mutations reconcile authoritative counters without stale overwrites")
|
|
folder_mgmt_rs = read("src/routes/folder_mgmt.rs")
|
|
require("reconcile_after_mutation" in folder_mgmt_rs, "mark-all-read uses the race-safe counter reconciler")
|
|
require("get_all_uids_and_flags" in folder_mgmt_rs and "through_uid" in folder_mgmt_rs, "mark-all-read snapshots a UID cutoff so later arrivals stay unread locally")
|
|
require("cached_uids" in folder_mgmt_rs and "for uid in &cached_uids" in folder_mgmt_rs, "empty-folder cleanup removes only messages captured before the server mutation")
|
|
require("routes_folder_mgmt::Q_002" not in folder_mgmt_rs, "mark-all-read no longer blindly overwrites unread counters with zero")
|
|
require("set_folder_counts_if_unchanged" in folders_route_rs, "forced folder refresh protects authoritative counters with compare-and-set")
|
|
require("folder_statuses_extended" in folders_route_rs and "pending_sync_count" in folders_route_rs, "folder refresh batches authoritative IMAP status and exposes sync backlog")
|
|
require("folder_refresh_uses_server_counts_without_relisting_fresh_folder_tree" in route_tests, "folder refresh regression test covers stale unread count versus authoritative IMAP STATUS")
|
|
require("if !cache_fresh" in folders_route_rs and "if query.refresh || !cache_fresh" in folders_route_rs, "folder tree LIST uses TTL while forced refresh updates counters through STATUS")
|
|
require("is_noselect" in folders_route_rs and "is_noselect" in realtime_sync_rs, "folder status refresh skips IMAP N\\Noselect hierarchy nodes")
|
|
require("folder-count-wide" in folders_js and "folder-count-compact" in folders_js, "folder UI exposes responsive total/unread counters")
|
|
index_html = read("src/web/index.html")
|
|
require(index_html.find('id="indexing-indicator"') < index_html.find('id="contacts-btn"'), "synchronization/index activity indicator is positioned left of Contacts")
|
|
require("folder-separator" in folders_js and ".folder-count .folder-separator" in read("src/web/css/30-mail.css"), "folder total/unread separator is styled independently from the unread value")
|
|
require("#message-count" in folders_js and "folderName === state.folder" in folders_js, "authoritative folder totals update the active-folder message count without a list rerender")
|
|
require("applyLocalMessageFlags" in reader_js and "updateMessageRowState" in reader_js, "reader flag updates patch individual message rows")
|
|
require("applyOptimisticMessageFlag" in reader_js and "rollback = applyOptimisticMessageFlag" in reader_js, "read/unread actions update rows and counters optimistically with guarded rollback")
|
|
require("if (payload.eventId)" in realtime_js and "const duplicate = state.realtimeSeenEvents.has(idKey);" in realtime_js and realtime_js.find("if (payload.eventId)") < realtime_js.find("const fingerprint ="), "realtime event dedupe trusts unique eventId before fingerprint fallback")
|
|
require("\"eventId\": uuid::Uuid::new_v4().to_string()" in read("src/realtime/ws.rs"), "websocket envelopes carry unique eventId values for lossless client dedupe")
|
|
require("MailEvent::FolderAllRead" in read("src/routes/folder_mgmt.rs") and "applyRealtimeFolderAllRead" in realtime_js, "mark-all-read propagates as a targeted realtime event instead of forcing a list reload")
|
|
require("update_folder_sync_cursor" in read("src/db/folders.rs") and "Q_031" in read("src/queries/sqlite/queries.rs"), "folder flag-sync cursor is stored independently from authoritative counters")
|
|
require('uid_fetch("1:*", "UID FLAGS")' in idle_rs and idle_rs.count("complete.then_some") >= 3, "IDLE fallback reconciles flags and rejects partial IMAP streams")
|
|
require(re.search(r"index_message_with_connection\s*\(\s*&transaction", idle_rs) is not None, "IDLE header ingestion enqueues search indexing in the same database transaction")
|
|
require("delete_folder_messages(&transaction" in messages_route_rs and "db::header_sync::invalidate(&transaction)" in messages_route_rs and re.search(r"update_folder_sync_status\s*\(\s*&transaction", messages_route_rs) is not None, "UIDVALIDITY reset keeps cache invalidation and folder metadata in one database transaction")
|
|
require(
|
|
"db::folders::get_folder(conn, &status_name)" in messages_route_rs
|
|
and "total_count: folder_state.total_count" in messages_route_rs
|
|
and "unread_count: folder_state.unread_count" in messages_route_rs,
|
|
"background sync publishes committed folder counters instead of stale network snapshots",
|
|
)
|
|
require("IMAP cache incomplete" in realtime_js and "Search indexing" in realtime_js, "activity indicator reports IMAP cache and search-index backlog")
|
|
require('indicator.classList.toggle("hidden", !busy)' in realtime_js, "activity indicator is hidden whenever synchronization/indexing is idle")
|
|
require("setIndexingIndicator(true, 0)" in realtime_js and "setIndexingIndicator(false, 0)" in realtime_js, "manual search reindex drives the activity indicator immediately")
|
|
require("MailEvent::IndexingStatus" in read("src/routes/diagnostics.rs") and "Extension(event_bus): Extension<Arc<EventBus>>" in read("src/routes/diagnostics.rs"), "manual search reindex broadcasts indexing activity to connected tabs")
|
|
require("deep_index_enabled" in read("src/routes/diagnostics.rs") and "body cache disabled" in realtime_js, "search diagnostics explain whether automatic body caching is enabled")
|
|
require('.route("/settings/reset", post(settings::reset_all_settings))' in routes_mod, "authenticated settings reset route is registered")
|
|
require("db::settings::reset_all" in read("src/routes/settings.rs"), "settings reset route delegates preference reset to the DB layer")
|
|
require('$("#quick-filter-btn")?.classList.toggle("hidden", state.authPolicy.sieveEnabled === false)' in read("src/web/js/20-preferences-auth.js"), "Sieve quick-filter UI disappears when Sieve is disabled")
|
|
require("state.authPolicy.sieveEnabled === false ? []" in read("src/web/js/60-mail-actions-search.js"), "Sieve context-menu actions disappear when Sieve is disabled")
|
|
mail_actions_js = read("src/web/js/60-mail-actions-search.js")
|
|
require("function nextMessageTargetAfterRemoval(folder, uid)" in mail_actions_js, "reader computes the adjacent message before removing the current item")
|
|
require("state.messages[index + 1] || state.messages[index - 1]" in mail_actions_js, "reader prefers the next message and falls back to the previous item")
|
|
require(mail_actions_js.count("await removeCurrentMessageFromView(") >= 2, "move and permanent-delete flows share automatic reader advancement")
|
|
require("if (nextTarget) await openMessage(nextTarget.folder, nextTarget.uid);" in mail_actions_js, "reader opens the adjacent message after current-message removal")
|
|
require("is_opensearch_backpressure_error" in read("src/search/engine.rs"), "OpenSearch worker classifies backpressure failures")
|
|
require("worker pass failed; backing off" in read("src/search/engine.rs"), "OpenSearch worker backs off after failures")
|
|
require("ENVIRONMENT: ${ENVIRONMENT:-development}" in read("docker/compose-postgres-s3-opensearch.yml"), "PostgreSQL Compose defaults direct HTTP to development cookie mode")
|
|
session_db = read("src/session_store/database.rs")
|
|
db_pool = read("src/db/pool.rs")
|
|
require("crate::db::pool::configure(config).await?" in session_db, "database session store delegates schema initialization to db::pool::configure")
|
|
require("global_schema_for_backend" not in session_db, "database session store does not run a second backend schema pass")
|
|
require("sqlx::raw_sql(&schema)" not in session_db and "execute_batch(&schema)" not in session_db, "session store avoids duplicate schema DDL for SQLite/MySQL/PostgreSQL")
|
|
require("SET LOCAL client_min_messages TO WARNING" in db_pool, "PostgreSQL idempotent startup DDL suppresses expected NOTICE noise locally")
|
|
require("Executor as _" in db_pool, "PostgreSQL startup imports SQLx Executor for direct raw DDL execution")
|
|
require(".execute(schema.as_str())" in db_pool, "PostgreSQL startup executes schema DDL through Executor instead of raw_sql")
|
|
require(not re.search(r"raw_sql\(&schema\)\s*\.execute\(&mut \*tx\)", db_pool), "PostgreSQL startup avoids raw_sql mutable-connection lifetime regression")
|
|
require("sqlite_test_configuration_does_not_pin_deleted_tempdir" in db_pool, "SQLite test isolation has a regression test for deleted TempDir reuse")
|
|
require("let config = AppConfig {" in db_pool and "let mut config = AppConfig::default();" not in db_pool, "SQLite isolation regression test is Clippy-clean with struct initialization")
|
|
sqlite_configure = db_pool.split("DatabaseBackend::Sqlite => {", 1)[1].split("DatabaseBackend::Mysql => {", 1)[0]
|
|
require("#[cfg(test)]" in sqlite_configure and "return Ok(());" in sqlite_configure and "#[cfg(not(test))]" in sqlite_configure, "SQLite test configuration does not publish a temporary database path globally")
|
|
require("RUST_LOG: ${RUST_LOG:-info,sqlx::postgres::notice=warn}" not in read("docker/compose-postgres-s3-opensearch.yml"), "PostgreSQL Compose no longer depends on a notice-filter workaround")
|
|
pgsql_queries = read("src/queries/pgsql/queries.rs")
|
|
require("ON CONFLICT(key) DO UPDATE SET value = CAST(user_meta.value AS INTEGER) + 1" in pgsql_queries, "PostgreSQL header-sync UPSERT qualifies the existing target value")
|
|
require("tenant_rewriter_prefixes_header_sync_target_qualifier" in db_pool, "tenant SQL rewriter has a regression test for qualified header-sync value")
|
|
mysql_queries = read("src/queries/mysql/queries.rs")
|
|
require("ON DUPLICATE KEY UPDATE value = CAST(value AS SIGNED) + 1" in mysql_queries, "MySQL header-sync uses its non-EXCLUDED duplicate-key form")
|
|
|
|
|
|
# PostgreSQL UPSERT audit: any read of an INSERT target column inside
|
|
# ON CONFLICT ... DO UPDATE must be qualified as either excluded.<column>
|
|
# or <target_table>.<column>. PostgreSQL otherwise treats names such as
|
|
# `value` as ambiguous between the existing target row and EXCLUDED.
|
|
def postgres_upsert_ambiguities(source: str) -> list[str]:
|
|
const_re = re.compile(
|
|
r'pub const\s+(\w+)\s*:\s*&str\s*=\s*(r#".*?"#|"(?:\\.|[^"\\])*")\s*;',
|
|
re.S,
|
|
)
|
|
problems: list[str] = []
|
|
for const_name, literal in const_re.findall(source):
|
|
if literal.startswith('r#"'):
|
|
sql = literal[3:-2]
|
|
else:
|
|
sql = bytes(literal[1:-1], 'utf-8').decode('unicode_escape')
|
|
if not re.search(r'ON\s+CONFLICT.*DO\s+UPDATE\s+SET', sql, re.I | re.S):
|
|
continue
|
|
insert = re.search(
|
|
r'INSERT\s+INTO\s+([A-Za-z_][\w]*)\s*\((.*?)\)\s*VALUES',
|
|
sql,
|
|
re.I | re.S,
|
|
)
|
|
if insert is None:
|
|
problems.append(f'{const_name}: unable to parse INSERT target')
|
|
continue
|
|
table = insert.group(1)
|
|
columns = [column.strip().strip('"') for column in insert.group(2).split(',')]
|
|
update = re.split(r'DO\s+UPDATE\s+SET', sql, maxsplit=1, flags=re.I)[1]
|
|
scan = re.sub(r"'(?:''|[^'])*'", '', update)
|
|
for column in columns:
|
|
candidate = re.sub(
|
|
rf'\b(?:excluded|{re.escape(table)})\s*\.\s*{re.escape(column)}\b',
|
|
'',
|
|
scan,
|
|
flags=re.I,
|
|
)
|
|
candidate = re.sub(
|
|
rf'(?:(?<=^)|(?<=,))\s*{re.escape(column)}\s*=',
|
|
'=',
|
|
candidate,
|
|
flags=re.I,
|
|
)
|
|
if re.search(rf'(?<!\.)\b{re.escape(column)}\b', candidate, re.I):
|
|
problems.append(f'{const_name}: unqualified target read `{column}` in {table} UPSERT')
|
|
return problems
|
|
|
|
pgsql_upsert_ambiguities = postgres_upsert_ambiguities(pgsql_queries)
|
|
require(
|
|
not pgsql_upsert_ambiguities,
|
|
'PostgreSQL ON CONFLICT target-column reads are fully qualified'
|
|
+ (f" ({'; '.join(pgsql_upsert_ambiguities)})" if pgsql_upsert_ambiguities else ''),
|
|
)
|
|
|
|
# Parse all maintained TOML examples.
|
|
toml_files = [
|
|
"Cargo.toml",
|
|
"rust-toolchain.toml",
|
|
"config.example.toml",
|
|
"config.dev.toml",
|
|
"packaging/rustbox.toml",
|
|
"docker/configs/single-sqlite.toml",
|
|
"docker/configs/postgres-s3-opensearch.toml",
|
|
"docker/configs/mysql-nfs-opensearch.toml",
|
|
"docker/configs/ha-garage.toml",
|
|
]
|
|
for rel in toml_files:
|
|
try:
|
|
tomllib.loads(read(rel))
|
|
ok(f"TOML parses: {rel}")
|
|
except Exception as exc:
|
|
errors.append(f"TOML parse failed: {rel}: {exc}")
|
|
|
|
# Parse Docker Compose YAML and validate referenced config paths.
|
|
compose_files = sorted((ROOT / "docker").glob("compose-*.yml"))
|
|
require(len(compose_files) >= 4, "four Docker Compose deployment variants exist")
|
|
if yaml is None:
|
|
errors.append("PyYAML is required for Docker Compose static validation")
|
|
else:
|
|
for path in compose_files:
|
|
try:
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
require(isinstance(data, dict) and "services" in data, f"Compose parses with services: {path.name}")
|
|
services = data.get("services", {})
|
|
for service_name, service in services.items():
|
|
if service_name != "rustbox" and not service_name.startswith("rustbox-"):
|
|
continue
|
|
for volume in service.get("volumes", []) or []:
|
|
if isinstance(volume, str) and volume.startswith("./configs/"):
|
|
config_rel = volume.split(":", 1)[0].removeprefix("./")
|
|
require((ROOT / "docker" / config_rel).is_file(), f"Compose config mount exists: {path.name}/{service_name} -> {config_rel}")
|
|
except Exception as exc:
|
|
errors.append(f"Compose parse failed: {path.name}: {exc}")
|
|
|
|
single = tomllib.loads(read("docker/configs/single-sqlite.toml"))
|
|
pg_s3 = tomllib.loads(read("docker/configs/postgres-s3-opensearch.toml"))
|
|
my_nfs = tomllib.loads(read("docker/configs/mysql-nfs-opensearch.toml"))
|
|
require(single["cluster_mode"] is False and single["storage"]["backend"] == "filesystem", "single-node Compose uses local filesystem storage")
|
|
require(pg_s3["cluster_mode"] is True and "data_dir" not in pg_s3, "PostgreSQL/S3 cluster config omits data_dir")
|
|
require(pg_s3["storage"]["backend"] == "s3" and pg_s3["search"]["backend"] == "opensearch", "PostgreSQL cluster uses S3 + OpenSearch")
|
|
require(my_nfs["cluster_mode"] is True and "data_dir" not in my_nfs, "MySQL/NFS cluster config omits data_dir")
|
|
require(my_nfs["storage"]["backend"] == "filesystem" and my_nfs["storage"]["shared"] is True, "MySQL cluster marks POSIX/NFS storage shared")
|
|
require(my_nfs["search"]["backend"] == "opensearch", "MySQL/NFS cluster uses OpenSearch")
|
|
dev_config = tomllib.loads(read("config.dev.toml"))
|
|
prod_config = tomllib.loads(read("config.example.toml"))
|
|
packaged_config = tomllib.loads(read("packaging/rustbox.toml"))
|
|
require(dev_config["search"]["deep_index"] is True, "development config enables automatic full-body cache/index")
|
|
require(prod_config["search"]["deep_index"] is False and packaged_config["search"]["deep_index"] is False, "production configs keep automatic full-body cache/index disabled")
|
|
require(prod_config.get("pgp_enabled") is True and "pgp_enabled" not in prod_config.get("search", {}), "production example keeps root-level settings outside TOML search table")
|
|
require(packaged_config.get("database_backend") == "sqlite" and packaged_config.get("max_attachment_size_mb") == 50 and packaged_config.get("sieve_tls_mode") == "none" and "database_backend" not in packaged_config.get("search", {}), "packaged production config preserves top-level runtime settings outside TOML search table")
|
|
for diagnostics_config in [
|
|
"config.dev.toml",
|
|
"config.example.toml",
|
|
"packaging/rustbox.toml",
|
|
"docker/configs/single-sqlite.toml",
|
|
"docker/configs/postgres-s3-opensearch.toml",
|
|
"docker/configs/ha-garage.toml",
|
|
"docker/configs/mysql-nfs-opensearch.toml",
|
|
]:
|
|
parsed = tomllib.loads(read(diagnostics_config))
|
|
diagnostics = parsed.get("diagnostics_endpoint", {})
|
|
require(
|
|
diagnostics.get("enabled") is False
|
|
and diagnostics.get("allowed_networks") == ["127.0.0.1/32", "::1/128"]
|
|
and diagnostics.get("path") == "/api/system/diagnostics",
|
|
f"diagnostics config is disabled and loopback-only by default: {diagnostics_config}",
|
|
)
|
|
require(dev_config["sync"]["interval_secs"] == 30 and dev_config["sync"]["deep_index_batch_size"] == 10, "development config documents 30s/10-message sync defaults")
|
|
require(prod_config["sync"]["interval_secs"] == 30 and prod_config["sync"]["deep_index_batch_size"] == 10, "production example documents 30s/10-message sync defaults")
|
|
require(packaged_config["sync"]["interval_secs"] == 30 and packaged_config["sync"]["deep_index_batch_size"] == 10, "packaged production config documents 30s/10-message sync defaults")
|
|
ha = tomllib.loads(read("docker/configs/ha-garage.toml"))
|
|
require(ha["cluster_mode"] is True and ha["session_backend"] == "redis", "HA/Garage config enables cluster mode with Redis-compatible sessions")
|
|
require(ha["redis_url"] == "redis://valkey:6379/", "HA/Garage config targets Valkey for shared sessions")
|
|
require(ha["storage"]["backend"] == "s3" and ha["storage"]["endpoint"] == "http://garage:3900", "HA/Garage config stores blobs in Garage S3")
|
|
require(ha["search"]["backend"] == "opensearch", "HA/Garage config uses OpenSearch")
|
|
require(ha["search"]["deep_index"] is True, "development HA example enables automatic deep index")
|
|
require(single["search"]["deep_index"] is False and pg_s3["search"]["deep_index"] is False and my_nfs["search"]["deep_index"] is False, "production Docker configs disable automatic full-body cache/index by default")
|
|
require(all(cfg["sync"]["interval_secs"] > 0 and cfg["sync"]["deep_index_batch_size"] > 0 for cfg in (single, pg_s3, my_nfs, ha)), "Docker configs provide positive IMAP sync/deep-index tuning values")
|
|
dockerfile = read("docker/Dockerfile")
|
|
require("FROM rust:1.99.1-trixie AS builder" in dockerfile, "Docker builder pins Rust 1.99.1 on Debian Trixie")
|
|
require("FROM debian:trixie-slim" in dockerfile, "Docker runtime uses Debian Trixie slim")
|
|
require("libssl3t64" in dockerfile and "libssl3 " not in dockerfile, "Docker runtime uses the Debian Trixie OpenSSL runtime package")
|
|
require("bookworm" not in dockerfile.lower(), "RustBox Dockerfile no longer uses Debian Bookworm")
|
|
require("postgres:17-trixie" in read("docker/compose-postgres-s3-opensearch.yml"), "PostgreSQL/S3 Compose uses PostgreSQL 17 Trixie")
|
|
require("bookworm" not in read("docker/compose-postgres-s3-opensearch.yml").lower(), "PostgreSQL/S3 Compose no longer uses Bookworm")
|
|
ha_compose = read("docker/compose-ha-garage.yml")
|
|
require("postgres:17-trixie" in ha_compose, "HA Compose uses PostgreSQL 17 Trixie")
|
|
require("bookworm" not in ha_compose.lower(), "HA Compose no longer uses Bookworm")
|
|
require("rustbox-1:" in ha_compose and "rustbox-2:" in ha_compose, "HA Compose defines two RustBox application nodes")
|
|
for service in ["postgres:", "valkey:", "opensearch:", "garage:", "envoy:"]:
|
|
require(service in ha_compose, f"HA Compose includes {service.removesuffix(':')}")
|
|
require("valkey/valkey:9.1.3-alpine" in ha_compose, "HA Compose pins Valkey 9.1.3")
|
|
require("opensearchproject/opensearch:3.9.0" in ha_compose, "HA Compose pins OpenSearch 3.9.0")
|
|
require("dxflrs/garage:v2.5.0" in ha_compose, "HA Compose pins Garage 2.5.0")
|
|
require("envoyproxy/envoy:v1.40.2" in ha_compose, "HA Compose pins Envoy 1.40.2")
|
|
require("X-RustBox-Backend" in read("docker/envoy/envoy.yaml"), "Envoy exposes the selected RustBox backend response header")
|
|
require("address: rustbox-1" in read("docker/envoy/envoy.yaml") and "address: rustbox-2" in read("docker/envoy/envoy.yaml"), "Envoy balances both RustBox nodes")
|
|
require('sieve_enabled = false' in read("docker/configs/ha-garage.toml"), "HA example disables optional Sieve by default")
|
|
require('security_headers_enabled = false' in read("docker/configs/ha-garage.toml"), "HA example keeps optional security headers disabled by default")
|
|
require("type: nfs" in read("docker/compose-mysql-nfs-opensearch.yml"), "NFS Compose uses a real NFS volume driver")
|
|
require("docker.io/pgsty/silo:RELEASE.2026-10-26T00-00-00Z" in read("docker/compose-postgres-s3-opensearch.yml"), "S3 Compose pins the maintained SILO integration image")
|
|
require("docker.io/pgsty/mc:RELEASE.2026-10-26T00-00-00Z" in read("docker/compose-postgres-s3-opensearch.yml"), "S3 Compose pins the matching SILO client image")
|
|
require('test: ["CMD", "silo", "healthcheck", "ready"]' in read("docker/compose-postgres-s3-opensearch.yml"), "S3 Compose waits on SILO readiness before bucket initialization")
|
|
require("endpoint = \"http://s3:9000\"" in read("docker/configs/postgres-s3-opensearch.toml"), "S3 config targets the generic Compose S3 service")
|
|
for rel in ["docker/compose-postgres-s3-opensearch.yml", "docker/compose-mysql-nfs-opensearch.yml"]:
|
|
require("opensearchproject/opensearch:3.9.0" in read(rel), f"Compose pins OpenSearch 3.9.0: {rel}")
|
|
require("opensearchproject/opensearch:latest" not in read(rel), f"Compose avoids moving OpenSearch latest tag: {rel}")
|
|
require('DISABLE_INSTALL_DEMO_CONFIG: "true"' in read(rel), f"Compose disables OpenSearch demo security bootstrap: {rel}")
|
|
require('DISABLE_SECURITY_PLUGIN: "true"' in read(rel), f"Compose explicitly disables OpenSearch security plugin for local integration: {rel}")
|
|
|
|
for rel in ["docker/compose-postgres-s3-opensearch.yml", "docker/compose-mysql-nfs-opensearch.yml"]:
|
|
match = re.search(r"SESSION_ENCRYPTION_KEY:\s*\$\{SESSION_ENCRYPTION_KEY:-([^}]+)\}", read(rel))
|
|
require(match is not None, f"Compose has SESSION_ENCRYPTION_KEY fallback: {rel}")
|
|
if match:
|
|
try:
|
|
decoded = base64.b64decode(match.group(1), validate=True)
|
|
require(len(decoded) == 32, f"Compose fallback session key decodes to 32 bytes: {rel}")
|
|
except Exception as exc:
|
|
errors.append(f"invalid Compose fallback session key in {rel}: {exc}")
|
|
|
|
# SQLite fresh-schema execution.
|
|
schema = read("migrations/sqlite/schema.sql")
|
|
global_sql = extract_section(schema, "-- RUSTBOX:GLOBAL-BEGIN", "-- RUSTBOX:GLOBAL-END")
|
|
tenant_sql = extract_section(schema, "-- RUSTBOX:TENANT-BEGIN", "-- RUSTBOX:TENANT-END").replace("{{p}}", "u_test__")
|
|
try:
|
|
conn = sqlite3.connect(":memory:")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
conn.executescript(global_sql)
|
|
conn.executescript(tenant_sql)
|
|
columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__draft_attachments)")}
|
|
require("checksum_sha256" in columns, "fresh SQLite schema has attachment checksum metadata")
|
|
outbox = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='u_test__search_outbox'").fetchone()
|
|
require(outbox is not None, "fresh SQLite schema has search_outbox")
|
|
outbox_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__search_outbox)")}
|
|
require("claimed_until" in outbox_columns, "fresh SQLite search_outbox separates worker lease from retry availability")
|
|
blob_outbox = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='u_test__blob_delete_outbox'").fetchone()
|
|
require(blob_outbox is not None, "fresh SQLite schema has blob_delete_outbox")
|
|
blob_outbox_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__blob_delete_outbox)")}
|
|
require({"storage_key", "attempts", "available_at", "claimed_until", "last_error"}.issubset(blob_outbox_columns), "fresh SQLite blob deletion outbox has retry/lease state")
|
|
folder_columns = {row[1] for row in conn.execute("PRAGMA table_info(u_test__folders)")}
|
|
require("counter_generation" in folder_columns, "fresh SQLite schema has V048 folder counter generation")
|
|
sqlite_header_sync = "INSERT INTO u_test__user_meta (key, value) VALUES ('header_sync_generation', '1') ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1"
|
|
conn.execute(sqlite_header_sync)
|
|
conn.execute(sqlite_header_sync)
|
|
sqlite_generation = conn.execute("SELECT value FROM u_test__user_meta WHERE key='header_sync_generation'").fetchone()
|
|
require(sqlite_generation == ("2",), "SQLite header-sync UPSERT remains unambiguous and increments generation")
|
|
account_default = conn.execute("PRAGMA table_info(rustbox_accounts)").fetchall()
|
|
version_default = next((row[4] for row in account_default if row[1] == "schema_version"), None)
|
|
require(str(version_default).strip("'\"") == str(BASE_SCHEMA_VERSION), f"fresh global compatibility baseline is {migration_label(BASE_SCHEMA_VERSION)} before tenant registration")
|
|
display_columns = {row[1]: row for row in conn.execute("PRAGMA table_info(u_test__display_preferences)")}
|
|
require("adaptive_email_colors" in display_columns and str(display_columns["adaptive_email_colors"][4]) == "0", "fresh schema defaults adaptive HTML-mail colors off")
|
|
mail_columns = {row[1]: row for row in conn.execute("PRAGMA table_info(u_test__mail_preferences)")}
|
|
require("message_loading_mode" in mail_columns and str(mail_columns["message_loading_mode"][4]).strip("\'\"") == "pagination", "fresh schema defaults mailbox loading mode to pagination")
|
|
conn.close()
|
|
except Exception as exc:
|
|
errors.append(f"fresh SQLite schema execution failed: {exc}")
|
|
|
|
# Migration/query parity across SQL engines. Only migrations newer than the
|
|
# consolidated baseline are active. Historical/squashed V*.sql files may remain
|
|
# in the source tree and are intentionally ignored by the runtime parity checks.
|
|
def active_migrations(dialect: str) -> list[tuple[int, str]]:
|
|
return [
|
|
(version, filename)
|
|
for version, filename in MIGRATIONS[dialect]
|
|
if version > BASE_SCHEMA_VERSION
|
|
]
|
|
|
|
|
|
reference_migrations = active_migrations("sqlite")
|
|
for _, dialect in MIGRATION_DIALECTS:
|
|
dialect_migrations = active_migrations(dialect)
|
|
require(
|
|
dialect_migrations == reference_migrations,
|
|
f"active migration filename/version parity matches SQLite for {dialect}",
|
|
)
|
|
schema_text = read(f"migrations/{dialect}/schema.sql")
|
|
expected_schema_banner = (
|
|
f"Squashed compatibility baseline: {migration_label(BASE_SCHEMA_VERSION)}; "
|
|
f"current schema: {migration_label(LATEST_SCHEMA_VERSION)}."
|
|
)
|
|
require(
|
|
expected_schema_banner in schema_text
|
|
and "counter_generation" in schema_text
|
|
and "blob_delete_outbox" in schema_text
|
|
and "compact_wide_rows" in schema_text
|
|
and "signature_insert_mode" in schema_text
|
|
and "adaptive_email_colors" in schema_text
|
|
and "message_loading_mode" in schema_text
|
|
and "pagination" in schema_text,
|
|
f"fresh {dialect} schema contains the consolidated baseline and current preference columns",
|
|
)
|
|
queries = read(f"src/queries/{dialect}/queries.rs")
|
|
require("adaptive_email_colors" in queries, f"display-preference query includes adaptive_email_colors for {dialect}")
|
|
require("message_loading_mode" in queries, f"mail-preference query includes message_loading_mode for {dialect}")
|
|
folder_block = re.search(r"pub mod db_folders\s*\{(.*?)\n\}", queries, re.S)
|
|
require(folder_block is not None and "Q_032" in folder_block.group(1) and "counter_generation = counter_generation + 1" in folder_block.group(1), f"folder counter CAS increments generation for {dialect}")
|
|
block_match = re.search(r"pub mod db_search_outbox\s*\{(.*?)\n\}", queries, re.S)
|
|
require(block_match is not None, f"search outbox query module exists for {dialect}")
|
|
if block_match:
|
|
qnames = set(re.findall(r"pub const (Q_\d{3})", block_match.group(1)))
|
|
require(qnames == {f"Q_{i:03d}" for i in range(1, 8)}, f"search outbox Q001-Q007 parity for {dialect}")
|
|
blob_block = re.search(r"pub mod db_blob_delete_outbox\s*\{(.*?)\n\}", queries, re.S)
|
|
require(blob_block is not None, f"blob deletion outbox query module exists for {dialect}")
|
|
if blob_block:
|
|
blob_qnames = set(re.findall(r"pub const (Q_\d{3})", blob_block.group(1)))
|
|
require(blob_qnames == {f"Q_{i:03d}" for i in range(1, 6)}, f"blob deletion outbox Q001-Q005 parity for {dialect}")
|
|
|
|
# Runtime migration catalogs are intentionally not statically mirrored against
|
|
# on-disk V*.sql files. They are release-maintained data and are validated by
|
|
# runtime migration tests instead of brittle file-list assertions.
|
|
|
|
require(
|
|
"latest_schema_version" in pool
|
|
and ".last()" in pool
|
|
and "unwrap_or(BASE_SCHEMA_VERSION)" in pool,
|
|
"runtime schema version is derived from the migration catalog",
|
|
)
|
|
require(
|
|
"latest_supported_sqlite = SQLITE_MIGRATIONS" in pool
|
|
and "if version > latest_supported_sqlite" in pool,
|
|
f"legacy SQLite import accepts {migration_label(BASE_SCHEMA_VERSION)}..{migration_label(LATEST_SCHEMA_VERSION)} and rejects newer schemas",
|
|
)
|
|
|
|
# Historical regression for the preference migration. This introduction version is
|
|
# intentionally fixed; do not bump it when V052/V053/... are added. Once the baseline
|
|
# reaches this version, the check automatically switches to fresh-schema validation.
|
|
PREFERENCE_FEATURE_INTRO_VERSION = 51
|
|
preference_migration_version = PREFERENCE_FEATURE_INTRO_VERSION
|
|
preference_migration = migration_containing(
|
|
"sqlite", "adaptive_email_colors", "message_loading_mode", "pagination"
|
|
)
|
|
if BASE_SCHEMA_VERSION < preference_migration_version:
|
|
require(
|
|
preference_migration is not None and preference_migration[0] == preference_migration_version,
|
|
f"{migration_label(preference_migration_version)} preference migration is present while baseline is older",
|
|
)
|
|
if preference_migration is not None and preference_migration[0] == preference_migration_version:
|
|
_, preference_migration_file = preference_migration
|
|
try:
|
|
conn = sqlite3.connect(":memory:")
|
|
conn.executescript("""
|
|
CREATE TABLE u_prod__display_preferences (
|
|
id INTEGER PRIMARY KEY,
|
|
theme TEXT NOT NULL DEFAULT 'system',
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE u_prod__mail_preferences (
|
|
id INTEGER PRIMARY KEY,
|
|
messages_per_page INTEGER NOT NULL DEFAULT 300,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
INSERT INTO u_prod__display_preferences(id) VALUES(1);
|
|
INSERT INTO u_prod__mail_preferences(id) VALUES(1);
|
|
""")
|
|
conn.executescript(
|
|
read(f"migrations/sqlite/{preference_migration_file}").replace("{{p}}", "u_prod__")
|
|
)
|
|
migrated_display = {row[1]: row for row in conn.execute("PRAGMA table_info(u_prod__display_preferences)")}
|
|
migrated_mail = {row[1]: row for row in conn.execute("PRAGMA table_info(u_prod__mail_preferences)")}
|
|
label = migration_label(preference_migration_version)
|
|
require("adaptive_email_colors" in migrated_display and str(migrated_display["adaptive_email_colors"][4]) == "0", f"SQLite {label} adds adaptive_email_colors")
|
|
require("message_loading_mode" in migrated_mail and str(migrated_mail["message_loading_mode"][4]).strip("'\"") == "pagination", f"SQLite {label} adds pagination mode")
|
|
values = conn.execute("SELECT adaptive_email_colors FROM u_prod__display_preferences WHERE id=1").fetchone()
|
|
mode = conn.execute("SELECT message_loading_mode FROM u_prod__mail_preferences WHERE id=1").fetchone()
|
|
require(values == (0,) and mode == ("pagination",), f"SQLite {label} backfills defaults for existing preference rows")
|
|
conn.close()
|
|
except Exception as exc:
|
|
errors.append(f"SQLite {migration_label(preference_migration_version)} migration execution failed: {exc}")
|
|
else:
|
|
require(
|
|
"adaptive_email_colors" in read("migrations/sqlite/schema.sql")
|
|
and "message_loading_mode" in read("migrations/sqlite/schema.sql"),
|
|
f"preference columns remain in the squashed {migration_label(BASE_SCHEMA_VERSION)}+ schema",
|
|
)
|
|
|
|
# Tenant/import table parity and transaction-bound mutation helpers.
|
|
def const_string_array(text: str, name: str) -> list[str]:
|
|
match = re.search(rf"const {name}: &\[&str\] = &\[(.*?)\];", text, re.S)
|
|
return re.findall(r'"([^"\n]+)"', match.group(1)) if match else []
|
|
|
|
tenant_tables = const_string_array(pool, "TENANT_TABLES")
|
|
import_tables = const_string_array(pool, "IMPORT_TABLES")
|
|
require(
|
|
tenant_tables and sorted(tenant_tables) == sorted(import_tables),
|
|
"tenant and legacy-import table sets stay in sync",
|
|
)
|
|
messages_rs = read("src/db/messages.rs")
|
|
routes_messages_rs = read("src/routes/messages/mod.rs")
|
|
require(
|
|
"pub fn remove_after_mutation_in_transaction(" in messages_rs
|
|
and "remove_after_mutation_in_transaction(&transaction, folder, uid)?;" in messages_rs
|
|
and "db::messages::remove_after_mutation_in_transaction(" in routes_messages_rs
|
|
and "db::messages::remove_after_mutation(&transaction" not in routes_messages_rs,
|
|
"move mutation reuses its caller transaction instead of nesting a second transaction",
|
|
)
|
|
|
|
# Exercise the actual SQLite search-outbox query constants against the current tenant schema.
|
|
try:
|
|
sqlite_queries = read("src/queries/sqlite/queries.rs")
|
|
outbox_block = re.search(r"pub mod db_search_outbox\s*\{(.*?)\n\}", sqlite_queries, re.S)
|
|
if outbox_block is None:
|
|
raise RuntimeError("db_search_outbox module not found")
|
|
queries = {
|
|
name: sql.replace("search_outbox", "u_test__search_outbox")
|
|
for name, sql in re.findall(r'pub const (Q_\d{3}): &str = "([^"]+)";', outbox_block.group(1))
|
|
}
|
|
conn = sqlite3.connect(":memory:")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
conn.executescript(tenant_sql)
|
|
conn.execute(queries["Q_001"], ("doc-1", "gen-1", "upsert", 7, "INBOX", "{}", 1000))
|
|
ready = conn.execute(queries["Q_002"], (1000, 10)).fetchall()
|
|
require(len(ready) == 1 and ready[0][0:3] == ("doc-1", "gen-1", "upsert"), "SQLite search outbox enqueue/list query roundtrip works")
|
|
first_claim = conn.execute(queries["Q_006"], ("doc-1", "gen-1", 1060, 1000)).rowcount
|
|
second_claim = conn.execute(queries["Q_006"], ("doc-1", "gen-1", 1060, 1000)).rowcount
|
|
require(first_claim == 1 and second_claim == 0, "SQLite search outbox lease claim is exclusive")
|
|
conn.execute(queries["Q_001"], ("doc-1", "gen-2", "delete", 7, "INBOX", None, 1001))
|
|
requeued = conn.execute(
|
|
"SELECT generation, event_type, attempts, available_at, claimed_until, dead_letter, created_at, updated_at "
|
|
"FROM u_test__search_outbox WHERE id='doc-1'"
|
|
).fetchone()
|
|
require(
|
|
requeued == ("gen-2", "delete", 0, 0, 1060, 0, 1001, 1001),
|
|
"SQLite search outbox re-enqueue resets retry state while preserving an active worker lease",
|
|
)
|
|
require(
|
|
conn.execute(queries["Q_002"], (1001, 10)).fetchall() == [],
|
|
"SQLite replacement event stays hidden until the active lease expires",
|
|
)
|
|
stale_delete = conn.execute(queries["Q_003"], ("doc-1", "gen-1")).rowcount
|
|
current_delete = conn.execute(queries["Q_003"], ("doc-1", "gen-2")).rowcount
|
|
require(stale_delete == 0 and current_delete == 1, "SQLite search outbox generation guard rejects stale acknowledgements")
|
|
conn.execute(queries["Q_001"], ("doc-2", "gen-3", "delete", 9, "Trash", None, 1002))
|
|
failure_update = conn.execute(queries["Q_004"], ("doc-2", "gen-3", 1, 2000, 1, "boom", 1003)).rowcount
|
|
stats = conn.execute(queries["Q_005"]).fetchone()
|
|
claimed_after_failure = conn.execute("SELECT claimed_until FROM u_test__search_outbox WHERE id='doc-2'").fetchone()[0]
|
|
require(failure_update == 1 and stats[0] == 1 and stats[1] == 1 and claimed_after_failure == 0, "SQLite search outbox retry/dead-letter resets worker lease and updates stats")
|
|
conn.execute(queries["Q_001"], ("doc-old", "gen-old", "delete", 10, "Archive", None, 900))
|
|
cleared = conn.execute(queries["Q_007"], (1000,)).rowcount
|
|
require(cleared == 1, "SQLite search outbox can clear pre-reindex superseded events")
|
|
conn.close()
|
|
except Exception as exc:
|
|
errors.append(f"SQLite search-outbox query execution failed: {exc}")
|
|
|
|
# Storage/search architecture invariants.
|
|
config_rs = read("src/config.rs")
|
|
storage_rs = read("src/storage/mod.rs")
|
|
search_rs = read("src/search/engine.rs")
|
|
require("if self.cluster_mode && !data_dir_explicit" in config_rs and "self.data_dir.clear()" in config_rs, "implicit data_dir is disabled in cluster mode")
|
|
require("cluster_shared_data_dir" not in config_rs and "search_index_backend" not in config_rs, "legacy top-level storage/search switches are removed")
|
|
require('self.storage.backend.trim().to_ascii_lowercase()' in config_rs and 'self.search.backend.trim().to_ascii_lowercase()' in config_rs, "storage/search backends come only from their dedicated config sections")
|
|
require('"tantivy" => "local_tantivy"' not in config_rs, "legacy Tantivy backend alias is removed")
|
|
require('"postgres"' not in config_rs.split("#[cfg(test)]", 1)[0] and '"postgresql"' not in config_rs.split("#[cfg(test)]", 1)[0] and '"postgres"' not in read("src/db/pool.rs").split("#[cfg(test)]", 1)[0] and '"postgresql"' not in read("src/db/pool.rs").split("#[cfg(test)]", 1)[0], "database backend uses canonical sqlite/mysql/pgsql names only")
|
|
require("cluster_shared_data_dir" not in read("config.example.toml") and "search_index_backend" not in read("config.example.toml"), "example config contains no pre-0.2 compatibility switches")
|
|
require("legacy_filesystem_root" not in storage_rs and "legacy_path(" not in storage_rs, "blob storage has no legacy attachment read-through path")
|
|
require('"filesystem" | "s3"' in config_rs or '"filesystem", "s3"' in config_rs, "configuration accepts filesystem and S3 storage")
|
|
require("storage.shared" in config_rs, "cluster-safe shared POSIX storage is capability-gated")
|
|
require("AmazonS3Builder" in storage_rs and "BufWriter" in storage_rs, "S3 backend uses streaming object-store writer")
|
|
require("with_retry" in storage_rs and "with_checksum_algorithm" in storage_rs, "S3 backend configures retries and checksums")
|
|
require("attachment_key" in storage_rs and "users/{user_hash}/drafts/{draft_id}/attachments/{attachment_id}" in storage_rs, "attachments use logical storage keys")
|
|
require("enqueue_unreferenced_delete(&conn, destination.as_ref(), &target_key).await" in storage_rs, "attachment migration durably queues failed destination blob cleanup")
|
|
require("let _ = upload.abort().await" in storage_rs and "failed reading source" in storage_rs, "attachment migration aborts a destination upload if source streaming fails")
|
|
require("if let Err(error) = crate::db::drafts::update_attachment_storage" in storage_rs and "Failed to queue storage migration cleanup" in storage_rs, "attachment migration queues destination cleanup when SQL pointer update fails")
|
|
require("OpenSearchBackend" in search_rs, "OpenSearch backend is implemented")
|
|
require("reqwest::blocking" not in search_rs and 'features = ["json", "rustls-tls"]' in read("Cargo.toml"), "OpenSearch uses the async reqwest client without blocking feature")
|
|
require("async fn request(" in search_rs and "request.send().await" in search_rs, "OpenSearch HTTP requests are asynchronous")
|
|
require('json!({"term": {"user_hash": user_hash}})' in search_rs, "OpenSearch queries enforce server-side tenant filter")
|
|
require("process_search_outbox" in search_rs and "claim(&conn" in search_rs, "OpenSearch worker uses durable claimed outbox events")
|
|
require("fn event_lease_secs(&self) -> u64" in search_rs and "let lease_secs = backend.event_lease_secs();" in search_rs and "claim(&conn, &event, 60)" not in search_rs, "OpenSearch outbox lease is derived from HTTP timeout/failover budget instead of a fixed 60 seconds")
|
|
require("opensearch_event_lease_covers_timeout_and_failover_budget" in search_rs, "OpenSearch lease budget has a source-level unit regression test")
|
|
require("claimed_until" in read("migrations/sqlite/schema.sql") and "reenqueue_preserves_active_claim_lease" in read("src/db/search_outbox.rs"), "outbox re-enqueue cannot steal an active worker lease")
|
|
blob_delete_rs = read("src/db/blob_delete_outbox.rs")
|
|
attachments_rs = read("src/routes/attachments.rs")
|
|
send_rs = read("src/routes/send.rs")
|
|
require("mark_failure" in blob_delete_rs and "min(3600)" in blob_delete_rs and "max_attempts" not in blob_delete_rs and "dead_letter" not in read("migrations/sqlite/schema.sql").split("CREATE TABLE {{p}}blob_delete_outbox", 1)[1].split("CREATE TABLE {{p}}search_outbox", 1)[0], "blob deletion outbox retries indefinitely with capped backoff and no permanent dead-letter")
|
|
require("unchecked_transaction()" in attachments_rs and "blob_delete_outbox::enqueue(&transaction" in attachments_rs and "delete_draft_attachment(&transaction" in attachments_rs, "single attachment deletion atomically queues blob cleanup with metadata deletion")
|
|
require("blob_delete_outbox::enqueue(&transaction" in drafts_rs and "delete_draft(&transaction" in drafts_rs, "whole-draft deletion atomically queues every attachment blob before metadata cascade")
|
|
require("blob_delete_outbox::enqueue(&transaction" in send_rs and "delete_draft(&transaction" in send_rs, "post-send draft cleanup atomically queues attachment blobs")
|
|
require("start_blob_delete_worker" in storage_rs and "process_blob_delete_outbox" in storage_rs and "start_blob_delete_worker(config.clone(), storage.clone())" in read("src/main.rs"), "durable blob deletion worker is wired at startup")
|
|
require('let key = "_health/probe".to_string();' in storage_rs, "storage health probe uses a fixed key and cannot accumulate unique orphan probe objects")
|
|
require("index_messages_batch_with_connection" in search_rs and "enqueue_upsert_on" in search_rs, "OpenSearch outbox supports caller-owned SQL transactions")
|
|
require("enqueue_participates_in_caller_transaction" in read("src/db/search_outbox.rs"), "transactional outbox rollback has a unit test")
|
|
require("search reindex" in read("README.md") and "run_search_reindex" in read("src/main.rs"), "full search reindex command is documented and wired")
|
|
require("clear_before(&conn, reindex_started_at)" in read("src/main.rs") and "pub fn clear_before(" in read("src/db/search_outbox.rs"), "full reindex removes only superseded pre-reindex outbox events")
|
|
require("quiesce search workers" in read("docs/STORAGE_SEARCH_OPERATIONS.md"), "cluster reindex documentation requires a controlled maintenance window")
|
|
require("self.backend.refresh().await?" in search_rs, "OpenSearch full reindex forces refresh before reporting completion")
|
|
require("pub async fn document_count(&self) -> Result<usize, String>" in search_rs, "search document-count diagnostics propagate backend errors")
|
|
require(".document_count()\n .await\n .map_err(AppError::InternalError)?" in read("src/routes/diagnostics.rs"), "diagnostics route does not convert OpenSearch count failures into zero")
|
|
idle_rs = read("src/realtime/idle.rs")
|
|
require("fn delete_cached_message(" in idle_rs and "delete_message_with_connection(&transaction, uid, folder)" in idle_rs, "IMAP IDLE cache deletion writes search outbox in the same DB transaction")
|
|
require("search_engine.clone()," in read("src/realtime/ws.rs"), "websocket IDLE wiring passes the shared search engine")
|
|
require("cargo generate-lockfile && cargo build --release --locked" in read("docker/Dockerfile"), "Docker release build generates and honors a dependency lockfile")
|
|
require("PostgreSQL + SILO (S3-compatible) + OpenSearch" in read("CHANGELOG.md"), "0.2.0 changelog matches the pinned S3-compatible Compose service")
|
|
require("docs/RUSTBOX_PLAN.md" not in read("docs/IMPLEMENTATION_STATUS.md"), "implementation status does not reference a missing release-plan file")
|
|
require("intentionally run one RustBox application container" in read("docker/README.md"), "Docker docs distinguish shared backing topology from multi-app load balancing")
|
|
|
|
attachments_rs = read("src/routes/attachments.rs")
|
|
require(".begin_upload(&key)" in attachments_rs and ".get(&attachment.storage_key)" in attachments_rs and "storage::process_queued_delete" in attachments_rs, "attachment HTTP routes use BlobStorage")
|
|
require("tokio::fs::" not in attachments_rs, "attachment HTTP routes no longer access local filesystem directly")
|
|
require("pub(crate) struct AttachMessageSourceRequest" in attachments_rs, "forward-as-attachment request type matches route handler crate visibility")
|
|
attachment_enqueue = attachments_rs.find("blob_delete_outbox::enqueue(&transaction")
|
|
attachment_db_delete = attachments_rs.find("delete_draft_attachment(&transaction", attachment_enqueue)
|
|
attachment_commit = attachments_rs.find(".commit()", attachment_db_delete)
|
|
attachment_blob_delete = attachments_rs.find("storage::process_queued_delete", attachment_commit)
|
|
require(0 <= attachment_enqueue < attachment_db_delete < attachment_commit < attachment_blob_delete, "attachment delete commits metadata removal and cleanup enqueue atomically before physical blob deletion")
|
|
drafts_rs = read("src/routes/drafts.rs")
|
|
draft_enqueue = drafts_rs.find("blob_delete_outbox::enqueue(&transaction")
|
|
draft_db_delete = drafts_rs.find("delete_draft(&transaction", draft_enqueue)
|
|
draft_commit = drafts_rs.find(".commit()", draft_db_delete)
|
|
draft_blob_delete = drafts_rs.find("storage::process_queued_delete", draft_commit)
|
|
require(0 <= draft_enqueue < draft_db_delete < draft_commit < draft_blob_delete, "draft delete commits metadata cascade and cleanup enqueue atomically before physical blob deletion")
|
|
send_rs = read("src/routes/send.rs")
|
|
send_enqueue = send_rs.find("blob_delete_outbox::enqueue(&transaction")
|
|
send_db_delete = send_rs.find("delete_draft(&transaction", send_enqueue)
|
|
send_commit = send_rs.find("transaction.commit()", send_db_delete)
|
|
send_blob_delete = send_rs.find("storage::process_queued_delete", send_commit)
|
|
require(0 <= send_enqueue < send_db_delete < send_commit < send_blob_delete, "post-send cleanup commits metadata removal and cleanup enqueue atomically before physical blob deletion")
|
|
|
|
# Repository/source-package hygiene independent of Cargo.
|
|
source_files = [path for path in ROOT.rglob("*") if path.is_file() and ".git" not in path.parts and "target" not in path.parts]
|
|
conflict_marker = re.compile(r"(?m)^(<<<<<<<|=======|>>>>>>>)")
|
|
conflicted = []
|
|
for path in source_files:
|
|
if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".woff", ".woff2", ".ttf", ".zip", ".gz"}:
|
|
continue
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except UnicodeDecodeError:
|
|
continue
|
|
if conflict_marker.search(text):
|
|
conflicted.append(str(path.relative_to(ROOT)))
|
|
require(not conflicted, f"no unresolved merge-conflict markers ({', '.join(conflicted) if conflicted else 'clean'})")
|
|
require(not any(path.suffix in {".ts", ".tsx"} for path in source_files), "source package contains no TypeScript files")
|
|
|
|
manifest = [line.strip() for line in read("src/web/js/manifest.txt").splitlines() if line.strip() and not line.lstrip().startswith("#")]
|
|
missing_manifest = [entry for entry in manifest if not (ROOT / "src/web/js" / entry).is_file()]
|
|
require(not missing_manifest, f"browser JS manifest entries exist ({', '.join(missing_manifest) if missing_manifest else 'complete'})")
|
|
|
|
include_paths = []
|
|
for path in ROOT.glob("src/**/*.rs"):
|
|
text = path.read_text(encoding="utf-8")
|
|
for match in re.finditer(r'include_(?:str|bytes)!\(\s*"([^"]+)"\s*\)', text):
|
|
include_paths.append((path, match.group(1)))
|
|
missing_includes = []
|
|
for source, relative in include_paths:
|
|
candidate = (source.parent / relative).resolve()
|
|
if not candidate.is_file():
|
|
missing_includes.append(f"{source.relative_to(ROOT)} -> {relative}")
|
|
require(not missing_includes, f"Rust include_str/include_bytes targets exist ({'; '.join(missing_includes) if missing_includes else 'complete'})")
|
|
|
|
# Shell syntax checks are useful offline and deterministic.
|
|
try:
|
|
shell_files = [ROOT / "dev.sh", *sorted((ROOT / "scripts").glob("*.sh")), ROOT / "docker/garage/init/init.sh"]
|
|
subprocess.run(["bash", "-n", *map(str, shell_files)], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
ok("shell scripts pass bash -n")
|
|
except Exception as exc:
|
|
errors.append(f"shell syntax validation failed: {exc}")
|
|
|
|
|
|
# Interface-style standard (0.2.23): every predefined style must be listed in
|
|
# the Rust catalog, the settings selector, the JS layout profiles, both palettes
|
|
# and the structural token table, so a style can never be half-defined.
|
|
skin_catalog_match = re.search(r"pub const INTERFACE_SKINS: &\[&str\] = &\[(.*?)\];", display_prefs_rs, re.S)
|
|
skin_catalog = re.findall(r'"([a-z0-9-]+)"', skin_catalog_match.group(1)) if skin_catalog_match else []
|
|
require(len(skin_catalog) >= 30 and len(skin_catalog) == len(set(skin_catalog)) and "default" not in skin_catalog, "Rust interface-style catalog is complete and duplicate-free")
|
|
require("crate::db::display_preferences::INTERFACE_SKINS" in read("src/config.rs"), "server default interface style is validated against the shared Rust catalog")
|
|
skin_select = re.search(r'<select id="pref-interface-skin">(.*?)</select>', index_html, re.S)
|
|
skin_options = re.findall(r'<option value="([a-z0-9-]+)"', skin_select.group(1)) if skin_select else []
|
|
require(skin_options == ["default", *skin_catalog], "settings interface-style selector lists exactly the Rust catalog in order")
|
|
prefs_js = read("src/web/js/20-preferences-auth.js")
|
|
profile_block = prefs_js.split("const interfaceStyleProfiles = {", 1)[1].split("\n};", 1)[0] if "const interfaceStyleProfiles = {" in prefs_js else ""
|
|
profile_keys = re.findall(r'^ "?([a-z0-9-]+)"?: \{$', profile_block, re.M)
|
|
require(sorted(profile_keys) == sorted(skin for skin in skin_catalog if skin != "rustbox"), "every predefined style has a fixed JS layout profile")
|
|
palette_css = read("src/web/css/55-roundcube-skins.css") + read("src/web/css/56-interface-styles.css")
|
|
missing_palettes = [
|
|
f"{skin}/{theme}"
|
|
for skin in skin_catalog if skin != "rustbox"
|
|
for theme in ("light", "dark")
|
|
if not re.search(rf'html\[data-interface-skin="{re.escape(skin)}"\]\[data-theme="{theme}"\]\s*\{{[^}}]*--accent:', palette_css)
|
|
]
|
|
require(not missing_palettes, "every predefined style ships light and dark palettes" + (f" (missing: {', '.join(missing_palettes)})" if missing_palettes else ""))
|
|
token_css = read("src/web/css/57-skin-tokens.css")
|
|
missing_tokens = [skin for skin in skin_catalog if f'html[data-interface-skin="{skin}"] {{' not in token_css]
|
|
require(not missing_tokens, "every interface style declares structural tokens" + (f" (missing: {', '.join(missing_tokens)})" if missing_tokens else ""))
|
|
root_tokens = set(re.findall(r"--(skin-[a-z-]+):", token_css.split("}", 1)[0]))
|
|
used_tokens = set(re.findall(r"var\(--(skin-[a-z-]+)\)", read("src/web/css/58-skin-immersive.css")))
|
|
declared_tokens = set(re.findall(r"--(skin-[a-z-]+):", token_css))
|
|
require(used_tokens <= root_tokens and declared_tokens <= root_tokens, "immersive rules only use structural tokens that have :root defaults")
|
|
css_manifest = [line.strip() for line in read("src/web/css/manifest.txt").splitlines() if line.strip()]
|
|
require(
|
|
"57-skin-tokens.css" in css_manifest and "58-skin-immersive.css" in css_manifest
|
|
and css_manifest.index("56-interface-styles.css") < css_manifest.index("57-skin-tokens.css") < css_manifest.index("58-skin-immersive.css") < css_manifest.index("60-responsive.css"),
|
|
"style tokens and immersive rules load after the palettes and before responsive overrides",
|
|
)
|
|
require(
|
|
'pub const SKIN_DEPTHS: &[&str] = &["colors", "standard", "immersive"];' in display_prefs_rs
|
|
and 'skinDepth: "skin_depth"' in prefs_js and 'id="pref-skin-depth"' in index_html
|
|
and "root.dataset.skinDepth" in prefs_js and 'savePreference("skinDepth"' in bindings_js,
|
|
"style depth is persisted, exposed in Appearance and applied to the document",
|
|
)
|
|
for _, dialect in MIGRATION_DIALECTS:
|
|
require(all(column in read(f"migrations/{dialect}/schema.sql") and column in read(f"src/queries/{dialect}/queries.rs") for column in ("skin_depth", "skin_gradients")), f"{dialect} fresh schema and display query include skin_depth and skin_gradients")
|
|
try:
|
|
conn = sqlite3.connect(":memory:")
|
|
conn.executescript("CREATE TABLE u_prod__display_preferences (id INTEGER PRIMARY KEY, interface_skin TEXT NOT NULL DEFAULT 'rustbox'); INSERT INTO u_prod__display_preferences(id, interface_skin) VALUES(1, 'nord');")
|
|
conn.executescript(read("migrations/sqlite/V053__skin_depth_and_gradients.sql").replace("{{p}}", "u_prod__"))
|
|
require(conn.execute("SELECT interface_skin, skin_depth, skin_gradients FROM u_prod__display_preferences WHERE id=1").fetchone() == ("nord", "standard", 0), "SQLite V053 adds skin_depth and skin_gradients with standard/off defaults for existing tenants")
|
|
conn.close()
|
|
except Exception as exc:
|
|
errors.append(f"SQLite V053 migration execution failed: {exc}")
|
|
require(
|
|
"pub skin_gradients: bool" in display_prefs_rs and 'skinGradients: "skin_gradients"' in prefs_js
|
|
and 'id="pref-skin-gradients"' in index_html and "root.dataset.skinGradients" in prefs_js
|
|
and 'savePreference("skinGradients"' in bindings_js,
|
|
"style gradients have their own persisted Appearance switch",
|
|
)
|
|
gradient_css = read("src/web/css/59-skin-gradients.css")
|
|
require(
|
|
"59-skin-gradients.css" in css_manifest
|
|
and css_manifest.index("58-skin-immersive.css") < css_manifest.index("59-skin-gradients.css") < css_manifest.index("60-responsive.css"),
|
|
"style gradient layer loads after the immersive rules",
|
|
)
|
|
require(
|
|
all(f"var(--grad-{layer})" in gradient_css for layer in ("backdrop", "appbar", "toolbar", "sidebar", "selected", "primary"))
|
|
and ':root[data-skin-gradients="true"]:not([data-interface-skin="rustbox"])[data-theme] .appbar' in gradient_css
|
|
and "data-skin-depth" not in gradient_css.split("/* Consumers", 1)[-1].split("{", 1)[0],
|
|
"style gradients apply to every predefined style except RustBox at any style depth",
|
|
)
|
|
# Every gradient in the stylesheets must sit in a rule scoped to the switch.
|
|
ungated_gradients = []
|
|
for css_name in css_manifest:
|
|
css_text = re.sub(r"/\*.*?\*/", "", read(f"src/web/css/{css_name}"), flags=re.S)
|
|
for match in re.finditer(r"gradient\(", css_text):
|
|
block_start = css_text.rfind("{", 0, match.start())
|
|
selector_start = max(css_text.rfind("}", 0, block_start), css_text.rfind(";", 0, block_start))
|
|
selector = css_text[selector_start + 1:block_start]
|
|
if 'data-skin-gradients="true"' not in selector:
|
|
ungated_gradients.append(f"{css_name}: {selector.strip()[:60]}")
|
|
require(not ungated_gradients, "every stylesheet gradient is gated by the style-gradients switch" + (f" (ungated: {'; '.join(ungated_gradients[:5])})" if ungated_gradients else ""))
|
|
for _, dialect in MIGRATION_DIALECTS:
|
|
require(all(column in read(f"migrations/{dialect}/schema.sql") and column in read(f"src/queries/{dialect}/queries.rs") for column in ("theme_light_from", "theme_dark_from", "account_color")), f"{dialect} fresh schema and display query include the theme schedule and account colour")
|
|
try:
|
|
conn = sqlite3.connect(":memory:")
|
|
conn.executescript("CREATE TABLE u_prod__display_preferences (id INTEGER PRIMARY KEY, theme TEXT NOT NULL DEFAULT 'system'); INSERT INTO u_prod__display_preferences(id) VALUES(1); CREATE TABLE u_prod__calendar_events (id TEXT PRIMARY KEY, reminder_minutes INTEGER); INSERT INTO u_prod__calendar_events(id, reminder_minutes) VALUES('e1', 15); CREATE TABLE u_prod__calendar_settings (id INTEGER PRIMARY KEY); INSERT INTO u_prod__calendar_settings(id) VALUES(1);")
|
|
conn.executescript(read("migrations/sqlite/V054__theme_schedule_account_color_and_reminders.sql").replace("{{p}}", "u_prod__"))
|
|
require(conn.execute("SELECT theme_light_from, theme_dark_from, account_color FROM u_prod__display_preferences WHERE id=1").fetchone() == ("07:00", "19:00", ""), "SQLite V054 adds the theme schedule and account colour with defaults")
|
|
require(conn.execute("SELECT reminder_minutes, reminder_extra_minutes FROM u_prod__calendar_events WHERE id='e1'").fetchone() == (15, None), "SQLite V054 adds an optional second calendar reminder and keeps existing reminders")
|
|
require(conn.execute("SELECT default_reminder_minutes, default_extra_reminder_minutes FROM u_prod__calendar_settings WHERE id=1").fetchone() == (None, None), "SQLite V054 adds empty default reminders for new events")
|
|
conn.close()
|
|
except Exception as exc:
|
|
errors.append(f"SQLite V054 migration execution failed: {exc}")
|
|
require(
|
|
'"schedule"' in display_prefs_rs and 'value="schedule"' in index_html and "function scheduledTheme(" in prefs_js
|
|
and 'id="pref-account-color"' in index_html and 'html[data-account-color="true"] .appbar::before' in read("src/web/css/50-settings.css"),
|
|
"time-of-day theme and per-account colour are wired end to end",
|
|
)
|
|
for _, dialect in MIGRATION_DIALECTS:
|
|
queries_text = read(f"src/queries/{dialect}/queries.rs")
|
|
require(
|
|
"reminder_extra_minutes" in read(f"migrations/{dialect}/schema.sql") and "reminder_extra_minutes" in queries_text
|
|
and "default_extra_reminder_minutes" in queries_text,
|
|
f"{dialect} schema and calendar queries include the second reminder and reminder defaults",
|
|
)
|
|
calendar_js = read("src/web/js/71-calendar.js")
|
|
require(
|
|
'id="calendar-reminder-extra"' in index_html and 'id="calendar-default-reminder"' in index_html
|
|
and "reminder_extra_minutes: reminderSelectValue(" in calendar_js
|
|
and "calendarReminderTrigger(event, minutes)" in read("src/web/js/80-settings-realtime.js"),
|
|
"calendar events support a second reminder (e.g. one day before) with defaults for new events",
|
|
)
|
|
require("function updateContrastWarning(" in prefs_js and 'id="pref-contrast-warning"' in index_html, "custom colours show a live contrast warning")
|
|
sieve_js = read("src/web/js/90-sieve.js")
|
|
sieve_apply_js = read("src/web/js/91-sieve-apply.js")
|
|
filters_rs = read("src/routes/filters.rs")
|
|
require(
|
|
'.route("/filters/match", post(filters::match_filter_handler))' in read("src/routes/mod.rs")
|
|
and "async fn search_uids(" in read("src/imap/client.rs")
|
|
and "async fn search_uids(" in read("src/imap/mock.rs")
|
|
and all("pub mod db_filter_match" in read(f"src/queries/{d}/queries.rs") for d in ("sqlite", "mysql", "pgsql")),
|
|
"filters: POST /filters/match is routed and backed by IMAP UID SEARCH on every client",
|
|
)
|
|
require(
|
|
"fn bounds(" in filters_rs and "fn resolve_matches(" in filters_rs and "uncertain" in filters_rs and "#[cfg(test)]" in filters_rs,
|
|
"filters: apply-now matching uses upper/lower IMAP bounds, decides the gap from the cache and reports uncertain messages",
|
|
)
|
|
require(
|
|
"91-sieve-apply.js" in read("src/web/js/manifest.txt")
|
|
and 'id="sieve-apply-check"' in index_html and 'id="sieve-apply-run"' in index_html
|
|
and "function runParsedSieveRuleNow(" in sieve_apply_js and "function alwaysMoveFromSender(" in sieve_apply_js
|
|
and "function ensureSieveTargetFolders(" in sieve_apply_js and 'id="sieve-templates"' in index_html,
|
|
"filters: apply now, per-rule run now, templates, missing-folder creation and 'always move from this sender' are wired",
|
|
)
|
|
require(
|
|
'id="sieve-builder-exceptions"' in index_html and "function splitSieveExceptions(" in sieve_js
|
|
and all(f'"{kind}"' in sieve_js for kind in ("to-only", "cc-only", "size-over", "size-under", "has-attachment", "priority-high"))
|
|
and '"label"' in sieve_js and '"trash"' in sieve_js,
|
|
"filters: exceptions, new conditions (To/Cc only, size, attachment, priority) and label/trash actions round-trip through the editor",
|
|
)
|
|
mysql_text_literal_defaults = re.findall(r"^\s*(\w+)\s+TEXT(?:\s+NOT NULL)?\s+DEFAULT\s+'", read("migrations/mysql/schema.sql") + "\n" + "\n".join(read(f"migrations/mysql/{name}") for _, name in MIGRATIONS["mysql"]), re.M)
|
|
require(not mysql_text_literal_defaults, "MySQL TEXT columns use expression defaults" + (f" (literal: {', '.join(mysql_text_literal_defaults)})" if mysql_text_literal_defaults else ""))
|
|
|
|
# TODO/release honesty: live integrations remain explicitly pending.
|
|
todo = read("TODO.md")
|
|
require("[ ] SILO Compose integration validation" in todo, "TODO keeps live SILO validation pending")
|
|
require("[ ] MinIO integration validation" in todo, "TODO keeps live MinIO validation pending")
|
|
require("[ ] AWS S3 integration validation" in todo, "TODO keeps live AWS S3 validation pending")
|
|
require("[ ] Ceph RGW compatibility validation" in todo, "TODO keeps live Ceph RGW validation pending")
|
|
require("[ ] S3-compatible storage works for draft attachments (live SILO/MinIO/AWS validation pending)" in todo, "acceptance does not overclaim live S3 validation")
|
|
|
|
print(f"static checks passed: {len(checks)}")
|
|
for message in checks:
|
|
print(f"PASS {message}")
|
|
if errors:
|
|
print(f"static checks failed: {len(errors)}", file=sys.stderr)
|
|
for message in errors:
|
|
print(f"FAIL {message}", file=sys.stderr)
|
|
raise SystemExit(1)
|