Verify RFC 9208 IMAP quota queries

This commit is contained in:
Mario Fetka
2026-07-30 02:41:04 +02:00
parent b88ce2a634
commit 56cfee5e44
5 changed files with 342 additions and 2 deletions
+16
View File
@@ -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
+274
View File
@@ -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)
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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 | | | |
+46
View File
@@ -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**