Verify RFC 9208 IMAP quota queries
This commit is contained in:
@@ -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
|
||||
|
||||
Executable
+274
@@ -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
@@ -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
|
||||
|
||||
@@ -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 | | | |
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
Reference in New Issue
Block a user