From 56cfee5e442751378eb198add5a4e642005a91b0 Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Thu, 30 Jul 2026 02:41:04 +0200 Subject: [PATCH] Verify RFC 9208 IMAP quota queries --- contrib/testing/README.md | 16 ++ contrib/testing/imap-quota-check.py | 274 ++++++++++++++++++++++++++++ docs/quotas.md | 6 +- docs/release-testing-0.7.md | 2 +- docs/test-evidence/0.7-r1.md | 46 +++++ 5 files changed, 342 insertions(+), 2 deletions(-) create mode 100755 contrib/testing/imap-quota-check.py diff --git a/contrib/testing/README.md b/contrib/testing/README.md index 95a9e3e..1e3a14a 100644 --- a/contrib/testing/README.md +++ b/contrib/testing/README.md @@ -108,6 +108,11 @@ one-off files in `/tmp`: subscription, flags, and message bytes across a real Bongo service restart. It intentionally keeps one TLS session open during restart to cover clean shutdown of an idle client. +- `imap-quota-check.py` temporarily applies an intentionally unaligned + per-user limit and verifies RFC 9208 capability publication, STORAGE-unit + rounding, existing and prospective mailbox roots, unknown roots, malformed + commands, the unlimited state, and rejection of unadvertised `SETQUOTA`. + Its cleanup restores the original exact-byte limit. - `upstream-imaptest-check.py` runs the generic Dovecot ImapTest project against live Bongo IMAPS. Scripted mode stages a selected core, search, `imap-01-11`, `imap-01-12`, `imap-01-13`, `imap-01-14`, or complete @@ -477,6 +482,17 @@ The script invokes `sudo -n bongo-admin user quota` only to set and restore the temporary administrative limit. The password is never passed on a command line. +The IMAP quota check uses the retained `test1` account and requires the same +explicit live-test opt-in: + +```sh +export BONGO_ALLOW_LIVE_USER_TEST=1 +export BONGO_TEST_PASSWORD='a-disposable-password-of-at-least-12-characters' +./contrib/testing/imap-quota-check.py +``` + +The original exact-byte limit is restored even when the test fails. + The partial Queue quota check creates two held messages and temporarily leaves space for exactly one of them. It verifies that the first reaches Store, the second remains byte-identical in Queue after Store returns quota exceeded, and diff --git a/contrib/testing/imap-quota-check.py b/contrib/testing/imap-quota-check.py new file mode 100755 index 0000000..bc0588b --- /dev/null +++ b/contrib/testing/imap-quota-check.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +# This program is free software, licensed under the terms of the GNU GPL. +# See the Bongo COPYING file for full details. +# Copyright (c) 2026 Bongo Project contributors + +"""Verify the RFC 9208 IMAP quota query contract on a live server.""" + +from __future__ import annotations + +import os +import re +import socket +import ssl +import subprocess +import sys +import time + + +HOST = os.environ.get("BONGO_TEST_HOST", "127.0.0.1") +PORT = int(os.environ.get("BONGO_TEST_IMAPS_PORT", "993")) +TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "10")) +USERNAME = os.environ.get("BONGO_TEST_USER", "test1") +PASSWORD = os.environ.get("BONGO_TEST_PASSWORD", "") +ADMIN = os.environ.get("BONGO_TEST_ADMIN", "/usr/bin/bongo-admin") +ALLOW_LIVE = os.environ.get("BONGO_ALLOW_LIVE_USER_TEST") == "1" +TAGGED = re.compile(rb"^([A-Za-z0-9]+) (OK|NO|BAD)(?: .*)?\r\n$") +QUOTA = re.compile( + rb'^\* QUOTA "" \(STORAGE ([0-9]+) ([0-9]+)\)\r\n$', + re.IGNORECASE, +) + + +class IMAPQuotaError(RuntimeError): + """Raised when the live quota contract is not met.""" + + +def quote(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def run_admin(*arguments: str) -> str: + completed = subprocess.run( + ["sudo", "-n", ADMIN, *arguments], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode: + detail = completed.stderr.strip() or completed.stdout.strip() + raise IMAPQuotaError( + f"bongo-admin {' '.join(arguments)} failed: {detail}" + ) + return completed.stdout + + +def read_admin_quota() -> tuple[int, int]: + output = run_admin("user", "quota", USERNAME) + used_match = re.search(r"^Used: ([0-9]+) bytes$", output, re.MULTILINE) + limit_match = re.search( + r"^Quota: (?:(unlimited)|([0-9]+) bytes)$", output, re.MULTILINE + ) + if not used_match or not limit_match: + raise IMAPQuotaError( + f"cannot parse bongo-admin quota output: {output!r}" + ) + limit = 0 if limit_match.group(1) else int(limit_match.group(2)) + return int(used_match.group(1)), limit + + +def set_admin_quota(limit: int) -> None: + run_admin( + "user", + "quota", + USERNAME, + "unlimited" if limit == 0 else str(limit), + ) + + +class IMAPConnection: + def __init__(self) -> None: + raw = socket.create_connection((HOST, PORT), timeout=TIMEOUT) + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + context.minimum_version = ssl.TLSVersion.TLSv1_2 + self.socket = context.wrap_socket(raw, server_hostname=HOST) + self.socket.settimeout(TIMEOUT) + self.reader = self.socket.makefile("rb") + self.counter = 0 + greeting = self.line() + if not greeting.startswith(b"* OK "): + raise IMAPQuotaError(f"unexpected IMAP greeting: {greeting!r}") + + def close(self) -> None: + try: + self.reader.close() + finally: + self.socket.close() + + def line(self) -> bytes: + line = self.reader.readline(65538) + if not line: + raise IMAPQuotaError( + "connection closed while waiting for IMAP response" + ) + if len(line) > 65536 or not line.endswith(b"\r\n"): + raise IMAPQuotaError(f"invalid IMAP response line: {line!r}") + return line + + def command( + self, command: str, expected: tuple[str, ...] = ("OK",) + ) -> list[bytes]: + self.counter += 1 + tag = f"Q{self.counter:04d}" + self.socket.sendall(f"{tag} {command}\r\n".encode("utf-8")) + lines: list[bytes] = [] + while True: + line = self.line() + match = TAGGED.match(line) + if match is not None and match.group(1).decode("ascii") == tag: + status = match.group(2).decode("ascii") + if status not in expected: + raise IMAPQuotaError( + f"{command!r} expected {expected}, received {line!r}" + ) + return lines + [line] + lines.append(line) + + +def require_line(lines: list[bytes], pattern: bytes, description: str) -> None: + if not any(re.search(pattern, line, re.IGNORECASE) for line in lines): + raise IMAPQuotaError(f"{description} missing from {lines!r}") + + +def require_no_line( + lines: list[bytes], pattern: bytes, description: str +) -> None: + if any(re.search(pattern, line, re.IGNORECASE) for line in lines): + raise IMAPQuotaError(f"unexpected {description} in {lines!r}") + + +def quota_values(lines: list[bytes]) -> tuple[int, int]: + matches = [QUOTA.match(line) for line in lines] + values = [match for match in matches if match is not None] + if len(values) != 1: + raise IMAPQuotaError( + f"expected exactly one STORAGE quota response, got {lines!r}" + ) + return int(values[0].group(1)), int(values[0].group(2)) + + +def units(value: int) -> int: + return value // 1024 + (value % 1024 != 0) + + +def require_safe_environment() -> None: + if not ALLOW_LIVE: + raise IMAPQuotaError( + "set BONGO_ALLOW_LIVE_USER_TEST=1 for the disposable live account" + ) + if not PASSWORD: + raise IMAPQuotaError("BONGO_TEST_PASSWORD must be set") + + +def main() -> int: + require_safe_environment() + baseline, original_limit = read_admin_quota() + temporary_limit = baseline + 2051 + while temporary_limit % 1024 == 0: + temporary_limit += 1 + prospective = f"Bongo 07 Prospective {os.getpid():x}-{time.time_ns():x}" + client: IMAPConnection | None = None + + try: + set_admin_quota(temporary_limit) + observed_used, observed_limit = read_admin_quota() + if (observed_used, observed_limit) != (baseline, temporary_limit): + raise IMAPQuotaError( + "administrative quota did not reach the Store: " + f"{observed_used}/{observed_limit}" + ) + + client = IMAPConnection() + capability = client.command("CAPABILITY") + capability_line = b" ".join( + line.upper() for line in capability if line.startswith(b"* CAPABILITY ") + ) + for token in (b" QUOTA ", b" QUOTA=RES-STORAGE "): + if token not in b" " + capability_line + b" ": + raise IMAPQuotaError( + f"{token.strip().decode()} not advertised: {capability!r}" + ) + if b" QUOTASET " in b" " + capability_line + b" ": + raise IMAPQuotaError(f"unsupported QUOTASET advertised: {capability!r}") + + client.command(f"LOGIN {quote(USERNAME)} {quote(PASSWORD)}") + expected = (units(baseline), units(temporary_limit)) + if quota_values(client.command('GETQUOTA ""')) != expected: + raise IMAPQuotaError("GETQUOTA did not report rounded STORAGE units") + + inbox = client.command("GETQUOTAROOT INBOX") + require_line( + inbox, + rb'^\* QUOTAROOT "INBOX" ""\r\n$', + "INBOX quota root", + ) + if quota_values(inbox) != expected: + raise IMAPQuotaError("INBOX quota root reported different usage") + + unknown = client.command(f"GETQUOTAROOT {quote(prospective)}") + require_line( + unknown, + rb'^\* QUOTAROOT "Bongo 07 Prospective [^"]+" ""\r\n$', + "prospective mailbox quota root", + ) + if quota_values(unknown) != expected: + raise IMAPQuotaError( + "prospective mailbox did not report the account quota root" + ) + + client.command('GETQUOTA "not-a-quota-root"', ("NO",)) + client.command("SETQUOTA \"\" (STORAGE 1)", ("NO",)) + client.command("GETQUOTA", ("BAD",)) + client.command('GETQUOTA "" extra', ("BAD",)) + client.command("GETQUOTAROOT", ("BAD",)) + client.command("GETQUOTAROOT INBOX extra", ("BAD",)) + client.command("NOOP") + + set_admin_quota(0) + unlimited_used, unlimited_limit = read_admin_quota() + if (unlimited_used, unlimited_limit) != (baseline, 0): + raise IMAPQuotaError( + f"unlimited quota state is {unlimited_used}/{unlimited_limit}" + ) + client.command('GETQUOTA ""', ("NO",)) + unlimited = client.command("GETQUOTAROOT INBOX") + require_line( + unlimited, + rb'^\* QUOTAROOT "INBOX"\r\n$', + "unlimited mailbox response without roots", + ) + require_no_line(unlimited, rb"^\* QUOTA ", "unlimited QUOTA response") + client.command("NOOP") + client.command("LOGOUT") + finally: + if client is not None: + try: + client.close() + except OSError: + pass + set_admin_quota(original_limit) + + restored_used, restored_limit = read_admin_quota() + if (restored_used, restored_limit) != (baseline, original_limit): + raise IMAPQuotaError( + "quota cleanup did not restore the original state: " + f"{restored_used}/{restored_limit}, expected {baseline}/{original_limit}" + ) + print( + "IMAP-21 PASS " + "capability=QUOTA/RES-STORAGE/no-QUOTASET " + f"storage={units(baseline)}/{units(temporary_limit)}KiB " + "roots=existing/prospective unknown-root=NO " + "unlimited=no-root malformed=BAD session=reusable quota-restored=yes" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, IMAPQuotaError) as error: + print(f"IMAP-21 FAIL: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/docs/quotas.md b/docs/quotas.md index 658161e..3ee7010 100644 --- a/docs/quotas.md +++ b/docs/quotas.md @@ -44,7 +44,11 @@ IMAP advertises `QUOTA` and `QUOTA=RES-STORAGE` and implements the RFC 9208 The single quota root is the empty string and governs every mailbox in the authenticated user's Store. Bongo does not advertise `QUOTASET`; only a local administrator can change a limit. IMAP reports STORAGE in units of 1024 -octets, while `bongo-admin` reports exact logical bytes. +octets, rounded up from the Store's exact logical byte count, while +`bongo-admin` reports exact bytes. As required for mailbox provisioning by +RFC 9208, `GETQUOTAROOT` also reports the prospective root for a syntactically +valid mailbox which has not been created yet. `GETQUOTA` rejects any root +other than the empty root. POP3 has no standardized quota query command, and Cyrus POP3 follows the same model. Bongo therefore does not invent a POP3 capability. POP3 accesses the diff --git a/docs/release-testing-0.7.md b/docs/release-testing-0.7.md index 2ebb29f..8b47e4c 100644 --- a/docs/release-testing-0.7.md +++ b/docs/release-testing-0.7.md @@ -188,7 +188,7 @@ review does not mark an open live test as passed. | IMAP-18 | Old-client plain IMAP4 subset without rev1/rev2 assumptions | [PASS](test-evidence/0.7-r1.md#imap-18) | | | | IMAP-19 | Invalid states, malformed sets/literals, oversized input, and STARTTLS plaintext-pipeline injection resistance | [PASS](test-evidence/0.7-r1.md#imap-19) | | | | IMAP-20 | Reconnect, simultaneous clients, restart, mailbox consistency | [PASS](test-evidence/0.7-r1.md#imap-20) | | | -| IMAP-21 | RFC 9208 capability, GETQUOTA/GETQUOTAROOT, storage units and unknown mailbox handling | | | | +| IMAP-21 | RFC 9208 capability, GETQUOTA/GETQUOTAROOT, storage units and unknown mailbox handling | [PASS](test-evidence/0.7-r1.md#imap-21) | | | | IMAP-22 | DELETED-STORAGE and [OVERQUOTA] for APPEND/COPY/MOVE; no unadvertised QUOTASET | | | | | IMAP-23 | Dovecot ImapTest scripted core/search suites and deterministic state-tracked multi-client stress | | | | diff --git a/docs/test-evidence/0.7-r1.md b/docs/test-evidence/0.7-r1.md index 3282141..f761c1a 100644 --- a/docs/test-evidence/0.7-r1.md +++ b/docs/test-evidence/0.7-r1.md @@ -4175,6 +4175,52 @@ The normal and strict `-Wall -Wextra -Werror` builds each passed all 108 CTest cases after both shutdown changes. The final live run completed in 7.27 seconds including service restart, reconnection, verification, and cleanup. +## IMAP-21 + +Result: **PASS** + +The Gentoo GCC 15.3.0 debug installation through commit `b88ce2a6` was tested +on 2026-07-30. Bongo advertises `QUOTA` and `QUOTA=RES-STORAGE`, but not +`QUOTASET`. `GETQUOTA ""` and `GETQUOTAROOT` report the authenticated +account's exact Store state in RFC 9208 STORAGE units of 1024 octets, rounded +up without truncating the effective byte limit. + +The command behavior was compared directly with RFC 9208, Dovecot 2.4.4 +`src/plugins/imap-quota/imap-quota-plugin.c`, and Cyrus IMAP 3.12.3 +`imap/imapd.c`. RFC 9208 explicitly permits `GETQUOTAROOT` for a mailbox +which does not yet exist so a client can determine its prospective quota +root before creation. Dovecot follows this behavior; Bongo therefore reports +the same account root for existing and syntactically valid prospective +mailboxes. An unknown quota-root name is distinct and receives tagged `NO`. +Cyrus requires an existing mailbox, which is a valid narrower server policy +but not the provisioning behavior selected for Bongo. + +The reusable live test preserved 987654 bytes of existing test mail, +temporarily applied a deliberately unaligned 989705-byte limit, and observed +965/967 STORAGE units. It checked existing and prospective roots, unknown +roots, missing and surplus arguments, non-advertisement and rejection of +`SETQUOTA`, continued session usability, and the unlimited state. Unlimited +accounts expose no administrative root or synthetic zero limit. The original +1048576-byte limit was restored in a `finally` path: + +```text +IMAP-21 PASS capability=QUOTA/RES-STORAGE/no-QUOTASET storage=965/967KiB roots=existing/prospective unknown-root=NO unlimited=no-root malformed=BAD session=reusable quota-restored=yes +``` + +The IMAP selected-state and UTF-8 live regressions passed again. The complete +isolated Dovecot ImapTest corpus remained green: + +```text +35 test groups: 0 failed, 0 skipped due to missing capabilities +base protocol: 0/439 individual commands failed +extensions: 0/88 individual commands failed +PASS: Dovecot ImapTest imap-01-14 suite (35 scripted test groups, isolated) +``` + +The normal and strict `-Wall -Wextra -Werror` builds each passed all 108 CTest +cases, including the native signed-63-bit Store-response parser and STORAGE +rounding boundary test. + ## IMAP retrospective source audit Result: **PASS for IMAP-01 through IMAP-08**