commit - 2a6467d3157cdcdc9f6a69080095e37a528abd2c
commit + 8b6932843b87183548532dae81ebad384663950a
blob - cc65af9f3e5ce618d06b770df9b41cf25029c780
blob + a048b0269361225d8c0318f6c477b70062f00f9b
--- README.md
+++ README.md
## What it is
-- **Privilege-separated**, `smtpd`-style, across six processes: a root *parent* reads configuration and binds the listening sockets; unprivileged *listener* and *auth* children handle the network and credential checks; *keymgr* holds the TLS private key and performs every private-key operation on request, so the process terminating TLS never has the key in its address space; a per-connection *search-oracle* parses the `SEARCH` grammar — the largest attacker-reachable parser in the daemon — in a process with no descriptors and no filesystem; and a *store* child is forked per authenticated session, chroots into the mail spool, and drops privileges to that session's own user before ever touching a message. `pledge(2)`, `unveil(2)`, and `chroot(2)` enforce these boundaries, not just convention: *parent* is the only process that can pass a file descriptor at all, and *search-oracle* runs on bare `stdio`.
+- **Privilege-separated**, `smtpd`-style, across six processes: a root *parent* reads configuration and binds the listening sockets; unprivileged *listener* and *auth* children handle the network and credential checks; *keymgr* holds the TLS private key and performs every private-key operation on request, so the process terminating TLS never has the key in its address space; a per-connection *search-oracle* parses the `SEARCH` grammar, the largest attacker-reachable parser in the daemon — in a process with no descriptors and no filesystem; and a *store* child is forked per authenticated session, chroots into the mail spool, and drops privileges to that session's own user before ever touching a message. `pledge(2)`, `unveil(2)`, and `chroot(2)` enforce these boundaries, not just convention: *parent* is the only process that can pass a file descriptor at all, and *search-oracle* runs on bare `stdio`.
- **Storage**: stock maildir format (`tmp/`/`new/`/`cur/`, atomic delivery via `rename(2)`), readable with `ls` and `grep`, and natively understood by `smtpd(8)`'s own `maildir` delivery action. IMAP's extra bookkeeping (UIDs, UIDVALIDITY, per-message mod-sequences, keywords) lives in a small, `flock(2)`-guarded, line-oriented index file per mailbox, plain colon-delimited text, not a database.
- **Transport**: STARTTLS on port 143 and implicit TLS on port 993 ([RFC 8314](https://www.rfc-editor.org/rfc/rfc8314)), via `libtls`. `AUTH=PLAIN` only, refused before TLS is established.
`SUBSCRIBE`, `UNSUBSCRIBE`, and ACL/shared-mailbox support are deliberately left out.
-**`IDLE` is a poll, not a kernel-driven push.** An `IDLE`ing session rechecks its selected mailbox every `idle poll` seconds (default 5, see `imapd.conf`), so new mail — whether delivered by an external MTA or by another IMAP session — is reported within one interval rather than instantly. Most polls are two `stat(2)` calls and no lock: the store child only re-reads the index and streams UIDs when the mailbox directory or `new/` has actually been touched. Setting `idle poll 0` disables polling entirely, which restores the earlier behaviour where an `IDLE`ing session saw nothing until it sent `DONE`.
+**`IDLE` is a poll, not a kernel-driven push.** An `IDLE`ing session rechecks its selected mailbox every `idle poll` seconds (default 5, see `imapd.conf`), so new mail, whether delivered by an external MTA or by another IMAP session, is reported within one interval rather than instantly. Most polls are two `stat(2)` calls and no lock: the store child only re-reads the index and streams UIDs when the mailbox directory or `new/` has actually been touched. Setting `idle poll 0` disables polling entirely, which restores the earlier behaviour where an `IDLE`ing session saw nothing until it sent `DONE`.
## Requirements
-OpenBSD only. This depends on `<imsg.h>`, `pledge(2)`, `unveil(2)`, and libutil's `imsgbuf_*` API, none of which exist outside OpenBSD. Developed and tested against OpenBSD 8.0. Links against libevent, libtls/libssl/libcrypto, and libutil, all base-system libraries (see `src/Makefile`).
+OpenBSD only. This depends on `<imsg.h>`, `pledge(2)`, `unveil(2)`, and libutil's `imsgbuf_*` API, none of which exist outside OpenBSD. Links against libevent, libtls/libssl/libcrypto, and libutil, all base-system libraries (see `src/Makefile`).
-`keymgr`, the process that isolates the TLS private key from `listener`, additionally depends on `tls_config_use_fake_private_key()` and undocumented ex_data-tagging behavior inside `tls_keypair_load()` — both unexported libtls/LibreSSL internals with no compatibility promise, and neither declared in libtls's public, installed `tls.h`.
+**imapd requires OpenBSD -current, and will not build on 7.9.**. Building on the most recent stable release is a goal for 1.0.
+`keymgr`, the process that isolates the TLS private key from `listener`, additionally depends on `tls_config_use_fake_private_key()` and undocumented ex_data-tagging behavior inside `tls_keypair_load()`, both unexported libtls/LibreSSL internals with no compatibility promise, and neither declared in libtls's public, installed `tls.h`.
+
## Building and installing
```
## Creating an account
-imapd's users aren't real system accounts — `imapduser(8)` manages a bespoke credentials file (`username:passwordhash:uid:gid:maildir`, bcrypt via `crypt_checkpass(3)`) and the matching maildir ownership together, since no combination of `useradd(8)`/`userdel(8)` can safely keep both in sync:
+imapd's users aren't real system accounts, `imapduser(8)` manages a bespoke credentials file (`username:passwordhash:uid:gid:maildir`, bcrypt via `crypt_checkpass(3)`) and the matching maildir ownership together, since no combination of `useradd(8)`/`userdel(8)` can safely keep both in sync:
```
doas imapduser -a someuser
- If the listener or auth process exits unexpectedly after startup, it is not automatically restarted. Recovery is `rcctl restart imapd`. See `imapd(8)`.
-`SIGHUP` reloads `spool`, `attachment max`, and the TLS certificate/key without dropping connected sessions, `listen on` and `credentials` changes still require a restart. See `imapd(8)`.
+`SIGHUP` reloads `spool`, `attachment max`, `idle poll`, `startups`, and the TLS certificate/key without dropping connected sessions, `listen on` and `credentials` changes still require a restart. See `imapd(8)`.
-IPv6 is supported (`listen on ::` or `listen on *` for dual-stack) but not the default — see `imapd(8)`'s `listen on` directive.
+IPv6 is supported (`listen on ::` or `listen on *` for dual-stack) but not the default, see `imapd(8)`'s `listen on` directive.
## Getting the source
blob - 83844217b4c7c6b04ec517451e215b394640e175
blob + 269c1b36d07fd286e1773f1a58763b2b5e5182c3
--- contrib/imapduser.8
+++ contrib/imapduser.8
.\"
.\" Written for the OpenIMAPD project. Public domain / no rights reserved.
.\"
-.Dd $Mdocdate: September 6 2026 $
+.Dd $Mdocdate: September 9 2026 $
.Dt IMAPDUSER 8
.Os
.Sh NAME
blob - 1cc25d6cde58222be9921a9f95c5ee229e4510a8
blob + d2d44336dd52fce32311a046d5ceb0a96265e1fc
--- src/Makefile
+++ src/Makefile
PROG= imapd
-SRCS= main.c parent.c log.c imsgev.c parse.y utf8.c \
+SRCS= main.c parent.c log.c imsgev.c parse.y utf8.c mboxname.c \
listener.c auth_cmd.c mailbox_cmd.c append_cmd.c fetch_cmd.c \
search_cmd.c store_cmd.c store_ipc.c \
auth.c \
blob - 0c2a64146c0476e8b02ed9b60b02b6b2ca467835
blob + 79a3db86c5d124e3e3840c6d57cf4566a6586c13
--- src/append_cmd.c
+++ src/append_cmd.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * append_cmd.c, APPEND: literal-driven message upload, and its
- * asynchronous IMSG_MBOX_APPENDED completion handling.
- */
+/* append_cmd.c: APPEND literal-driven message upload and its async IMSG_MBOX_APPENDED completion handling. */
#include <sys/types.h>
#include <sys/queue.h>
#include "imapd.h"
#include "log.h"
#include "listener.h"
+#include "mboxname.h"
/* RFC 9051 SS9 date-time via sscanf(3); calendar validity (e.g. Feb 31) unchecked, timegm(3) normalizes it. */
int
if (zsign == '-')
zoff = -zoff;
- /*
- * The year is already required to be 1970 or later, but nothing bounds
- * the zone -- sscanf's %c%2d%2d accepts up to +9999, and RFC 9051 SS9's
- * "zone = ("+" / "-") 4DIGIT" does not constrain the value either. So
- * "01-Jan-1970 00:00:00 +9959" resolves to -359940, and
- * handle_mbox_append() formats the delivery timestamp straight into the
- * maildir basename -- producing a file called "-359940.pid_n.host" in a
- * maildir the README advertises as readable with ls(1) and grep(1),
- * where any shell glob over cur/ hands that name to rm(1) as an
- * option rather than a file. Out of the intended range, so refuse it.
- */
+ /* Reject out-of-range zone offsets -- sscanf/RFC 9051 don't bound them, and an extreme offset can produce a maildir basename unsafe for shell globs. */
if ((int64_t)t - zoff < 0)
return (-1);
memcpy(digitsbuf, start, digits_len);
digitsbuf[digits_len] = '\0';
- /*
- * RFC 9051 SS9: literal = "{" number64 ["+"] "}", and
- * number64 = 1*DIGIT -- no sign. strtoull(3) accepts one, so
- * "{-1}" would arrive as ULLONG_MAX with errno untouched and
- * be answered NO [LIMIT] "message too large" by cmd_append()
- * rather than the BAD a syntax error deserves. Same
- * first-character-is-a-digit guard listener.c's own literal
- * pre-scan already applies to the same announcement.
- */
+ /* RFC 9051 SS9's number64 is unsigned, but strtoull(3) accepts a sign, so "{-1}" would arrive as ULLONG_MAX and get a misleading NO [LIMIT] instead of BAD -- same digit guard listener.c's literal pre-scan already applies. */
if (digitsbuf[0] < '0' || digitsbuf[0] > '9') {
*errmsg = "malformed literal octet count";
return (-1);
s->literal_remaining = parsed.litlen;
s->literal_pending = 1;
- /*
- * RFC 9051 SS4.3: only synchronizing literals need a "+" continuation;
- * harmless but misleading to send for non-sync.
- *
- * sizeof() - 1, not a hand-counted constant: this line used to pass 27
- * for a 26-byte string, which wrote the string literal's own NUL
- * terminator onto the wire ahead of the tagged APPEND response. NUL is
- * not a legal octet in the IMAP stream; clients mostly tolerate it,
- * which is why it went unnoticed. Same idiom as listener.c's own
- * session_write(s, bad, sizeof(bad) - 1) call sites.
- */
+ /* RFC 9051 SS4.3: "+" continuation is only for synchronizing literals. Uses sizeof()-1, not a hand count -- a wrong hand-counted length once wrote a stray NUL onto the wire, same idiom as listener.c's session_write() calls. */
if (!parsed.litnonsync) {
static const char cont[] = "+ Ready for literal data\r\n";
s->state = SESSION_APPENDING;
- /*
- * Same fail-soft shape send_mbox_request() now uses: a compose failure
- * would otherwise leave s->state at SESSION_APPENDING with nothing in
- * flight to move it back, and session_is_busy() then blocks every
- * further command while the client waits for a tagged reply.
- */
+ /* Same fail-soft shape as send_mbox_request(): without it, a compose failure leaves s->state stuck at SESSION_APPENDING and session_is_busy() blocks every further command. */
if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_APPEND, 0, 0, -1,
combined, combined_len) == -1) {
log_warn("session %u: imsg_compose IMSG_MBOX_APPEND", s->id);
}
/* INBOX compared case-insensitively (SS5.1); any other mailbox name case-sensitively. */
- if (listener_mailbox_name_is_inbox(s->append_mailbox) &&
- listener_mailbox_name_is_inbox(s->selected_mailbox))
+ if (mailbox_name_is_inbox(s->append_mailbox) &&
+ mailbox_name_is_inbox(s->selected_mailbox))
appended_to_selected = 1;
else
appended_to_selected =
blob - 6e913abfb622ccd27527ba535ef21fdd7468da89
blob + 27fa92b6d7ede434293de8142b8d3afcc477c6c6
--- src/auth.c
+++ src/auth.c
static void auth_dispatch(int, short, void *);
static void auth_dispatch_parent(int, short, void *);
-/*
- * SS6.2's retrofit: does a second IMSG_AUTH_REQUEST for a session_id
- * auth already resolved successfully get treated as a fresh login
- * attempt, or refused? A correctly-behaving listener never sends one
- * -- AUTHENTICATE is only offered from SESSION_NOT_AUTH (listener.c's
- * command table), and a session leaves that state for good the moment
- * auth grants it -- so this is pure defense against a compromised or
- * buggy listener replaying (or fabricating a duplicate of) a grant it
- * already received, mirroring keymgr.c's SS6.1 keymgr_got_init gate:
- * explicit and checkable, not merely "the code that could send this
- * doesn't exist yet."
- *
- * A fixed-size ring, not a TAILQ: auth is never told when a session
- * ends (listener/store don't notify it -- only parent's
- * store_children TAILQ has session lifecycle visibility, over a
- * different channel), so there is no event to free an entry on. The
- * alternative is unbounded growth; a ring just ages the oldest entry
- * out instead. That's safe, not merely convenient: session_id is
- * parent.c's monotonically increasing next_session_id (spawn_
- * connection()), minted once per accepted connection and never
- * reused for the life of the daemon -- SS7 moved this counter, and
- * the accept() loop that used to feed it, from listener.c to
- * parent.c (see parent.c's own header comment). SS7 also makes
- * reuse-within-one-process a stronger guarantee than that alone:
- * each auth-worker is now spawned fresh per connection and paired
- * with exactly one listener-worker for its own whole (short) life
- * (parent.c's spawn_connection() again), so a single auth-worker
- * can structurally never observe more than one distinct session_id
- * in the first place. Kept as a ring rather than a single slot
- * anyway, so this file doesn't need to change again if that ever
- * stops being true. Sized well above parent.c's
- * STORE_CHILD_MAX (64 concurrent sessions) so eviction shouldn't
- * happen at any realistic session volume.
- */
-/*
- * Cap on failed authentication attempts this auth-worker will spend a
- * bcrypt on, i.e. sshd_config(5)'s MaxAuthTries, whose own default this
- * matches.
- *
- * A plain static counter IS a per-connection counter here: SS7 spawns one
- * auth-worker per connection, paired with exactly one listener-worker for
- * its whole (short) life (parent.c's spawn_connection()) -- the same fact
- * the auth_resolved comment above relies on.
- *
- * Why it is needed: listener.c returns a failed session to
- * SESSION_NOT_AUTH and AUTHENTICATE is ST_NOTAUTH, so a client may retry
- * without limit. Each retry costs this process one bcrypt -- ~100ms of
- * CPU, deliberately -- and costs the client one small packet on a
- * connection it already holds. That asymmetry is backwards: bcrypt's work
- * factor is meant to be paid by whoever is guessing, and without a cap an
- * attacker converts cheap packets into unbounded server CPU across as
- * many connections as MaxStartups allows.
- *
- * Past the cap, requests are refused WITHOUT calling crypt_checkpass(3),
- * which is what removes the cost. The connection is not dropped -- doing
- * that needs a listener-side change and a way to say so on the wire; the
- * CPU asymmetry, which is the actual damage, is closed either way.
- */
+/* SS6.2: refuses a second IMSG_AUTH_REQUEST for an already-resolved session_id, defending against a compromised/buggy listener replaying a grant; tracked in a fixed-size ring (not a TAILQ) since auth is never notified of session end, and session_id is unique-per-daemon so an evicted entry is harmless. */
+/* Caps bcrypt-costing auth attempts per connection (sshd's MaxAuthTries default) so a client retrying without limit can't convert cheap packets into unbounded server CPU; past the cap, requests are refused without calling crypt_checkpass(3), closing the cost asymmetry (though not dropping the connection). */
#define AUTH_MAX_TRIES 6
static unsigned int auth_failures;
imsg_get_type(&imsg));
if (imsg_get_data(&imsg, &init, sizeof(init)) == -1)
fatalx("auth: bad IMSG_AUTH_INIT payload");
- /*
- * imsg_get_data() guarantees size, not NUL termination, force it --
- * same rule as req.username/req.password below, and parent.c's own
- * inbound maildir. strlcpy(3) reads its source to the NUL to compute
- * its return value, so an unterminated field here would be an
- * unbounded read past this stack struct, on the message that decides
- * what directory this process chroot(2)s into.
- */
+ /* imsg_get_data() guarantees size, not NUL termination -- force it, since strlcpy(3) would otherwise read unboundedly past this stack struct while computing the chroot(2) target. */
init.cred_file[sizeof(init.cred_file) - 1] = '\0';
imsg_free(&imsg);
if (strlcpy(chrootdir, init.cred_file, sizeof(chrootdir)) >=
sizeof(chrootdir))
fatalx("cred_file too long: %s", init.cred_file);
- /*
- * Actually test what the message below claims. strrchr() finding a
- * '/' only means the path HAS a directory component: "etc/creds"
- * would pass and then chroot(2) relative to whatever working
- * directory rc.d(8) left this daemon in. parse.y only length-checks
- * the "credentials" directive, so nothing upstream enforces this.
- */
+ /* Actually verifies what the fatalx() below claims: strrchr() finding '/' only proves a directory component exists, not an absolute path (e.g. "etc/creds" would otherwise chroot(2) relative to cwd), and parse.y doesn't enforce this upstream. */
if (init.cred_file[0] != '/')
fatalx("cred_file must be an absolute path: %s",
init.cred_file);
setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1)
fatal("cannot drop privileges to _imapauth");
- /*
- * SS7: auth-worker's one and only peer, wired by parent.c's
- * spawn_connection() via setup_peer_send() the moment both it
- * and the listener-worker it's paired with exist. No
- * IMSG_SETUP_DONE ack round-trip -- see parent.c's header
- * comment for why spawn_connection() doesn't use one for
- * per-connection wiring; this boot sequence already reads a
- * fixed, statically known set of messages (just IMSG_AUTH_INIT
- * above, then this) before ever touching the event loop,
- * regardless of any ack.
- */
+ /* SS7: wires auth-worker's one and only peer via parent.c's spawn_connection()/setup_peer_send(), with no IMSG_SETUP_DONE ack needed since this boot sequence already reads a fixed, statically-known message set before touching the event loop. */
peer_fd = setup_recv_one_peer(&ibuf3);
event_init();
fatal("unveil lock");
}
- /*
- * No recvfd, no sendfd. This process receives exactly one descriptor
- * in its life -- the peer fd from setup_recv_one_peer() above, which
- * has already arrived by the time this line runs -- and it never
- * sends one: the parent is the only process in the tree that attaches
- * a descriptor to an imsg (parent.c's setup_peer_send(),
- * setup_search_peer_send() and IMSG_LISTENER_SESSION_INIT are the
- * only five such call sites).
- *
- * An earlier version of this comment kept both promises on the theory
- * that an imsgbuf_allow_fdpass() channel uses sendmsg(2)/recvmsg(2)
- * for all of its traffic. It does -- but that is not what the two
- * promises gate. SYS_sendmsg and SYS_recvmsg are PLEDGE_STDIO
- * (sys/kern/kern_pledge.c); "sendfd"/"recvfd" are checked in
- * unp_internalize()/unp_externalize() (sys/kern/uipc_usrreq.c), which
- * the kernel reaches only when SCM_RIGHTS is actually attached to the
- * message. A plain imsg with fd == -1 needs neither.
- *
- * If that reasoning is wrong, pledge(2) does not degrade: a violation
- * is an uncatchable SIGABRT with a core dump, and this line reverts.
- */
+ /* No recvfd, no sendfd: this process gets its one peer fd via setup_recv_one_peer() before this line and never attaches a descriptor to an imsg itself (only parent.c does); a plain imsg with fd == -1 needs neither pledge promise, and a wrong guess here is an uncatchable SIGABRT, not silent breakage. */
#ifdef __OpenBSD__
if (pledge("stdio rpath", NULL) == -1)
fatal("pledge");
if ((n = imsgbuf_read(&iev->ibuf)) == -1)
fatal("imsgbuf_read");
if (n == 0) {
- /*
- * SS7: this process was spawned (parent.c's
- * spawn_connection()) to serve exactly this one
- * connection's listener-worker and will never serve
- * another -- exit now rather than sit in
- * event_dispatch() forever with nothing left to do.
- * parent.c's reap_child() already documents this
- * exact expectation ("left to notice its own peer
- * channel EOF and exit on its own") and already
- * treats an auth-worker exit as the ordinary,
- * expected end of a session, not something to warn
- * about. Matches store.c's store_shutdown() for the
- * same reason on that per-session worker.
- */
+ /* SS7: this auth-worker was spawned to serve exactly one connection and will never serve another, so it exits here on listener EOF rather than idling in event_dispatch() forever -- the ordinary, expected end of a session per parent.c's reap_child(). */
log_debug("auth-worker: listener closed channel, "
"exiting");
exit(0);
cred.session_id = res.session_id;
cred.uid = res.uid;
cred.gid = res.gid;
- /* cred.maildir and res.maildir are both sized
- * AUTH_MAILDIR_MAX, truncation is structurally
- * impossible, so the return value is discarded
- * deliberately, same as imsg_store_init's own
- * maildir field elsewhere. */
+ /* cred.maildir and res.maildir are both sized AUTH_MAILDIR_MAX, so truncation is structurally impossible and the strlcpy() return value is discarded deliberately. */
(void)strlcpy(cred.maildir, res.maildir,
sizeof(cred.maildir));
if (imsg_compose(&iev_parent.ibuf,
(void)fd;
}
-/*
- * Usernames come off the network, so they reach syslog only through this:
- * anything outside printable ASCII becomes '?'. listener.c is meant to
- * reject a bare CR/LF in a command line, but auth is a separate process and
- * does not get to assume that held.
- */
+/* Usernames come off the network and reach syslog only through this: anything outside printable ASCII becomes '?', since auth can't assume listener.c's CR/LF rejection held. */
static void
auth_safe_name(const char *in, char *out, size_t outsize)
{
int found;
char safename[AUTH_USERNAME_MAX];
- /*
- * Budget spent: refuse without spending a bcrypt. Deliberately
- * before cred_lookup(), so a client past the cap cannot even make
- * this process re-read and re-scan the credential file. Logged and
- * reported exactly like any other failure -- the client learns
- * nothing it did not already know.
- */
+ /* Budget spent: refuses before cred_lookup() so a client past AUTH_MAX_TRIES can't even trigger a re-read/re-scan of the credential file; reported identically to any other failure. */
if (auth_failures >= AUTH_MAX_TRIES) {
char overname[AUTH_USERNAME_MAX];
auth_failures++;
}
- /*
- * Nothing used to record an authentication outcome at all, so a
- * password-guessing run left no trace in the logs and there was
- * nothing for pf(4)/fail2ban-style tooling to key on. log_info() is
- * not gated on verbosity (see log.c), so these are always emitted.
- * The failure line deliberately does not distinguish "no such user"
- * from "wrong password" -- that would hand back the same enumeration
- * oracle crypt_checkpass(NULL) exists to close.
- */
+ /* Logs every authentication outcome (previously nothing did, leaving password-guessing runs untraceable for fail2ban-style tooling); log_info() is always emitted, and the failure line deliberately doesn't distinguish "no such user" from "wrong password" to avoid an enumeration oracle. */
auth_safe_name(req->username, safename, sizeof(safename));
if (res->ok)
log_info("session %u: authentication succeeded for \"%s\" "
explicit_bzero(&ce, sizeof(ce));
}
+/* True if a privileged file at st is safe to trust here: owned by root or the current (post-chroot, post-setresuid) uid, and not group-writable, group-executable, or accessible to world at all; same policy parse.y's check_file_secrecy() applies to imapd.conf (parse.y:678-695), kept as its own function since that one runs pre-privsep against an fd the parent still owns and logs a different message. */
+static int
+cred_file_secure(const struct stat *st)
+{
+ if (st->st_uid != 0 && st->st_uid != getuid())
+ return (0);
+ if (st->st_mode & (S_IWGRP | S_IXGRP | S_IRWXO))
+ return (0);
+ return (1);
+}
+
/* linear scan of "username:passwordhash:uid:gid:maildir" lines */
static int
cred_lookup(const char *path, const char *username, struct cred_entry *out)
return (-1);
}
- /*
- * parent.c refuses to load the TLS private key unless it is
- * root-owned and no looser than 0740; this file holds every user's
- * bcrypt hash and had no such check. A flat text file people edit
- * by hand very easily ends up 0644, at which point any local user
- * can take the hashes away and attack them offline.
- *
- * Deliberately permissive about ownership and group-read, so the
- * usual "root:_imapauth 0640" and "_imapauth 0400" layouts both
- * pass; only world access and group-write are refused. Fails
- * closed: a credential store with the wrong mode stops logins
- * rather than serving them, and says so loudly.
- */
+ /* Rejects a credentials file not owned by root or the current uid, or that is group-writable, group-executable, or accessible to world at all (parent.c already enforces an equivalent policy for the TLS key, and parse.y's check_file_secrecy() for imapd.conf itself; this file holding every bcrypt hash had no such check until this one); group-read stays permissive so the documented "root:_imapauth 0640" layout keeps working, and a bad mode or owner fails closed. */
{
struct stat st;
fclose(fp);
return (-1);
}
- if (st.st_mode & (S_IROTH | S_IWOTH | S_IXOTH | S_IWGRP)) {
- log_warnx("%s: insecure permissions (mode %04o), must "
- "not be world-accessible or group-writable; "
- "refusing all authentication until this is fixed",
- path, (unsigned)(st.st_mode & 07777));
+ if (!cred_file_secure(&st)) {
+ log_warnx("%s: insecure (mode %04o, owner uid %u), "
+ "must be owned by root or the current user and "
+ "not group-writable, group-executable, or "
+ "world-accessible; refusing all authentication "
+ "until this is fixed",
+ path, (unsigned)(st.st_mode & 07777),
+ (unsigned)st.st_uid);
fclose(fp);
return (-1);
}
char *ep;
unsigned long ulval;
- /*
- * fgets(3) splits an over-long line, and the tail would then be
- * parsed as its own entry -- a phantom credential rather than an
- * error. Refuse to read the file at all rather than guess.
- */
+ /* fgets(3) silently splits an over-long line, which would otherwise parse the tail as a phantom credential entry -- refuses to read the whole file rather than guess. */
if (strchr(line, '\n') == NULL &&
strlen(line) == sizeof(line) - 1) {
log_warnx("%s: over-long line, refusing to parse the "
strlcpy(out->passwordhash, fields[1],
sizeof(out->passwordhash)) >= sizeof(out->passwordhash))
continue;
- /*
- * crypt_checkpass(3) returns SUCCESS when the stored hash
- * and the supplied password are both empty
- * (lib/libc/crypt/cryptutil.c's "empty password" case), so a
- * blank second field is not a disabled account -- it is a
- * login with an empty password. Require a bcrypt hash, for
- * the same reason as the uid/gid checks below: the
- * credential file should not be able to express this in the
- * first place.
- *
- * Only the empty case needs catching. Any other non-bcrypt
- * value ("!", "*", a legacy crypt string, garbage) already
- * falls through crypt_checkpass()'s own "$2" test to its
- * fake: label, which burns a bcrypt for timing and fails.
- *
- * "continue" rather than a distinct error, matching every
- * other malformed-entry case here: the client must not be
- * able to tell this apart from "no such user", or the
- * enumeration oracle crypt_checkpass(NULL) exists to close
- * comes back through the error text.
- *
- * To disable an account, use imapduser -d, which removes the
- * line.
- */
+ /* Requires a bcrypt hash ("$2" prefix): crypt_checkpass(3) treats an empty stored hash plus empty password as a successful login rather than a disabled account, so a blank field must be rejected here, and non-bcrypt values are skipped the same way as every other malformed entry to avoid an enumeration oracle -- use imapduser -d to disable an account instead. */
if (fields[1][0] != '$' || fields[1][1] != '2')
continue;
- /*
- * strtoul(3) accepts a leading "-", so "-1" would arrive here as
- * 0xffffffff, and "0" is root. parent.c refuses uid/gid 0 at the
- * spawn boundary, which is the check that matters -- but the
- * credential file should not be able to express either in the
- * first place, and the wrap case is caught nowhere else.
- */
+ /* strtoul(3) accepts a leading '-', so "-1" would parse as 0xffffffff (and "0" is root); the credential file shouldn't be able to express either uid/gid, so both are rejected here before the wrap case slips through unnoticed. */
if (fields[2][0] < '0' || fields[2][0] > '9' ||
fields[3][0] < '0' || fields[3][0] > '9')
continue;
break;
}
- /*
- * line[] held the raw credential record -- username, bcrypt hash,
- * uid, gid, maildir -- for every entry scanned. auth_verify()
- * scrubs its struct cred_entry and auth_dispatch() scrubs the
- * password; this buffer was the one left behind.
- */
+ /* line[] held the raw credential record (username, bcrypt hash, uid, gid, maildir) for every entry scanned; auth_verify() and auth_dispatch() scrub their own copies, so this buffer is the one left behind. */
explicit_bzero(line, sizeof(line));
fclose(fp);
return (found ? 0 : -1);
blob - a83bf2678a4738a483467710477fd2c2d2e0eb29
blob + 86be637642814c5966c967710f302bf7574884b3
--- src/auth_cmd.c
+++ src/auth_cmd.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * auth_cmd.c, CAPABILITY/NOOP/LOGOUT/ID/LOGIN/STARTTLS/
- * AUTHENTICATE/ENABLE: command-any and command-nonauth handlers that
- * don't need an established mailbox session.
- */
+/* auth_cmd.c: CAPABILITY/NOOP/LOGOUT/ID/LOGIN/STARTTLS/AUTHENTICATE/ENABLE handlers that don't need a mailbox session. */
#include <sys/types.h>
#include <sys/queue.h>
#include "log.h"
#include "listener.h"
-/*
- * RFC 9051 SS6.1.1 capability strings, selected by session->tls_active.
- */
+/* RFC 9051 SS6.1.1 capability strings, selected by session->tls_active. */
#define CAPABILITY_PRE_TLS "IMAP4rev2 STARTTLS LOGINDISABLED ID CONDSTORE QRESYNC"
#define CAPABILITY_POST_TLS "IMAP4rev2 AUTH=PLAIN LOGINDISABLED ID CONDSTORE QRESYNC"
}
-/*
- * LOGIN is permanently disabled, matching LOGINDISABLED in both
- * CAPABILITY strings above.
- */
+/* LOGIN is permanently disabled, matching LOGINDISABLED in both CAPABILITY strings above. */
int
cmd_login(struct session *s, const char *tag, char *args)
{
session_reply(s, tag, "NO", "[SERVERBUG] internal error");
return (1);
}
- /*
- * SS7: this connection's auth-worker may not exist -- parent.c's
- * spawn_connection() tolerates that fork failing independently
- * of the listener-worker's own. listener_main()'s boot-drain
- * loop leaves iev_auth.ibuf.fd at -1 in that case rather than
- * wiring it to a real peer (see listener.c's globals-block
- * comment on iev_auth). Fail gracefully instead of composing to
- * an unwired imsgev.
- */
+ /* SS7: the auth-worker may not exist if its fork failed (parent.c), leaving iev_auth.ibuf.fd at -1 -- fail gracefully rather than compose to an unwired imsgev. */
if (iev_auth.ibuf.fd == -1) {
session_reply(s, tag, "NO", "[UNAVAILABLE] authentication "
"temporarily unavailable");
}
if (initial != NULL) { /* RFC 9051 SS6.2.2 initial-resp: finishes in one round trip */
- /* `initial` is the base64 of the cleartext password and points
- * into s->inbuf; have the reader scrub it once consumed. */
+ /* `initial` is the base64 cleartext password and points into s->inbuf; have the reader scrub it once consumed. */
s->scrub_inbuf = 1;
return sasl_plain_finish(s, tag, initial, 1);
}
if (newly_condstore || newly_qresync)
session_condstore_enable(s);
- /*
- * buf[64] can never truncate here: the only strings ever appended
- * are these two fixed literals plus one separator space, 18 bytes
- * total in the worst case ("QRESYNC CONDSTORE").
- */
+ /* buf[64] can never truncate here: worst case is two fixed literals plus a space, 18 bytes ("QRESYNC CONDSTORE"). */
buf[0] = '\0';
if (newly_qresync)
(void)strlcat(buf, "QRESYNC", sizeof(buf));
blob - 4ece4d36298f7a478bc532382f97e19b0132e581
blob + f9228ac75a7d99082afc200f02579cf5847e1b7a
--- src/envelope.c
+++ src/envelope.c
envbuf_append(char *buf, size_t bufsize, size_t *outlen, const char *data,
size_t datalen)
{
- /* subtract rather than add: "*outlen + datalen" wraps if datalen is
- * ever close to SIZE_MAX, and the check would then pass */
+ /* Subtract rather than add -- "*outlen + datalen" could wrap near SIZE_MAX and falsely pass the check. */
if (*outlen > bufsize || datalen > bufsize - *outlen)
return (-1);
memcpy(buf + *outlen, data, datalen);
for (i = 0; i < vallen; i++) {
char c = val[i];
- /* RFC 9051 SS4.3: a quoted string is TEXT-CHAR only, which
- * excludes NUL, CR and LF; substitute rather than reject so
- * one odd byte in a header doesn't drop the whole field */
+ /* RFC 9051 SS4.3 quoted strings exclude NUL/CR/LF; substitute rather than reject so one bad byte doesn't drop the whole field. */
if (c == '\0' || c == '\r' || c == '\n')
c = ' ';
if ((c == '"' || c == '\\') &&
return (0);
fail:
- /* all-or-nothing: a partial append leaves an unterminated quoted
- * string in the caller's buffer */
+ /* All-or-nothing: a partial append would leave an unterminated quoted string in the caller's buffer. */
*outlen = save;
return (-1);
}
if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, ")", 1) == -1)
return (-1);
- /* one atomic append: either the whole "(...)" tuple lands in the
- * caller's buffer or none of it does (envbuf_append() leaves
- * *outlen untouched on failure) */
+ /* One atomic append: the whole "(...)" tuple lands or none of it does, since envbuf_append() leaves *outlen untouched on failure. */
return (envbuf_append(buf, bufsize, outlen, addrbuf, addrlen));
}
tok_len--;
if (tok_len > 0) {
- /* a malformed or non-fitting address leaves buf/outlen
- * untouched -- envbuf_append_one_address() builds the
- * whole "(...)" tuple locally before its one atomic
- * append into buf -- so skipping it and continuing is
- * safe */
+ /* Safe to skip a malformed or non-fitting address and continue: envbuf_append_one_address() builds the tuple locally before one atomic append, leaving buf/outlen untouched on failure. */
if (envbuf_append_one_address(buf, bufsize, outlen,
val + tok_start, tok_len) == 0)
any = 1;
blob - 3d9bc13cb2e5fed06610f3907760529a6f0c3fce
blob + 204ba724b7978cb4705e7709d340b52914883ba1
--- src/fetch_cmd.c
+++ src/fetch_cmd.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * fetch_cmd.c, FETCH: attribute/section-spec parsing and
- * response building.
- */
+/* FETCH: attribute/section-spec parsing and response building. */
#include <sys/types.h>
#include <sys/queue.h>
return (0);
}
-/*
- * Parses one range token -- no ':'-split ambiguity beyond the existing
- * single colon, no comma -- into r. This used to be parse_seq_range()'s
- * entire body, factored out so parse_sequence_set() below can reuse it
- * once per comma-separated segment instead of duplicating it (parse_
- * seq_range() itself is gone now -- QRESYNC known-uids, its last
- * caller, moved to parse_sequence_set() directly; see mailbox_cmd.c).
- */
+/* Parses one range token (single optional colon, no comma) into r; factored out of the old parse_seq_range() so parse_sequence_set() can reuse it per comma-separated segment. */
static int
parse_one_seq_range(const char *tok, struct seq_range *r)
{
return (0);
}
-/*
- * RFC 9051 SS9 sequence-set: (seq-number/seq-range) *("," seq-number/
- * seq-range) -- what every parse_seq_range() call site used to reject a
- * comma for, rather than actually parse. Splits text on top-level commas
- * (no nesting or quoting in this grammar, so a plain scan is exact) and
- * parses each segment with parse_one_seq_range() above. Writes up to
- * SEQSET_MAX_RANGES entries to ranges[] and the count to *nranges;
- * returns -1 (with *errmsg set) if any segment is malformed, empty, or
- * there are more segments than SEQSET_MAX_RANGES allows.
- */
+/* Parses an RFC 9051 SS9 sequence-set (comma-separated seq-number/seq-range) by splitting on top-level commas and parsing each with parse_one_seq_range(), writing up to SEQSET_MAX_RANGES entries to ranges[] and the count to *nranges, or returning -1 with *errmsg set on a malformed, empty, or excess segment. */
int
parse_sequence_set(const char *text, struct seq_range ranges[SEQSET_MAX_RANGES],
uint32_t *nranges, const char **errmsg)
"mod-sequence value";
return (-1);
}
- /*
- * RFC 7162 SS7: chgsince-fetch-mod takes a
- * mod-sequence-value, "1*DIGIT ... (1 <= n <=
- * 9,223,372,036,854,775,807)". strtoull(3) accepts a
- * leading sign, so "-1" would otherwise arrive as
- * ULLONG_MAX with errno untouched and match nothing,
- * silently. Same first-character-is-a-digit guard
- * auth.c, index.c and listener.c's literal parser use.
- */
+ /* RFC 7162 SS7 mod-sequence-values are unsigned only, but strtoull(3) accepts a leading sign, so a guard rejects non-digit-leading input to stop "-1" silently becoming ULLONG_MAX (same check as auth.c/index.c/listener.c's literal parser). */
if (*valtok < '0' || *valtok > '9') {
*errmsg = "invalid CHANGEDSINCE mod-sequence";
return (-1);
blob - ec0bf31d729d076828fe70cb87dd5dd50c2dd6a8
blob + 3063e8439e40e298e3a780bbc9955988325c4100
--- src/imapd.8
+++ src/imapd.8
.\" Written for the OpenIMAPD project. Public domain / no rights reserved,
.\" matching the project's ports-oriented, OpenBSD-base-inclusion goal.
.\"
-.Dd $Mdocdate: September 6 2026 $
+.Dd $Mdocdate: September 9 2026 $
.Dt IMAPD 8
.Os
.Sh NAME
and reloads the
.Ic spool ,
.Ic attachment max ,
+.Ic idle poll ,
+.Ic startups ,
.Ic tls certificate ,
and
.Ic tls key
blob - 0cf2479b94bc7d44cefac9c27ab52e1c0ccfd64e
blob + b39ecd65a8d22008183ce59025d7bcd9da215595
--- src/imapd.h
+++ src/imapd.h
#include <imsg.h>
#include <stdint.h>
-#define IMAPD_VERSION "0.1.3"
+#define IMAPD_VERSION "0.1.4"
/*
* Process roles, selected at exec time via "-x <role>". See main.c.
blob - 2aa0bb8c8aa051fa5f41d67e9576286e411585cf
blob + 1867c3df066b4fed8fcc23aac6f1b02eb03727eb
--- src/imsgev.c
+++ src/imsgev.c
/*
* Copyright (c) 2026 David Williams <dhw@openimapd.dev>
* Copyright (c) 2009 Eric Faurot <eric@openbsd.org>
+ * Copyright (c) 2005 Claudio Jeker <claudio@openbsd.org>
+ * Copyright (c) 2004 Esben Norby <norby@openbsd.org>
+ * Copyright (c) 2003, 2004 Henning Brauer <henning@openbsd.org>
*
* This file's name and its "struct imsgev" wrapper-around-imsgbuf+
* event(3) concept match Eric Faurot's imsgev.c in OpenBSD's ldapd
* libevent handler, vs. ldapd's callback+needfd model) were written
* independently and differ from his implementation.
*
+ * imsgev_add() below is the exception: it is a verbatim copy (up to
+ * whitespace and the choice of iev vs. iev->data as event_set()'s last
+ * argument) of the imsg_event_add() idiom shared across OpenBSD privsep
+ * daemons, traceable to usr.sbin/ospfd/ospfd.c:525-534 (Claudio Jeker
+ * 2005, Esben Norby 2004, Henning Brauer 2003-2004) and copied with
+ * only that one-argument variation into dvmrpd.c, npppd.c, and rad.c.
+ * Their copyright is carried forward for that function specifically,
+ * same rationale as log.c's and parse.y's shared-idiom copyright chains.
+ *
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * imsgev.c, shared wrapper around imsgbuf + event(3), used by
- * parent.c, listener.c, auth.c, and store.c.
- *
- * IMSG API VERSION. This tree calls the current libutil imsg interface --
- * imsgbuf_init()/imsgbuf_read()/imsgbuf_write()/imsgbuf_flush()/
- * imsgbuf_clear() and imsgbuf_get() -- and not the older imsg_init()/
- * imsg_read()/imsg_get() spellings. That is not cosmetic: OpenBSD kept
- * imsg_get() for a while as a compatibility wrapper (it called
- * imsgbuf_get() and translated a success into a byte count) and has since
- * removed it, so a build against current headers fails to link on that
- * symbol alone. imsgbuf_get() returns 1 for a message, 0 for none and -1 on
- * error; every call site in this tree tests only the 0 and -1 cases, which
- * the wrapper passed through unchanged, so the switch was exact.
- */
+/* Shared imsgbuf+event(3) wrapper for parent/listener/auth/store, built on the current imsgbuf_*() API (imsg_get() and friends were removed upstream); imsgbuf_get()'s 1/0/-1 return is handled exactly as before. */
#include <sys/types.h>
#include "imapd.h"
#include "log.h"
-/*
- * The one place an imsgbuf gets its imapd-wide settings. Every channel in
- * the daemon goes through here, including the five that each role builds by
- * hand on fd 3 before the event loop exists -- they used to open-code two of
- * these three lines and omit the third, which left the two ends of the
- * parent<->child channel disagreeing about the size limit.
- *
- * On imsgbuf_set_maxsize(3): its argument is the maximum PAYLOAD, not the
- * maximum message. It adds IMSG_HEADER_SIZE before storing (libutil's
- * imsgbuf_set_maxsize()), and imsgbuf_init(3) has already installed
- * MAX_IMSGSIZE as the whole-message limit. So this call RAISES the limit by
- * IMSG_HEADER_SIZE rather than clamping it -- which is fine, but it means
- * every channel has to make the same call or the ends differ by 16 bytes.
- *
- * On imsgbuf_allow_fdpass(3): the parent fd-passes on each of these channels
- * at spawn time (setup_peer_send(), IMSG_LISTENER_SESSION_INIT), so every
- * channel needs it. Note that the parent is the ONLY process that ever
- * attaches a descriptor to an imsg -- see the pledge comments in auth.c,
- * keymgr.c, listener.c, search_oracle.c and store.c.
- */
+/* Sets imapd-wide imsgbuf settings for every channel: imsgbuf_set_maxsize() raises the whole-message limit by IMSG_HEADER_SIZE since its argument is payload-only, and imsgbuf_allow_fdpass() is needed since the parent fd-passes on these channels at spawn time. */
void
imsgev_ibuf_init(struct imsgbuf *ibuf, int fd)
{
imsgbuf_allow_fdpass(ibuf);
}
-/*
- * Arm EV_WRITE for a channel that has just queued a message.
- *
- * libutil calls this from imsg_close() (imsg.c:397-399), which is the funnel
- * every queueing path goes through: imsg_compose(), imsg_composev() and
- * imsg_forward() all end there. So it fires once per message queued, on every
- * path, and no call site can forget it. It replaces the 36 hand-written
- * "imsgev_add() after every imsg_compose()" pairings this tree used to carry.
- *
- * imsgbuf_set_close_callback(3) and imsgbuf_set_userdata(3) arrived in
- * OpenBSD commit 348f1fc0836b (2026-09-04) -- the same commit that removed
- * imsg_get() -- explicitly "to replace the bad imsgev wrappers in various
- * deamons".
- *
- * On the early return: a single FETCH can queue hundreds of messages, and
- * imsgev_add() is event_del() + event_set() + event_add(). Once EV_WRITE is
- * armed the rest of a batch has nothing to do. event_pending(3) is asked
- * rather than iev->events because it reports what libevent actually holds and
- * so cannot go stale; smtpd uses the same idiom (usr.sbin/smtpd/control.c:362).
- * Auditing every early return in all 13 dispatch handlers found them all to be
- * teardown paths, so iev->events would in fact have been safe here -- this is
- * belt and braces, not a fix for a known hole.
- */
+/* Arms EV_WRITE via libutil's imsg_close() callback so every queued message gets it exactly once; the early return skips re-arming once EV_WRITE is already pending mid-batch. */
static void
imsgev_on_compose(struct imsgbuf *ibuf, void *arg)
{
event_set(&iev->ev, fd, iev->events, iev->handler, iev->data);
event_add(&iev->ev, NULL);
- /*
- * After event_set()/event_add(), because the callback touches iev->ev,
- * and after imsgev_ibuf_init(), because imsgbuf_init() memset()s the
- * whole imsgbuf and would wipe both of these. Deliberately NOT done in
- * imsgev_ibuf_init() itself: the five roles call that on fd 3 before
- * any event loop exists and then compose synchronously through
- * setup_recv_done_and_ack(), where a callback would reach an event that
- * has never been event_set().
- */
+ /* Must follow event_set()/event_add() (callback touches iev->ev) and imsgev_ibuf_init() (imsgbuf_init() memset()s the struct); not done inside imsgev_ibuf_init() itself since roles call it pre-event-loop on fd 3. */
imsgbuf_set_userdata(&iev->ibuf, iev);
imsgbuf_set_close_callback(&iev->ibuf, imsgev_on_compose);
}
event_add(&iev->ev, NULL);
}
-/*
- * Re-arm a channel's read event at the end of its libevent dispatch handler.
- *
- * imsgev_init() arms EV_READ without EV_PERSIST, so libevent drops the event
- * once it has fired. Every dispatch handler in this daemon must therefore
- * re-arm before returning, or that channel is never read again -- a hang
- * rather than a crash, and not one any single test names.
- *
- * This is the SAME work as imsgev_add(), on purpose, under a second name.
- * imsgev_add()'s other job -- arming EV_WRITE after an imsg_compose() -- now
- * belongs to imsgev_on_compose(), which libutil calls from imsg_close(). The
- * two jobs used to be spelled identically at 49 call sites, which is what made
- * "delete the 36 the callback replaces" a change no reviewer could check by
- * reading the diff. Naming them apart is what made that diff legible; it is
- * not a behaviour change, and delegating rather than duplicating keeps it from
- * becoming one.
- *
- * Note that it still arms EV_WRITE when output is queued, because a handler
- * that composed a reply and is now returning needs exactly that.
- *
- * See docs/Opus-5-security-review-2/Opus-5-DESIGN-imsgev-retirement.md
- * sections 3 and 6.
- */
+/* Re-arms EV_READ (which imsgev_init() sets without EV_PERSIST, so it drops after firing) at the end of every dispatch handler, delegating to imsgev_add() -- kept as a separate name for clarity, not different behavior. */
void
imsgev_rearm_read(struct imsgev *iev)
{
blob - c7edf6f62c43e5467c67983c0fd8ca81f21dc298
blob + 9a77c2b79924002c66a151ee72482f4bebf69da2
--- src/index.c
+++ src/index.c
#include "log.h"
#include "store_internal.h"
-/*
- * The index is a colon-delimited, line-oriented text file, so nothing
- * written into one of its fields may contain ':', CR or LF -- an LF
- * especially, since it would make the next index_save() emit a second,
- * fully attacker-chosen physical line. index_append() has always
- * enforced this for the basename it writes; the sites that need a
- * keywords field bypass index_append() and hand-build the line, so the
- * rule lives here where every one of them can share it.
- */
-/*
- * RFC 7162 SS7: a mod-sequence is a "Positive unsigned 63-bit integer
- * (1 <= n <= 9,223,372,036,854,775,807)". Values read back from the index
- * are bounded by it, as the client-facing parsers in mailbox_cmd.c,
- * fetch_cmd.c and store_cmd.c bound the ones read off the wire.
- */
+/* Index lines are colon-delimited text, so no field may contain ':', CR, or LF -- centralized here since keywords-field callers bypass index_append() and hand-build lines. */
+/* RFC 7162 SS7 bounds a mod-sequence to a positive 63-bit integer; values read back from the index are bounded the same way the wire-facing parsers already are. */
#define INDEX_MODSEQ_MAX INT64_MAX
int
return (field != NULL && strpbrk(field, ":\r\n") == NULL);
}
-/*
- * A basename read back OUT of the index is concatenated into "new/%s" /
- * "cur/%s" and handed to open(2)/stat(2)/rename(2) (mime.c, mbox_store.c).
- * unveil(2) stops such a path leaving the maildir, but nothing stops it
- * moving around inside it, so the format's rules are enforced on load as
- * well as on the write side that already refuses these.
- */
+/* A basename read from the index is pasted into paths for open(2)/stat(2)/rename(2); unveil(2) only stops it leaving the maildir, so load-time enforces the same format rules as the write side. */
int
index_basename_valid(const char *basename)
{
const unsigned char *p;
- /* strictly stronger than index_field_valid(): whatever is unsafe to
- * write into a line is also unsafe to paste into a path */
+ /* Strictly stronger than index_field_valid(): whatever is unsafe to write into a line is also unsafe to paste into a path. */
if (!index_field_valid(basename))
return (0);
/* excludes "", ".", "..", and dotfiles in one test */
return (1);
}
+/* Grows idx->lines by doubling (from 16) when it is full; index_load() and index_append() carried byte-identical copies of this block, so the growth policy and its failure log now live in one place. Returns 0 when there is room for one more line, -1 on allocation failure (already logged). */
+static int
+index_lines_grow(struct mbox_index *idx)
+{
+ size_t newcap;
+ char **newlines;
+
+ if (idx->nlines < idx->cap)
+ return (0);
+
+ newcap = (idx->cap == 0) ? 16 : idx->cap * 2;
+ if ((newlines = reallocarray(idx->lines, newcap,
+ sizeof(*idx->lines))) == NULL) {
+ log_warn("session %u: reallocarray index", session_id);
+ return (-1);
+ }
+ idx->lines = newlines;
+ idx->cap = newcap;
+ return (0);
+}
+
int
index_load(int fd, struct mbox_index *idx)
{
}
while (fgets(line, sizeof(line), fp) != NULL) {
- /*
- * fgets(3) silently splits a line longer than the buffer,
- * and the remainder is then parsed as its own record. A
- * filled buffer with no '\n' in it is ambiguous by itself
- * -- it's also what a LEGAL maximum-length line looks like,
- * since its own trailing '\n' doesn't fit in this read.
- * Peek at the next byte to tell them apart: the line's own
- * '\n' (or EOF, the last line in a file missing its final
- * newline) means this was exactly one record; anything else
- * means fgets(3) really did split it.
- */
+ /* fgets(3) silently splits an over-long line; peek at the next byte to distinguish a legal max-length line (next byte is '\n' or EOF) from an actual split record. */
if (strchr(line, '\n') == NULL &&
strlen(line) == sizeof(line) - 1) {
int c = fgetc(fp);
goto fail;
}
*colon = '\0';
- /*
- * Each of the three header fields is digits or
- * nothing, for the reason index_parse_line() states
- * below for the UID field: strtoul(3) and strtoull(3)
- * accept leading whitespace and a sign, so "-1:1:1"
- * would load a UIDVALIDITY of 4294967295 and " 5:1:1"
- * a UIDVALIDITY of 5, neither of which this format can
- * express. Same guard auth.c, listener.c and the three
- * mod-sequence parsers use.
- */
+ /* Each header field is digits-or-nothing: strtoul(3)/strtoull(3) accept leading whitespace and a sign, so unguarded input like "-1:1:1" would silently parse into a bogus value; same guard used elsewhere. */
if (line[0] < '0' || line[0] > '9') {
log_warnx("session %u: malformed "
"UIDVALIDITY: %s", session_id, line);
continue;
}
- if (idx->nlines == idx->cap) {
- size_t newcap = (idx->cap == 0) ? 16 : idx->cap * 2;
- char **newlines = reallocarray(idx->lines, newcap,
- sizeof(*idx->lines));
-
- if (newlines == NULL) {
- log_warn("session %u: reallocarray index",
- session_id);
- goto fail;
- }
- idx->lines = newlines;
- idx->cap = newcap;
- }
+ if (index_lines_grow(idx) == -1)
+ goto fail;
if ((idx->lines[idx->nlines] = strdup(line)) == NULL) {
log_warn("session %u: strdup index line", session_id);
goto fail;
return (0);
fail:
- /* idx may hold a partial set of already-allocated lines at this
- * point; index_free() is a safe no-op if it doesn't. Every caller
- * (refresh_index() included) documents/relies on "-1 means idx is
- * already freed" -- this is what makes that true. */
+ /* idx may hold partially-allocated lines here; index_free() is a safe no-op, making "-1 means idx is already freed" true for every caller including refresh_index(). */
index_free(idx);
fclose(fp);
return (-1);
memset(rec, 0, sizeof(*rec));
- /*
- * strtoul(3) accepts leading whitespace and a sign, so ":x:y:1"
- * would parse as UID 0 and "-1:x:y:1" as UID 4294967295. The field
- * is digits or nothing.
- */
+ /* strtoul(3) accepts leading whitespace and a sign, so an unguarded UID field could parse ":x:y:1" as 0 or "-1:x:y:1" as 4294967295; the field must be digits-or-nothing. */
if (line[0] < '0' || line[0] > '9') {
log_warnx("session %u: corrupt index line (UID field is not "
"a decimal number)", session_id);
}
memcpy(rec->basename, p, (size_t)(q - p));
rec->basename[q - p] = '\0';
- /*
- * About to be pasted into "new/%s" / "cur/%s" and passed to
- * open(2)/stat(2)/rename(2) by every caller. Refuse traversal,
- * hidden names and control bytes here rather than relying on
- * unveil(2) to catch the ones that would leave the maildir -- it
- * does nothing about the ones that stay inside it. Logged by UID,
- * not by basename: the basename is exactly the untrusted text that
- * should not reach syslog raw.
- */
+ /* Refuses traversal, hidden names, and control bytes before this basename is pasted into open(2)/stat(2)/rename(2) paths, since unveil(2) only stops paths leaving the maildir; logged by UID, never by the untrusted basename itself. */
if (!index_basename_valid(rec->basename)) {
log_warnx("session %u: refusing index line with unsafe "
"basename (UID %u)", session_id, rec->uid);
memcpy(rec->keywords, p, (size_t)(r - p));
rec->keywords[r - p] = '\0';
- /*
- * Same rule as the UID field above, which this function has
- * always enforced -- the MODSEQ field twelve lines down did
- * not get it. RFC 7162 SS7 bounds a mod-sequence at
- * 9,223,372,036,854,775,807, and strtoull(3)'s sign handling
- * would otherwise turn "-1" into 18446744073709551615 with
- * errno untouched, a value that then flows into CHANGEDSINCE
- * and UNCHANGEDSINCE comparisons and out to the client as a
- * MODSEQ FETCH item.
- */
+ /* Same digit-or-nothing guard as the UID field, now applied to MODSEQ: RFC 7162 SS7 bounds it at 9,223,372,036,854,775,807, but strtoull(3)'s sign handling would otherwise turn "-1" into 18446744073709551615 and leak into CHANGEDSINCE/UNCHANGEDSINCE and client-visible MODSEQ. */
if (r[1] < '0' || r[1] > '9') {
log_warnx("session %u: malformed per-message MODSEQ "
"in index line: %s", session_id, line);
return (v);
}
-/*
- * Resolves every "*" in a parsed sequence-set (an array of struct
- * seq_range, e.g. from IMSG_MBOX_FETCH's trailing array) against max --
- * the store's live index_max_uid() for a by-UID request, idx->nlines
- * for a sequence-number request, same values handle_mbox_fetch() and
- * friends have always resolved "*" against.
- *
- * RFC 9051 SS9: a seq-range is unordered ("the first sequence number
- * may be smaller or larger than the second"). parse_one_seq_range()
- * (fetch_cmd.c) already swaps a backwards LITERAL range at parse time,
- * but skips the swap when either side is "*", since only this function
- * knows what "*" resolves to -- so e.g. "5:*" on a 2-message mailbox
- * arrives here as lo=5, hi=2 and is swapped to lo=2, hi=5 below, same
- * as mbox_copy.c's COPY/MOVE handling has always done for this case
- * (formerly duplicated there, now centralized here so every caller,
- * including FETCH/STORE/UID EXPUNGE, gets the same RFC-correct
- * behavior for a backwards "*"-involving range).
- *
- * The swap runs before the clamps below: lo is then clamped up to at
- * least 1, and hi is additionally clamped down to max when clamp_hi is
- * set, matching this codebase's existing per-mode behavior (sequence
- * numbers can never legitimately exceed idx->nlines, but an explicit
- * (non-"*") UID above the highest UID in use is left alone rather than
- * clamped, since it's simply a range that won't match anything past
- * the last message). Every range is kept, even one still degenerate
- * after the swap and clamps (possible only when max itself is 0, i.e.
- * an empty mailbox, e.g. "*:*" resolving to lo=1 hi=0 after the lo<1
- * clamp) -- seqset_contains() below correctly treats lo > hi as "never
- * matches", and every caller's own scan is bounded by the same empty
- * idx->nlines/no-UIDs-in-use condition, so this can't cause an
- * incorrect match, only a harmless unmatchable entry. Ranges are not
- * merged or sorted -- SEQSET_MAX_RANGES already bounds the count, so
- * letting seqset_contains() below do one full pass per resolved range
- * at each membership test is cheap enough not to be worth a merge
- * step. Returns the number of ranges written to resolved[] (always
- * nranges; unlike before this function grew the swap, no range is
- * ever dropped).
- */
+/* Resolves "*" entries in a parsed sequence-set against max (index_max_uid() for UID requests, idx->nlines for sequence-number requests); swaps any backwards "*"-involving range per RFC 9051 SS9 (since parse_one_seq_range() can't), then clamps lo up to 1 and, when clamp_hi is set, hi down to max, keeping every range including degenerate ones that seqset_contains() correctly treats as unmatchable. */
uint32_t
seqset_resolve(const struct seq_range *ranges, uint32_t nranges,
uint32_t max, int clamp_hi, struct seq_range resolved[SEQSET_MAX_RANGES])
return (0);
}
-/*
- * Highest hi across all resolved ranges (0 if nresolved == 0), for an
- * early-exit bound on an ascending scan of idx->lines: once the loop's
- * position/UID exceeds this, no later line can match any range, same
- * early "break" every one of these loops already had for a single
- * range's hi.
- */
+/* Highest hi across all resolved ranges (0 if none), letting an ascending scan of idx->lines break early once past it, same as a single-range scan already did. */
uint32_t
seqset_max_hi(const struct seq_range *resolved, uint32_t nresolved)
{
return (max);
}
+/* The "does this command apply to this message?" rule for FETCH/STORE/COPY, in one place: RFC 9051 SS6.4.9 makes a UID command's sequence-set UID-space and a bare one position-space, so the caller passes both and by_uid picks. PAST_END is a stop signal, valid only because those three walk idx->lines in ascending order -- the compaction loops in move_same_mailbox()/handle_mbox_expunge() deliberately don't use this, since breaking early would leave the surviving lines they still have to copy down unwritten. */
+enum seqset_pos
+seqset_position(const struct seq_range *resolved, uint32_t nresolved,
+ uint32_t max_hi, int by_uid, uint32_t uid, uint32_t seqno)
+{
+ uint32_t val = by_uid ? uid : seqno;
+
+ if (val > max_hi)
+ return (SEQSET_PAST_END);
+ if (!seqset_contains(resolved, nresolved, val))
+ return (SEQSET_SKIP);
+ return (SEQSET_MATCH);
+}
+
/* Reports every UID in [lo, hi] absent from idx as IMSG_MBOX_SELECT_VANISHED ranges; RFC 7162 SS3.2.6 VANISHED modifier. */
void
send_vanished_range(const struct mbox_index *idx, uint32_t lo, uint32_t hi,
"IMSG_MBOX_SELECT_VANISHED", session_id);
}
- /*
- * A UID of UINT32_MAX would wrap want to 0, after which the
- * tail check below is trivially true and this function emits
- * a VANISHED (EARLIER) range covering the whole UID space --
- * telling a QRESYNC client that every message in the mailbox
- * is gone. Stop instead: there is nothing above this UID to
- * report.
- */
+ /* Stop here: a UID of UINT32_MAX would wrap to 0 and make the tail check trivially true, emitting a VANISHED range that wrongly claims every message in the mailbox is gone. */
if (rec.uid == UINT32_MAX)
return;
want = rec.uid + 1;
char line[STORE_INDEX_LINE_MAX];
int len;
- /*
- * RFC 9051 SS9 makes a uniqueid an nz-number, so UID 0 is not a UID.
- * Callers assign from idx->uidnext and increment it afterwards, with
- * no ceiling anywhere, so an exhausted uidnext wraps to 0 and the
- * appends after that silently REUSE UIDs still in the mailbox --
- * which SS2.3.1.1 forbids outright, and which breaks
- * index_max_uid()'s ascending-order assumption and every "*"
- * resolution built on it. Refusing here turns that into a logged
- * failure at the first append past the end. The RFC's actual answer
- * to running out of UIDs is to change UIDVALIDITY, which needs
- * persistent state this daemon does not keep yet; see the index.c
- * review's finding #1.
- */
+ /* RFC 9051 SS9 forbids UID 0; with no ceiling on uidnext, exhaustion would wrap it to 0 and silently reuse in-use UIDs (forbidden by SS2.3.1.1, and breaking index_max_uid()'s ascending assumption), so refuse here instead -- the RFC's real fix, changing UIDVALIDITY, needs persistent state not yet kept (see index.c review's finding #1). */
if (uid == 0) {
log_warnx("session %u: refusing index entry with UID 0 "
"(uidnext exhausted or index header corrupt)", session_id);
return (-1);
}
- if (idx->nlines == idx->cap) {
- size_t newcap = (idx->cap == 0) ? 16 : idx->cap * 2;
- char **newlines = reallocarray(idx->lines, newcap,
- sizeof(*idx->lines));
-
- if (newlines == NULL) {
- log_warn("session %u: reallocarray index", session_id);
- return (-1);
- }
- idx->lines = newlines;
- idx->cap = newcap;
- }
+ if (index_lines_grow(idx) == -1)
+ return (-1);
if ((idx->lines[idx->nlines] = strdup(line)) == NULL) {
log_warn("session %u: strdup index line", session_id);
return (-1);
uint32_t nresolved, max_hi, i;
if (req->qresync_has_uids) {
- /*
- * known-uids is a full RFC 9051 SS9 sequence-set
- * (SS3.2.5.1), resolved/swapped/clamped the same way as
- * every other UID-space consumer. "*" is forbidden in
- * known-uids and already rejected by mailbox_cmd.c's
- * parse_qresync_group(), so the max passed here is never
- * actually consulted -- kept only for the same calling
- * convention every other by-UID seqset_resolve() caller
- * uses. clamp_hi is 0: a known UID above the highest one
- * currently in use is exactly the case RFC 7162 SS3.2.5.1
- * wants reported VANISHED, not silently dropped.
- */
+ /* known-uids is a full RFC 9051 SS9 sequence-set resolved the same way as any UID-space consumer; max is unused since "*" is already rejected upstream, and clamp_hi is 0 because a known UID above the current highest is exactly what RFC 7162 SS3.2.5.1 wants reported VANISHED, not dropped. */
nresolved = seqset_resolve(ranges, nranges,
index_max_uid(idx), 0, resolved);
} else {
nresolved = 1;
}
- /*
- * RFC 7162 SS3.2.6: VANISHED (EARLIER) MUST precede FETCH in the
- * response stream; guaranteed not by send order here but by
- * store_ipc.c's session_handle_mbox_selected(), which buffers
- * both kinds separately while SESSION_SELECTING and flushes all
- * VANISHED ranges before any FETCH -- the same two-pass split
- * handle_mbox_fetch() already uses for its own FETCH ...
- * (VANISHED) modifier, and send_vanished_range() itself is the
- * exact same helper that call site uses, one resolved range at a
- * time.
- */
+ /* RFC 7162 SS3.2.6 requires VANISHED (EARLIER) precede FETCH; ordering is guaranteed by store_ipc.c's session_handle_mbox_selected(), which buffers and flushes VANISHED before FETCH, the same two-pass split and helper handle_mbox_fetch() uses. */
for (i = 0; i < nresolved; i++)
send_vanished_range(idx, resolved[i].lo, resolved[i].hi, iev);
}
}
-/*
- * Takes the mailbox's index lock (op is LOCK_EX or LOCK_SH) and opens the
- * index under it, filling *il. Returns 0, or -1 with nothing held.
- *
- * The order matters and is the whole point: the lock is taken on
- * STORE_INDEX_LOCK_NAME, whose inode is stable, and the index is opened only
- * afterwards, so the descriptor cannot refer to an inode that a concurrent
- * index_save() has already renamed away. See STORE_INDEX_LOCK_NAME's comment
- * in store_internal.h for what went wrong when the lock was taken on the
- * index itself.
- *
- * Callers release with index_lock_release(), which is idempotent.
- */
+/* Takes the index lock (LOCK_EX/LOCK_SH) on STORE_INDEX_LOCK_NAME's stable inode before opening the index, so the descriptor can't refer to an inode a concurrent index_save() already renamed away; release with the idempotent index_lock_release(). */
int
index_lock_acquire(struct index_lock *il, int op)
{
}
}
-/*
- * Issues a UIDVALIDITY for a mailbox that has none, and records it.
- *
- * RFC 9051 SS2.3.1.1 requires that a mailbox which loses its UIDs be given a
- * UIDVALIDITY greater than the one it had before, and recommends a timestamp
- * on the grounds that this "ensures that the value is unique and always
- * increases". Two calls inside one second break that promise, and a client
- * cannot detect it: an unchanged UIDVALIDITY is exactly how the protocol says
- * "your cache is still good".
- *
- * So the timestamp is kept as a floor, not as the answer: the value returned
- * is the later of the clock and one past the highest value this user has ever
- * been issued, and the new high-water mark is written back before returning.
- * The record lives at the maildir root (STORE_UIDVALIDITY_NAME) because the
- * mailbox's own state is gone after DELETE -- that is what DELETE means -- so
- * the memory has to outlive it.
- *
- * Called from index_load() with the mailbox's index lock already held. See
- * STORE_UIDVALIDITY_NAME's comment for why this lock is safe to nest there,
- * and for the rule that keeps it that way.
- *
- * Every failure degrades to the old behaviour -- a bare timestamp -- rather
- * than failing the operation the caller was performing: a mailbox that opens
- * with a slightly weaker UIDVALIDITY guarantee is better than a mailbox that
- * will not open. One case deliberately does NOT write back: a file that
- * exists and holds something unparseable is left exactly as it is, because
- * overwriting it would replace a floor we failed to read with a lower one,
- * which is the single worst thing this function could do.
- */
+/* Issues and records a new UIDVALIDITY: RFC 9051 SS2.3.1.1 requires it strictly increase, so the timestamp is only a floor -- the value returned is max(clock, last-issued+1), the high-water mark is persisted at the maildir root (STORE_UIDVALIDITY_NAME, since per-mailbox state is gone after DELETE), and every failure degrades to a bare timestamp except an unparseable-but-present file, which is left untouched rather than overwritten with a lower floor. */
uint32_t
uidvalidity_next(void)
{
ssize_t n;
int fd, writeback = 1;
- /*
- * The namespace is flat -- select_mailbox_dir() leaves a mailbox with
- * a single chdir("..") -- so the root is either the cwd (INBOX) or
- * exactly one level up.
- */
+ /* The namespace is flat -- select_mailbox_dir() reaches a mailbox with a single chdir("..") -- so the root is either the cwd (INBOX) or exactly one level up. */
path = current_mailbox_dir[0] == '\0' ?
STORE_UIDVALIDITY_NAME : "../" STORE_UIDVALIDITY_NAME;
return (val != 0 ? val : 1);
}
- /*
- * A zero-length file is the ordinary just-created case, not damage:
- * floor 0 is correct for it. Anything present but unreadable is
- * damage, and is left alone.
- */
+ /* A zero-length file is the ordinary just-created case (floor 0 is correct); anything present but unreadable is damage and is left alone. */
if (fstat(fd, &st) == 0 && st.st_size > 0) {
if ((n = read(fd, buf, sizeof(buf) - 1)) <= 0) {
log_warn("session %u: read %s (UIDVALIDITY floor)",
} else {
buf[n] = '\0';
buf[strcspn(buf, "\r\n")] = '\0';
- /* same digit guard as the index header: strtoul(3)
- * accepts a leading sign, so "-1" would read as
- * 4294967295 and pin the floor at its ceiling */
+ /* same digit guard as the index header: strtoul(3) accepts a leading sign, so "-1" would read as 4294967295 and pin the floor at its ceiling */
errno = 0;
parsed = strtoul(buf, &ep, 10);
if (buf[0] < '0' || buf[0] > '9' || *ep != '\0' ||
if (writeback) {
if (val <= floor) {
if (floor == UINT32_MAX) {
- /* 4 billion issued values, or a clock past
- * 2106: nothing greater is representable. */
+ /* 4 billion issued values, or a clock past 2106: nothing greater is representable. */
log_warnx("session %u: UIDVALIDITY floor is "
"exhausted (%u); reusing it", session_id,
floor);
"may be issued again", session_id, path);
}
- /*
- * A successful floor consultation is otherwise silent, and a
- * UIDVALIDITY that is quietly wrong looks exactly like one that is
- * right -- the client cannot tell, which is the whole reason this
- * function exists. At -v this says what was read and what was issued.
- */
+ /* A successful floor consultation is otherwise silent, and a quietly-wrong UIDVALIDITY looks identical to a correct one to the client; -v logs what was read and what was issued. */
log_debug("session %u: UIDVALIDITY: floor %u in %s -> issued %u%s",
session_id, floor, path, val,
writeback ? "" : " (floor NOT updated)");
return (val);
}
-/*
- * Walks new/ for maildir deliveries the index does not know about yet.
- *
- * mutate == 0 answers only "is there at least one?", stops at the first, and
- * touches neither idx nor the filesystem -- so it is safe under a SHARED
- * index lock. That is the question handle_mbox_idle_refresh()'s poll asks
- * every few seconds, and the answer is almost always no.
- *
- * mutate == 1 indexes every one it finds, which is what refresh_index() wants
- * and which needs the exclusive lock its callers hold.
- *
- * Returns 1 if anything was found (mutate == 1: added), 0 if not, -1 on error
- * -- and on error with mutate set, idx has already been index_free()'d, which
- * is refresh_index()'s long-standing contract with ITS callers.
- */
+/* Walks new/ for undiscovered maildir deliveries: mutate==0 only answers "is there at least one?" without touching idx or the filesystem (safe under a shared lock, used by the frequent IDLE poll); mutate==1 indexes everything found and needs the exclusive lock. Returns 1 (found/added), 0, or -1 on error -- on error with mutate set, idx is already index_free()'d, per refresh_index()'s contract. */
static int
index_scan_new(struct mbox_index *idx, int mutate)
{
continue; /* ".", "..", and dotfiles, maildir delivery never creates the latter */
/* never index a filename with ':' or newline, would corrupt the index line format */
if (strpbrk(de->d_name, ":\r\n") != NULL) {
- /*
- * Logged only on the mutating pass. The read-only one
- * runs once per poll interval for the whole life of an
- * IDLE, and one badly-named file would otherwise fill
- * the log with the same line forever.
- */
+ /* Logged only on the mutating pass -- the read-only pass runs every poll interval for an IDLE's whole life, and a badly-named file would otherwise fill the log forever. */
if (mutate)
log_warnx("session %u: skipping new/ file "
"with unsafe name (contains ':' or "
if ((added = index_scan_new(idx, 1)) == -1)
return (-1); /* index_scan_new() has already freed idx */
- /*
- * Nothing new: don't pay index_save()'s cost for a no-op refresh --
- * unless the header itself is new, in which case the cost is the
- * point. A freshly invented UIDVALIDITY that is never written down is
- * invented again, differently, on the next call.
- */
+ /* Skip index_save()'s cost on a no-op refresh, unless the header itself is new -- a freshly invented UIDVALIDITY that's never written down would just be invented again, differently, next call. */
if (!added && !idx->fresh)
return (0);
return (0);
}
-/*
- * Cheap change probe for handle_mbox_idle_refresh().
- *
- * Two stat(2) calls, no lock and no read, answering "can anything possibly
- * have changed since the last look?". Both directories are needed and neither
- * is redundant:
- *
- * "." every mutation imapd itself makes ends in index_save(), which
- * creates imapd.index.tmp and rename(2)s it over imapd.index. Both
- * are operations on this directory, so its mtime moves for APPEND,
- * STORE, EXPUNGE, COPY and MOVE alike.
- * "new" an external MTA's delivery lands a file here and touches nothing
- * else until something indexes it, so "." alone would miss the one
- * case IDLE exists for.
- *
- * The sample is taken and stored BEFORE the caller does any work, on purpose.
- * Recording it afterwards would be tidier -- our own index_save() moves "."'s
- * mtime, so a real change costs one extra no-op refresh on the following poll
- * -- but it would also record a state that was never reported, and a write
- * that landed between the work and the sample would then be invisible for
- * good. One redundant refresh is a much better bug than a lost notification.
- *
- * The residual hole is timestamp granularity: two changes in the same
- * nanosecond, either side of a sample, are indistinguishable. On OpenBSD's
- * nanosecond st_mtim that is theoretical, and it is written down here rather
- * than left to be rediscovered.
- */
+/* Cheap change probe: two stat(2) calls (no lock, no read) on "." (moved by every index_save()-based mutation: APPEND/STORE/EXPUNGE/COPY/MOVE) and "new" (touched by an external MTA delivery before anything indexes it); sampled before the caller's work so a change is never missed, at the cost of one harmless extra refresh, modulo theoretical same-nanosecond races. */
static struct {
int valid;
ino_t dir_ino;
int same;
if (stat(".", &dst) == -1) {
- /* cannot tell, so do not claim to know: fall through to the
- * full refresh, which will report the failure properly. */
+ /* Cannot tell, so do not claim to know: fall through to the full refresh, which will report the failure properly. */
idle_probe.valid = 0;
return (0);
}
memset(&reply, 0, sizeof(reply));
- /*
- * Cheapest question first. On an untouched mailbox this is the whole
- * of the work: no lock, no index read, and no IMSG_MBOX_IDLE_UID
- * stream -- which matters because that stream is one message per
- * message in the mailbox, and the poll runs every few seconds.
- */
+ /* Cheapest question first: on an untouched mailbox this is the whole job, with no lock, no index read, and no per-message IMSG_MBOX_IDLE_UID stream, which matters since the poll runs every few seconds. */
if (idle_probe_unchanged()) {
reply.ok = 1;
reply.unchanged = 1;
- /*
- * The whole mechanism is otherwise silent, and a poll that
- * has quietly stopped firing is indistinguishable from a
- * healthy daemon -- which is exactly how the dead
- * cross-session push survived unnoticed. At -v these two
- * lines make the poll observable: one per interval while
- * nothing happens, the other when there is real work.
- */
+ /* The poll mechanism is otherwise silent and a dead one is indistinguishable from a healthy one (as the cross-session push bug showed); at -v these lines make each poll and any real work observable. */
log_debug("session %u: idle refresh: unchanged (probe: no "
"change to . or new/)", session_id);
goto send;
}
- /*
- * Something moved, so the index must be read -- but reading is all
- * the common case needs, so take the SHARED lock and escalate only if
- * new/ actually holds a delivery that has to be written into the
- * index.
- */
+ /* Something moved, so the index must be read; take the shared lock since reading alone covers the common case, and escalate only when new/ actually holds a delivery to index. */
if (index_lock_acquire(&il, LOCK_SH) == -1)
goto send;
if (index_load(il.fd, &idx) == -1) {
index_lock_release(&il);
goto send;
}
- /*
- * idx.fresh joins pending here: index_load() has just invented a
- * UIDVALIDITY for a mailbox that had no index, and writing it down
- * needs the exclusive lock as much as indexing a delivery does.
- */
+ /* idx.fresh joins pending here: index_load() just invented a UIDVALIDITY for a header-less mailbox, and persisting it needs the exclusive lock just as indexing a delivery does. */
if (pending || idx.fresh) {
- /*
- * Drop the shared lock and redo the whole thing exclusively.
- * Deliberately not an in-place flock(2) upgrade: that
- * conversion is not atomic, so another process can slip in
- * between the two states and what was read under the shared
- * lock cannot be trusted afterwards. Re-reading under
- * LOCK_EX costs one extra index_load() on the rare path that
- * had real work to do anyway.
- */
+ /* Deliberately drop the shared lock and redo everything under LOCK_EX rather than upgrading in place -- flock(2) has no atomic upgrade, so another process could slip in between states and invalidate what was read under the shared lock. */
index_free(&idx);
index_lock_release(&il);
if (index_lock_acquire(&il, LOCK_EX) == -1)
blob - f7ebcb8722030c44e1c797cbe9f9048f4d8e536a
blob + c359eac84115ad33f85b403204722cfa81cc2edc
--- src/keymgr.c
+++ src/keymgr.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * keymgr.c, TLS private-key isolation process: holds the real RSA/EC
- * private key; listener.c gets libtls's "fake private key" instead and
- * forwards every sign/decrypt operation here over imsg. See
- * docs/openimap-tls-privsep-design.md SS5 for the full design.
- *
- * Deliberately diverges from ca.c's fatalx()-on-bad-request style for
- * anything content-dependent: imapd's parent.c does not auto-restart a
- * dead listener/auth/keymgr child (see parent.c's reap_child()), it only
- * logs and tells the operator to "rcctl restart imapd" -- a crashed
- * keymgr would be worse than smtpd's ca dying, since every future TLS
- * handshake needs it. This file follows the same fatal/graceful split
- * every other role in this tree already uses (compare auth_main()'s
- * fatalx()-on-bad-INIT-framing vs. auth_verify()'s graceful "wrong
- * password" reply, or listener_main()'s own "no TLS cert/key received"
- * warning instead of a fatalx()): boot-time *plumbing* failures (no
- * peer, a parent that closes the channel mid-handshake, getpwnam/
- * chroot/privilege-drop failing) are still fatalx() -- there is no
- * sensible degraded mode for those. Boot-time *content* failures (an
- * unparseable cert or key) and any later per-request failure (unknown
- * hash, a key that won't do the requested operation) are logged and
- * degrade instead: keymgr keeps running with no usable key (or its last
- * known-good one), and answers every signing request with a plain
- * failure until a good SIGHUP reload arrives, exactly mirroring how
- * listener.c already treats its own "no TLS cert/key received" case as
- * non-fatal.
- */
+/* keymgr.c: holds the real TLS private key for listener.c's fake-key/imsg forwarding (docs/openimap-tls-privsep-design.md SS5); boot-time plumbing failures are fatal, but content failures (bad cert/key) and per-request failures degrade gracefully, keeping the process alive with no usable key rather than crashing. */
#include <sys/types.h>
#include <sys/queue.h>
#include "imapd.h"
#include "log.h"
-/*
- * Matches parent.c's send_tls_cert()/send_keymgr_key() read buffer size
- * (8192), same reasoning as listener.c's own TLS_CERT_MAX -- kept as a
- * separate local constant rather than a shared imapd.h macro, same as
- * parent.c's own unnamed 8192 and listener.c's TLS_CERT_MAX/TLS_KEY_MAX
- * today; nothing outside this file needs to agree on the exact value,
- * only that it's large enough for parent.c's own read buffer.
- */
+/* Matches parent.c's read buffer size (8192), kept as a separate local constant rather than a shared imapd.h macro since nothing else needs to agree on the exact value. */
#define KEYMGR_CERT_MAX 8192
#define KEYMGR_KEY_MAX 8192
-/*
- * SS7: keymgr is the one remaining boot-time, daemon-lifetime
- * singleton (this file's header comment) but now serves every live
- * connection's listener-worker, not just one -- each gets its own
- * peer entry, wired in as parent.c's spawn_connection() forks it
- * (keymgr_dispatch_parent()'s own IMSG_SETUP_PEER case below), torn
- * down independently when that one listener-worker's channel closes
- * (keymgr_dispatch_listener()). Named (not anonymous) so a forward
- * declaration isn't needed above keymgr_dispatch_parent()'s use of
- * the type -- same reasoning as listener.h's own struct session_list.
- */
+/* SS7: keymgr now serves every live connection's listener-worker via its own peer entry, wired in by IMSG_SETUP_PEER and torn down on channel close; named so keymgr_dispatch_parent() doesn't need a forward declaration. */
struct keymgr_peer {
uint32_t session_id;
struct imsgev iev;
static int keymgr_got_init;
static int keymgr_load(const char *, size_t, const char *, size_t);
+static void keymgr_try_reload(void);
static int keymgr_pubkey_hash(X509 *, char *, size_t);
static void keymgr_dispatch_listener(int, short, void *);
static void keymgr_peer_teardown(struct keymgr_peer *);
/* fd-passing is allowed on this channel for the fd-passed IMSG_SETUP_PEER peer fds; see imsgev_ibuf_init()'s own comment */
imsgev_ibuf_init(&ibuf3, 3);
- /*
- * IMSG_TLS_CERT (cert bytes) and IMSG_KEYMGR_INIT (key bytes) must
- * both be read before the peer handshake -- same "config before
- * anything else" ordering auth_main() already uses for
- * IMSG_AUTH_INIT (auth.c's comment: "must be read first"), so that
- * keymgr_got_init/keymgr_pkey are in their final boot-time state
- * before listener's peer channel can possibly send a signing
- * request. Two separate imsg types rather than one combined
- * payload deliberately mirrors parent.c's send_tls_cert()/
- * send_keymgr_key() split (see parent.c's send_keymgr_init()
- * comment) rather than packing cert+key into a single imsg, which
- * would leave uncomfortably little headroom under MAX_IMSGSIZE
- * (16384) once both are near their own 8192-byte caps.
- */
+ /* Both IMSG_TLS_CERT and IMSG_KEYMGR_INIT must be read before the peer handshake so keymgr_got_init/keymgr_pkey are final before any signing request can arrive; sent as two imsgs (mirroring parent.c's send_tls_cert()/send_keymgr_key() split) rather than one, to stay comfortably under MAX_IMSGSIZE. */
while (!got_cert || !got_key) {
if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
fatal("imsgbuf_get");
setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1)
fatal("cannot drop privileges to _imapkey");
- /*
- * SS7: unlike listener/auth-worker, keymgr stays the one
- * boot-time, daemon-lifetime child (this file's header comment)
- * -- but no peer is wired to it at boot any more. parent.c's
- * boot sequence sends it only IMSG_SETUP_DONE, with no preceding
- * IMSG_SETUP_PEER (see parent.c's own boot comment on why); its
- * first, and every later, listener-worker peer instead arrives
- * post-boot over this same fd 3 channel, as an ordinary
- * IMSG_SETUP_PEER per spawn_connection() call, handled by
- * keymgr_dispatch_parent()'s own case below -- not here.
- */
+ /* SS7: keymgr stays the one boot-time, daemon-lifetime child, but no peer is wired to it at boot -- parent.c sends only IMSG_SETUP_DONE; every listener-worker peer arrives later over this same channel via IMSG_SETUP_PEER, handled below. */
setup_recv_done_and_ack(&ibuf3);
event_init();
NULL);
#ifdef __OpenBSD__
- /*
- * recvfd, and only recvfd. Unlike every other child, keymgr keeps
- * receiving descriptors for its whole life: it is the one
- * daemon-lifetime singleton, and spawn_connection() sends it a fresh
- * IMSG_SETUP_PEER per accepted connection (parent.c), handled by
- * keymgr_dispatch_parent()'s own case below.
- *
- * No sendfd. keymgr never attaches a descriptor to an imsg -- the
- * parent is the only process in the tree that does. An earlier
- * version of this comment claimed sendfd was needed "for
- * imsg_compose()'s reply path"; it is not. SYS_sendmsg is
- * PLEDGE_STDIO (sys/kern/kern_pledge.c), and "sendfd" is checked in
- * unp_internalize() (sys/kern/uipc_usrreq.c), which the kernel
- * reaches only when SCM_RIGHTS is actually attached. keymgr_reply()
- * composes with fd == -1.
- *
- * No rpath either: keymgr touches no filesystem at all (SS5.5), which
- * is why this is auth.c's promise minus rpath.
- */
+ /* recvfd only: keymgr keeps receiving peer fds via IMSG_SETUP_PEER for its whole life but never sends one (only parent attaches descriptors to imsgs, and keymgr_reply() composes with fd == -1); no rpath either since keymgr touches no filesystem (SS5.5). */
if (pledge("stdio recvfd", NULL) == -1)
fatal("pledge");
#endif
fatalx("exited event loop");
}
-/*
- * Replicates smtpd's ssl.c hash_x509() byte-for-byte (see this file's
- * header comment for why the exact format is load-bearing, not
- * cosmetic): SHA256 digest of the certificate's DER SubjectPublicKeyInfo,
- * formatted "SHA256:" followed by lowercase hex.
- */
+/* Replicates smtpd's ssl.c hash_x509() byte-for-byte: SHA256 of the cert's DER SubjectPublicKeyInfo, formatted "SHA256:" plus lowercase hex -- the exact format is load-bearing (see file header), not cosmetic. */
static int
keymgr_pubkey_hash(X509 *cert, char *hash, size_t hashlen)
{
static const char hex[] = "0123456789abcdef";
- /*
- * unsigned char/unsigned int, not smtpd hash_x509()'s char/int:
- * X509_pubkey_digest(3) takes "unsigned char *md, unsigned int
- * *len" (x509.h), and the signed spellings draw a -Wpointer-sign
- * and an incompatible-pointer diagnostic under this Makefile's
- * -Wall. The emitted string is unchanged -- with an unsigned
- * digest, digest[i] >> 4 is already the high nibble in 0..15, so
- * the & 0x0f below becomes redundant rather than wrong, and it is
- * kept so this stays visibly the same algorithm as hash_x509().
- */
+ /* Uses unsigned char/unsigned int, not smtpd hash_x509()'s signed types, to match X509_pubkey_digest(3)'s prototype and avoid -Wpointer-sign warnings; the emitted string is unchanged since digest[i] is already unsigned. */
unsigned char digest[EVP_MAX_MD_SIZE];
size_t off;
unsigned int dlen, i;
return (0);
}
-/*
- * Parses a cert+key pair and, only if both parse successfully, replaces
- * the currently-loaded key. Deliberately parses the new pair fully
- * before touching the old one, unlike a literal "free the old EVP_PKEY/
- * hash, then install the new one": a rejected or malformed SIGHUP
- * reload (a typo'd path, a half-written file mid-rotation on the
- * operator's side) should leave keymgr still answering with the last
- * known-good key, not with none at all. This is not sourced against
- * smtpd's ca.c, whose own dict_check()/dict_xset() reload behavior this
- * project's design document already flags as unconfirmed either way
- * (docs/openimap-tls-privsep-design.md SS5.5) -- it's a small,
- * self-contained imapd choice, not a smtpd port.
- *
- * cert_buf/key_buf are not retained past this call; only the derived
- * EVP_PKEY and hash string are kept. Callers are responsible for
- * scrubbing their own copy of key_buf once this returns.
- */
+/* Parses a new cert+key pair fully before replacing the live one, so a malformed SIGHUP reload leaves the last known-good key in place instead of none; not sourced from smtpd's ca.c reload logic. cert_buf/key_buf aren't retained past this call -- callers must scrub key_buf themselves. */
static int
keymgr_load(const char *cert_buf, size_t cert_len, const char *key_buf,
size_t key_len)
goto fail;
}
- /*
- * Both halves parsing is not the same as them belonging together.
- * A rotation that replaced the certificate but not the key (or the
- * reverse) yields exactly that: two files that each parse cleanly
- * and do not correspond. It is the most common way a rotation goes
- * wrong, and it is precisely the case this function's
- * parse-both-before-swapping design exists to survive -- so check
- * it here, where both objects are in hand, and let the goto below
- * keep the last known-good pair.
- *
- * The per-request hash check in keymgr_handle_rsa()/_ecdsa()
- * cannot catch this: it compares the request's certificate hash
- * against this process's certificate hash, never the certificate
- * against the key.
- */
+ /* A cert and key can each parse fine yet not correspond to each other (e.g. a rotation that replaced only one) -- checked here via X509_check_private_key() before swapping, since the per-request hash check elsewhere can't catch a cert/key mismatch. */
if (X509_check_private_key(cert, pkey) != 1) {
log_warnx("certificate and private key do not match, not "
"(re)loading");
EVP_PKEY_free(keymgr_pkey);
keymgr_pkey = pkey;
pkey = NULL;
- /* strlcpy, not memcpy of sizeof(): keymgr_pubkey_hash() writes 72
- * of KEYMGR_HASH_MAX's 80 bytes, and copying the rest would drag
- * eight uninitialised stack bytes into a static. */
+ /* strlcpy, not memcpy of sizeof(): keymgr_pubkey_hash() only writes 72 of KEYMGR_HASH_MAX's 80 bytes, and memcpy would drag uninitialised stack bytes into a static. */
(void)strlcpy(keymgr_hash, hash, sizeof(keymgr_hash));
log_info("TLS key loaded (%s)", keymgr_hash);
return (-1);
}
+/* Commits a SIGHUP reload once BOTH halves have arrived: IMSG_TLS_CERT and IMSG_KEYMGR_INIT can land in either order, so both cases call this and only the second one finds the pair complete. The key buffer is scrubbed whether or not the load succeeded, and both flags clear so the next reload starts from a clean pair rather than half of this one. */
+static void
+keymgr_try_reload(void)
+{
+ if (!reload_got_cert || !reload_got_key)
+ return;
+
+ if (keymgr_load(reload_cert_buf, reload_cert_len, reload_key_buf,
+ reload_key_len) == -1)
+ log_warnx("SIGHUP reload: keeping previous key, see above");
+ explicit_bzero(reload_key_buf, sizeof(reload_key_buf));
+ reload_got_cert = reload_got_key = 0;
+}
+
/* PARENT channel (fd 3): SIGHUP reload's IMSG_TLS_CERT/IMSG_KEYMGR_INIT pair (same paired-flags shape listener.c used for its own now-removed cert/key reload gating), plus IMSG_SETUP_PEER wiring in a fresh listener-worker's peer (SS7: one per parent.c's spawn_connection() call, imsg_get_id() carries session_id -- see listener.c's own IMSG_SETUP_PEER (store) case for the same pattern). */
static void
keymgr_dispatch_parent(int fd, short event, void *arg)
if ((n = imsgbuf_read(&iev->ibuf)) == -1)
fatal("imsgbuf_read");
if (n == 0) {
- /*
- * The parent is gone, so this process is done -- the
- * same answer keymgr_main() already gives to the
- * identical event before the event loop starts
- * ("parent closed channel before INIT"), and what
- * keymgr_dispatch_listener()'s comment below has
- * always said this channel means.
- *
- * It used to event_del() and return, which left
- * keymgr serving its existing listener peers until
- * the last one closed. That protected nothing:
- * keymgr is reached only through libtls's
- * private-key callbacks, which fire during the TLS
- * handshake, so an established session never asks it
- * for anything again. What it cost was a process
- * holding the TLS private key outliving its
- * supervisor for as long as one client kept a
- * connection open -- and IDLE means days -- while an
- * "rcctl restart imapd" brought up a second keymgr
- * with the same key.
- *
- * log_warnx, not log_debug: unlike auth-worker's and
- * search-oracle's peer EOF, this is not the ordinary
- * end of anything. An orderly shutdown SIGTERMs
- * keymgr (parent.c's sigterm_handler()), so reaching
- * here means the parent died without running it --
- * a crash, a SIGKILL, the OOM killer. An operator
- * should see that without -v.
- *
- * exit(0), not fatalx: keymgr has not failed. It is
- * ending because the process it exists to serve
- * ended. fatalx would log "fatal:" at LOG_CRIT and
- * exit 1, which misreports an orderly response to
- * someone else's death -- and there is no parent
- * left to read the status anyway.
- */
+ /* Parent gone means this process is done: it used to linger serving existing peers, but keymgr is only ever consulted during a TLS handshake, so that only kept a key-holding process alive for as long as any client held a connection open; log_warnx (an operator should notice) and exit(0) (not a failure, just following the parent's death) rather than fatalx(). */
log_warnx("parent closed channel, exiting");
exit(0);
}
}
reload_cert_len = len;
reload_got_cert = 1;
- if (reload_got_cert && reload_got_key) {
- if (keymgr_load(reload_cert_buf,
- reload_cert_len, reload_key_buf,
- reload_key_len) == -1)
- log_warnx("SIGHUP reload: "
- "keeping previous key, see "
- "above");
- explicit_bzero(reload_key_buf,
- sizeof(reload_key_buf));
- reload_got_cert = reload_got_key = 0;
- }
+ keymgr_try_reload();
break;
}
case IMSG_KEYMGR_INIT: {
}
reload_key_len = len;
reload_got_key = 1;
- if (reload_got_cert && reload_got_key) {
- if (keymgr_load(reload_cert_buf,
- reload_cert_len, reload_key_buf,
- reload_key_len) == -1)
- log_warnx("SIGHUP reload: "
- "keeping previous key, see "
- "above");
- explicit_bzero(reload_key_buf,
- sizeof(reload_key_buf));
- reload_got_cert = reload_got_key = 0;
- }
+ keymgr_try_reload();
break;
}
default:
(void)fd;
}
-/*
- * LISTENER channel: the three signing/decrypt request types
- * (SS5.2/SS6.1). SS7: arg is this peer's owning struct keymgr_peer,
- * not the bare struct imsgev directly (imsgev_init()'s own arg,
- * set when keymgr_dispatch_parent()'s IMSG_SETUP_PEER case wires a
- * new listener-worker in) -- needed on the EOF path below to know
- * which one of possibly many live peers just went away.
- */
+/* LISTENER channel: handles the three signing/decrypt request types (SS5.2/SS6.1); arg is the owning struct keymgr_peer (not a bare imsgev) so the EOF path knows which of possibly many live peers just went away. */
static void
keymgr_dispatch_listener(int fd, short event, void *arg)
{
struct imsg imsg;
ssize_t n;
- /*
- * A transport failure on ONE listener-worker's channel drops that
- * peer, it does not end this process. This is the same
- * plumbing-vs-degrade split this file's header comment already
- * describes, applied to the per-peer channel: keymgr is the one
- * daemon-lifetime singleton and parent.c's reap_child()
- * deliberately does not restart it, so fatal()ing here would turn
- * one connection's worker dying at the wrong moment (a crash, a
- * SIGKILL, the shutdown race) into "no TLS for the whole daemon
- * until an operator runs rcctl restart". EPIPE from a peer that
- * went away with a reply still queued is exactly that case --
- * keymgr_reply() composes and returns, so replies really do sit
- * queued for the event loop to write.
- *
- * parent.c's store_child_dispatch() is the in-tree precedent for
- * this shape. keymgr_dispatch_parent() stays strict: that is the
- * fd-3 channel, and a keymgr that has lost its parent has no
- * future. Its transport errors fatal() and, since 2026-09-05, its
- * EOF exits too -- that last path used to drain instead, which is
- * the one place this sentence was describing something the code
- * did not do.
- */
+ /* A transport failure on one listener-worker's channel drops only that peer, not the whole process -- fatal()ing here would turn one connection's worker dying into a daemon-wide TLS outage, since keymgr is never restarted by parent.c's reap_child(); keymgr_dispatch_parent() (the fd-3 channel) stays strict since losing the parent leaves keymgr with no future. */
if (event & EV_WRITE) {
if (imsgbuf_write(&iev->ibuf) == -1) {
log_warnx("session %u: write error on listener "
(void)fd;
}
-/*
- * Drops one listener-worker peer: unregisters its event, closes and
- * clears its channel, unlinks it and frees it. Shared by every exit in
- * keymgr_dispatch_listener() -- EOF and transport error alike -- which
- * is the point: those are the same underlying event (that worker is
- * gone) arriving through two code paths, and they must not have two
- * different outcomes.
- *
- * imsgbuf_clear() is not optional: imsgbuf_init() allocates, and
- * close(2) alone would leak it, one allocation per connection for the
- * life of the daemon.
- */
+/* Drops one listener-worker peer: unregisters its event, closes and clears its channel, unlinks and frees it -- shared by every exit path in keymgr_dispatch_listener() so EOF and transport error give the same outcome; imsgbuf_clear() is required or imsgbuf_init()'s allocation leaks. */
static void
keymgr_peer_teardown(struct keymgr_peer *kp)
{
struct imsg_keymgr_sign_reply rep;
unsigned char combined[sizeof(rep) + KEYMGR_DATA_MAX];
- /*
- * Every caller bounds its own result today (both handlers check
- * RSA_size()/ECDSA_size() against their output buffer before
- * operating), but combined[] is a fixed stack buffer in the
- * key-holding process and this function should not depend on that
- * discipline holding forever -- keymgr_recv_trailing(), its
- * mirror image on the inbound side, does bound-check. A result
- * that does not fit is reported as a failed operation, which is
- * what struct imsg_keymgr_sign_reply's ok field is for.
- */
+ /* Every caller already bounds its own result, but combined[] is a fixed stack buffer holding the private key's output, so this function bound-checks independently rather than relying on that discipline holding forever; an oversized result is reported as a failed operation. */
if (ok && tolen > KEYMGR_DATA_MAX) {
log_warnx("keymgr_reply: %zu-byte result exceeds "
"KEYMGR_DATA_MAX (%d), refusing", tolen,
sizeof(rep) + (ok ? tolen : 0)) == -1)
log_warn("imsg_compose reply");
- /*
- * imsg_compose() has copied it. On an RSA_PRIVDEC this buffer
- * held the session's decrypted premaster secret; the file scrubs
- * the key material it is given (key_buf, reload_key_buf) and the
- * output of the operations it performs belongs in the same rule.
- */
+ /* imsg_compose() has copied the reply; this buffer may hold a decrypted premaster secret (on RSA_PRIVDEC), so it's scrubbed here like every other key-material buffer in this file. */
explicit_bzero(combined, sizeof(combined));
}
return;
}
keymgr_reply(iev, type, id, 1, to, (size_t)ret);
- /* RSA_PRIVDEC's output is the session's decrypted premaster
- * secret; do not leave it on this process's stack. */
+ /* RSA_PRIVDEC's output is the session's decrypted premaster secret; don't leave it on this process's stack. */
explicit_bzero(to, sizeof(to));
}
blob - 690803c7796434b4318d87e9ef1ca9a9282726d0
blob + 6d37b8daf020763e4daddf91c126a00a30bea7d4
--- src/listener.c
+++ src/listener.c
struct session_list sessions = TAILQ_HEAD_INITIALIZER(sessions);
-struct imsgev iev_auth; /* channel to the AUTH process; .ibuf.fd == -1
- * if this connection's auth-worker spawn failed
- * (see listener_main()'s boot-drain comment) --
- * auth_cmd.c's sasl_plain_finish() checks this
- * before sending IMSG_AUTH_REQUEST */
-struct imsgev iev_search; /* channel to the search-oracle process (SS8.1);
- * .ibuf.fd == -1 if this connection's oracle
- * spawn failed (same boot-drain comment as
- * iev_auth above) -- search_cmd.c's
- * search_dispatch() checks this before sending
- * IMSG_SEARCH_PARSE_REQUEST */
+struct imsgev iev_auth; /* Channel to the AUTH process; .ibuf.fd == -1 if this connection's auth-worker spawn failed, checked by auth_cmd.c's sasl_plain_finish() before sending IMSG_AUTH_REQUEST. */
+struct imsgev iev_search; /* Channel to the search-oracle process (SS8.1); .ibuf.fd == -1 if spawn failed, checked by search_cmd.c's search_dispatch() before sending IMSG_SEARCH_PARSE_REQUEST. */
struct imsgev iev_parent; /* fd 3, alive for the process's lifetime */
-/*
- * Channel to the keymgr process: real TLS private-key operations are
- * forwarded here synchronously, from inside OpenSSL's RSA_METHOD/
- * EC_KEY_METHOD callbacks (keymgr_engine_init() below), never through
- * the normal event-driven imsgev dispatch -- nothing unsolicited ever
- * arrives on this channel, so it's a plain struct imsgbuf, not a
- * struct imsgev; see keymgr_forward_rsa()/keymgr_forward_ecdsa().
- */
+/* Channel to the keymgr process: private-key ops are forwarded here synchronously from OpenSSL callbacks, never via imsgev dispatch, so it's a plain struct imsgbuf; see keymgr_forward_rsa()/keymgr_forward_ecdsa(). */
static struct imsgbuf keymgr_ibuf;
static struct tls_config *listener_tls_config;
};
#define NUM_IMAP_CMDS (sizeof(imap_cmds) / sizeof(imap_cmds[0]))
-/*
- * tls_config_use_fake_private_key() is internal to lib/libtls, not
- * declared in the installed tls.h -- confirmed directly (checked
- * lib/libtls/tls.h, the only public libtls header in openbsd_source/):
- * neither it nor tls_config_set_sign_cb() (tls_internal.h) appears
- * there. smtpd's smtp.c (smtp.c:54) forward-declares it the same way;
- * see docs/openimap-tls-privsep-design.md SS9 on what depending on an
- * unexported, no-compatibility-promise libtls internal costs.
- */
+/* tls_config_use_fake_private_key() is an internal, undeclared libtls symbol forward-declared here, same as smtpd's smtp.c does; see docs/openimap-tls-privsep-design.md SS9 on the risk of depending on it. */
void tls_config_use_fake_private_key(struct tls_config *);
-/*
- * SS5.4/SS10.2's fake-key call shape, exactly as smtp.c:187-193 uses it
- * (Gilles Chehade, Pierre-Yves Ritschard, Jacek Masiulaniec -- see this
- * file's header comment): tls_config_use_fake_private_key() installs
- * libtls's placeholder key, which keymgr_engine_init()'s RSA_METHOD/
- * EC_KEY_METHOD override below then intercepts every operation on;
- * tls_config_set_keypair_mem() supplies the real certificate with a NULL
- * key. Wrapped in one function so the tls_config-building if/else-if
- * chains in listener_main() (one call per branch) don't need
- * restructuring around two calls.
- */
+/* Installs libtls's placeholder private key plus the real certificate (smtp.c:187-193's call shape) in one function so listener_main()'s tls_config-building if/else-if chains need only one call per branch. */
static int
keymgr_set_fake_keypair(struct tls_config *config, const char *cert_buf,
size_t cert_len)
cert_len, NULL, 0);
}
-/*
- * RSA/ECDSA privsep engine: installed once, process-wide, so every RSA/
- * EC private-key operation OpenSSL performs against this process's
- * "fake" key -- inside a TLS handshake, on whatever connection happens
- * to be negotiating at the time -- is intercepted here and forwarded to
- * keymgr over keymgr_ibuf instead. Adapted from smtpd's ca.c
- * (ca.c:289-558, Reyk Floeter, Gilles Chehade -- see this file's header
- * comment); ca.c's own m_*()-based imsg framing (smtpd's message-
- * abstraction layer) is replaced with imapd's native "fixed header +
- * trailing raw bytes on one imsg" convention, and the imsg id field
- * imapd already uses elsewhere (e.g. IMSG_SETUP_PEER's session id) takes
- * the place of ca.c's own separate reqid bookkeeping.
- */
+/* RSA/ECDSA privsep engine, installed once process-wide: intercepts every private-key operation OpenSSL performs against this process's fake key and forwards it to keymgr; adapted from smtpd's ca.c (ca.c:289-558) with imapd's own imsg framing. */
static const RSA_METHOD *keymgr_rsa_default;
static RSA_METHOD *keymgr_rsae_method;
static const EC_KEY_METHOD *keymgr_ecdsa_default;
static EC_KEY_METHOD *keymgr_ecdsae_method;
-/*
- * Blocks reading keymgr_ibuf directly, bypassing the event loop --
- * called synchronously from inside an OpenSSL RSA_METHOD callback, which
- * cannot itself be deferred to the normal libevent dispatch. Unlike
- * ca.c's rsae_send_imsg() (ca.c:293-369), there's no "some other imsg is
- * queued up, hand it to the normal dispatcher" branch: nothing besides
- * these three request/reply pairs is ever multiplexed on the listener
- * <->keymgr channel, keymgr never sends anything unsolicited.
- */
+/* Blocks reading keymgr_ibuf directly from inside an OpenSSL RSA_METHOD callback; unlike ca.c's rsae_send_imsg(), nothing else is ever multiplexed on this channel so there's no need to hand off unrelated imsgs. */
static int
keymgr_forward_rsa(uint32_t type, const char *hash, const unsigned char *from,
int fromlen, unsigned char *to, size_t tosize, int padding)
imsg_free(&imsg);
break;
}
- /*
- * tosize, not KEYMGR_DATA_MAX: `to` is OpenSSL's own
- * output buffer, which the caller sized RSA_size(rsa)
- * -- 256 bytes for a 2048-bit key, where KEYMGR_DATA_MAX
- * is 1024, sized for an 8192-bit one. Bounding by the
- * wire cap rather than by the buffer we were actually
- * handed would let a longer-than-expected reply overrun
- * it. smtpd's ca.c sends RSA_size() across for exactly
- * this reason (ca.c:319, m_add_size()); this restores
- * that bound on the receiving side.
- */
+ /* Bound by tosize (OpenSSL's actual output buffer, e.g. 256 bytes for a 2048-bit key), not by the larger KEYMGR_DATA_MAX wire cap, or an oversized reply could overrun it; mirrors ca.c's own RSA_size() bound. */
if (rep.ok && rep.tolen <= tosize &&
imsg_get_len(&imsg) == rep.tolen) {
if (imsg_get_buf(&imsg, to, rep.tolen) == -1)
return (sig);
}
+/* Checks A, B and C1 (docs/OpenIMAPD-TLS-Review/Opus-5-DESIGN-B3-implementation.md) before any key op is forwarded to keymgr; fatalx(), not a log line, since a failure here means privilege separation isn't actually in effect. */
+static void
+keymgr_assert_fake_key(const char *hash, const BIGNUM *priv, const char *op)
+{
+ size_t i;
+
+ /* Check A: a public-key-only object from tls_config_use_fake_private_key() never has d/priv_key set, so a non-NULL priv here means libtls is no longer using the placeholder key. */
+ if (priv != NULL)
+ fatalx("%s: key object carries a private component -- "
+ "libtls is no longer using a placeholder key, and this "
+ "process is not separated from the TLS private key", op);
+
+ /* Check B: unlike smtpd's ca.c, listener configures exactly one keypair and never reaches this callback for an unrelated key, so a missing pubkey-hash tag is itself the regression, not a benign case to fall through on. */
+ if (hash == NULL)
+ fatalx("%s: no pubkey-hash tag on the key object -- libtls's "
+ "ex_data slot 0 tagging has changed", op);
+
+ /* Check C1, done before the tag is read as a string: strlcpy(3) has no bound once the destination is full, so this bounded loop (not memchr/strnlen) confirms the tag is NUL-terminated within KEYMGR_HASH_MAX bytes before anything trusts it. */
+ for (i = 0; i < KEYMGR_HASH_MAX; i++)
+ if (hash[i] == '\0')
+ return;
+ fatalx("%s: pubkey-hash tag is not a NUL-terminated string within "
+ "%d bytes -- refusing to read it", op, KEYMGR_HASH_MAX);
+}
+
static int
keymgr_rsa_priv_enc(int flen, const unsigned char *from, unsigned char *to,
RSA *rsa, int padding)
{
- char *hash;
+ const char *hash = RSA_get_ex_data(rsa, 0);
- if ((hash = RSA_get_ex_data(rsa, 0)) != NULL)
- return (keymgr_forward_rsa(IMSG_KEYMGR_RSA_PRIVENC, hash,
- from, flen, to, (size_t)RSA_size(rsa), padding));
- return (RSA_meth_get_priv_enc(keymgr_rsa_default)(flen, from, to,
- rsa, padding));
+ keymgr_assert_fake_key(hash, RSA_get0_d(rsa), "RSA_PRIVENC");
+ return (keymgr_forward_rsa(IMSG_KEYMGR_RSA_PRIVENC, hash,
+ from, flen, to, (size_t)RSA_size(rsa), padding));
}
static int
keymgr_rsa_priv_dec(int flen, const unsigned char *from, unsigned char *to,
RSA *rsa, int padding)
{
- char *hash;
+ const char *hash = RSA_get_ex_data(rsa, 0);
- if ((hash = RSA_get_ex_data(rsa, 0)) != NULL)
- return (keymgr_forward_rsa(IMSG_KEYMGR_RSA_PRIVDEC, hash,
- from, flen, to, (size_t)RSA_size(rsa), padding));
- return (RSA_meth_get_priv_dec(keymgr_rsa_default)(flen, from, to,
- rsa, padding));
+ keymgr_assert_fake_key(hash, RSA_get0_d(rsa), "RSA_PRIVDEC");
+ return (keymgr_forward_rsa(IMSG_KEYMGR_RSA_PRIVDEC, hash,
+ from, flen, to, (size_t)RSA_size(rsa), padding));
}
static ECDSA_SIG *
keymgr_ecdsa_do_sign(const unsigned char *dgst, int dgst_len,
const BIGNUM *inv, const BIGNUM *rp, EC_KEY *eckey)
{
- ECDSA_SIG *(*psign_sig)(const unsigned char *, int, const BIGNUM *,
- const BIGNUM *, EC_KEY *);
- char *hash;
+ const char *hash = EC_KEY_get_ex_data(eckey, 0);
- if ((hash = EC_KEY_get_ex_data(eckey, 0)) != NULL)
- return (keymgr_forward_ecdsa(hash, dgst, dgst_len));
- EC_KEY_METHOD_get_sign(keymgr_ecdsa_default, NULL, NULL, &psign_sig);
- return (psign_sig(dgst, dgst_len, inv, rp, eckey));
+ /* inv/rp are ECDSA_sign_setup() precomputation, unused since keymgr performs the operation, not this process. */
+ (void)inv;
+ (void)rp;
+
+ keymgr_assert_fake_key(hash, EC_KEY_get0_private_key(eckey),
+ "ECDSA_SIGN");
+ return (keymgr_forward_ecdsa(hash, dgst, dgst_len));
}
static void
/* fd-passing is allowed on this channel: it receives fd-passed peer/session messages below; see imsgev_ibuf_init()'s own comment */
imsgev_ibuf_init(&ibuf3, 3);
- /*
- * SS7: this process is spawned fresh per connection (parent.c's
- * spawn_connection()), not once at daemon boot, so the peer
- * handshake is folded into the same synchronous drain loop as
- * the rest of boot below rather than kept as separate blocking
- * setup_recv_one_peer() calls. The auth peer in particular may
- * never arrive at all -- spawn_connection() skips wiring one
- * when the auth-worker itself failed to fork -- so it can't be
- * a fixed, blocking "read exactly one" step the way it was when
- * a listener process's whole boot depended on both peers
- * existing. IMSG_SETUP_PEER is told apart by id: 0 for the auth
- * peer, session_id (always >= 1) for the keymgr peer, matching
- * parent.c's own setup_peer_send() calls. There is no
- * IMSG_SETUP_DONE/ack step either -- see parent.c's header
- * comment for why spawn_connection() doesn't use one.
- */
+ /* SS7: this process is spawned fresh per connection, so the peer handshake is drained in this same synchronous loop rather than separate blocking calls; the auth peer may never arrive, and IMSG_SETUP_PEER's id (0 vs session_id) tells auth from keymgr, matching parent.c's setup_peer_send(). */
while (!got_cert || !got_session_init || !got_keymgr_peer) {
if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
fatal("imsgbuf_get");
"carried no fd");
break;
}
- /*
- * parent sends each of these exactly once, so a
- * repeat cannot happen today -- but this loop runs
- * before pledge(2) and before the privilege drop,
- * which makes it the one place where "trust the
- * parent" is doing the most work. State the
- * invariant rather than silently overwriting a
- * descriptor. imsg_get_fd(3) has already passed
- * responsibility for peer_fd to us (imsg_init(3):
- * only UNCLAIMED descriptors are closed by
- * imsg_free()), so the duplicate must be closed
- * here.
- */
+ /* A repeat can't happen today, but this runs pre-pledge/pre-privdrop where trusting the parent matters most, so state the invariant rather than silently overwrite; imsg_get_fd(3) already handed us peer_fd, so the duplicate must be closed here. */
if (id == 0) {
if (auth_peer_fd != -1) {
log_warnx("listener: duplicate auth "
log_warnx("bad IMSG_LISTENER_SESSION_INIT");
break;
}
- /*
- * Refuse before claiming, unlike the two peer
- * cases above: an fd left unclaimed on this imsg
- * is closed by imsg_free() below (imsg_init(3)),
- * so there is nothing to clean up by hand.
- */
+ /* Refuse before claiming, unlike the peer cases above: an unclaimed fd on this imsg is closed by imsg_free() below, so there's nothing to clean up by hand. */
if (client_fd != -1) {
log_warnx("listener: duplicate "
"IMSG_LISTENER_SESSION_INIT, ignoring");
event_init();
- /*
- * auth_peer_fd may be -1 (no auth-worker for this connection --
- * see this function's header comment); iev_auth.ibuf.fd is left
- * at -1 in that case (its default zero-init would be fd 0, a
- * real, misleading fd), and auth_cmd.c's sasl_plain_finish()
- * checks that before ever composing to it.
- */
+ /* auth_peer_fd may be -1 (no auth-worker spawned); iev_auth.ibuf.fd is left at -1 rather than defaulting to fd 0, and auth_cmd.c's sasl_plain_finish() checks that before composing to it. */
if (auth_peer_fd != -1)
imsgev_init(&iev_auth, auth_peer_fd, listener_dispatch_auth,
NULL);
"connection gets one", sinit.session_id);
}
- /*
- * SS8.1: search_peer_fd may be -1 (no search-oracle for this
- * connection -- parent.c's spawn_connection() tolerates that fork
- * failing independently of listener/auth's own); iev_search.ibuf.fd
- * is left at -1 in that case, and search_cmd.c's search_dispatch()
- * checks that before ever composing to it, mirroring iev_auth just
- * above.
- */
+ /* SS8.1: search_peer_fd may be -1 (no search-oracle spawned, independent of auth's fork); iev_search.ibuf.fd is left at -1, checked by search_cmd.c's search_dispatch() before composing to it, mirroring iev_auth above. */
if (search_peer_fd != -1)
imsgev_init(&iev_search, search_peer_fd, listener_dispatch_search,
NULL);
imsgev_init_from_ibuf(&iev_parent, &ibuf3, listener_dispatch_parent,
NULL);
- /*
- * This process's one and only session, built from what boot just
- * drained above -- see listener_start_session()'s own comment.
- * Must run after event_init()/the TLS setup block above:
- * session_tls_start()/session_arm_client_read() register
- * libevent events, and an implicit-TLS session needs
- * listener_tls_ctx already set.
- */
+ /* This process's one and only session, built from what boot just drained; must run after event_init() and the TLS setup above since session_tls_start()/session_arm_client_read() register libevent events needing listener_tls_ctx already set. */
listener_idle_poll_secs = sinit.idle_poll_secs;
listener_start_session(sinit.session_id, client_fd,
sinit.implicit_tls, &sinit.remote_ss, sinit.remote_sslen);
- /*
- * SS8.1 finding: this process makes no socket(2)/connect(2)/
- * bind(2)/listen(2)/accept(2) call anywhere any more -- parent.c's
- * spawn_connection() owns every one of those now (SS7), and this
- * process only ever inherits an already-accepted client_fd over
- * IMSG_LISTENER_SESSION_INIT. The one remaining candidate for
- * needing "inet" is listener_start_session()'s getnameinfo(3) call
- * just above, formatting s->remote_addr -- but NI_NUMERICHOST|
- * NI_NUMERICSERV means it never touches the resolver or the
- * network, just formats already-numeric address bytes already in
- * hand (see that call's own comment). Dropping "inet" here on that
- * basis; if this turns out wrong, pledge(2) will kill the process
- * on its next getnameinfo(3) call and this line reverts.
- *
- * "recvfd" stays: this process receives a descriptor after this line,
- * the store child's peer fd, arriving as IMSG_SETUP_PEER on the fd 3
- * channel once parent.c's store-fork handshake completes
- * (listener_dispatch_parent()'s own case below).
- *
- * "sendfd" goes: this process never attaches a descriptor to an imsg.
- * The parent is the only one in the tree that does
- * (setup_peer_send(), setup_search_peer_send(),
- * IMSG_LISTENER_SESSION_INIT -- five call sites, all in parent.c).
- * SYS_sendmsg is PLEDGE_STDIO (sys/kern/kern_pledge.c); "sendfd" is
- * checked in unp_internalize() (sys/kern/uipc_usrreq.c), which the
- * kernel reaches only when SCM_RIGHTS is actually attached, so an
- * imsgbuf_allow_fdpass() channel carrying only fd == -1 messages does
- * not need the promise.
- */
+ /* pledge(2) promises: no socket/connect/bind/listen/accept call remains here (SS7 moved them to parent.c), so "inet" is dropped since getnameinfo(3) below only formats already-numeric bytes; "recvfd" stays for the store child's peer fd arriving later; "sendfd" goes since this process never attaches a descriptor to an imsg. */
#ifdef __OpenBSD__
if (pledge("stdio recvfd", NULL) == -1)
fatal("pledge");
fatalx("listener: exited event loop");
}
-/*
- * Builds and starts this process's one and only session, from the
- * IMSG_LISTENER_SESSION_INIT payload listener_main() drained at boot
- * (SS7: parent.c's spawn_connection() forks one listener-worker per
- * accepted connection instead of a single long-lived listener
- * accept()ing every one itself; MaxStartups admission control moved
- * with it, checked in parent.c's parent_accept() before this process
- * even exists -- see parent.c's own count_startups()/
- * startups_should_drop(), moved there verbatim from what used to
- * live in this file). Does the same work the old accept()-driven
- * listener_accept() did once accept(2) returned: construct struct
- * session, format remote_addr, log, and either begin the TLS
- * handshake or send the plaintext greeting -- just once, since
- * there's only ever one connection for this process to serve.
- */
+/* Builds and starts this process's one and only session from the IMSG_LISTENER_SESSION_INIT payload drained at boot (SS7), doing what the old accept()-driven listener_accept() did: build struct session, format remote_addr, log, then begin the TLS handshake or send the plaintext greeting. */
static void
listener_start_session(uint32_t session_id, int client_fd, int implicit_tls,
const struct sockaddr_storage *ss, socklen_t sslen)
if (s == NULL) {
log_warn("calloc");
close(client_fd);
- /*
- * SS7: nothing else will ever run in this process -- it
- * exists to serve this one session, and this is the only
- * call to this function. Returning would park it in
- * event_dispatch() forever with no client, holding a
- * parent-side open_session entry that counts against
- * MaxStartups for the rest of the daemon's uptime.
- * session_teardown()'s exit(0) is unreachable from here
- * (there is no session to tear down), so exit directly;
- * the implicit-TLS branch just below reaches the same
- * outcome through session_teardown().
- */
+ /* Exit directly rather than return: nothing else will ever run in this process, and returning would park it in event_dispatch() forever holding a MaxStartups slot with no client and no session to tear down. */
exit(1);
}
session_idle_poll_init(s); /* before anything can tear s down */
{
char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];
- /*
- * NI_NUMERIC*: no resolver call, no network I/O of any
- * kind -- pure formatting of the already-numeric address
- * bytes in ss/sslen. This is the fact listener_main()'s
- * pledge() comment (SS8.1) rests dropping "inet" on; if
- * that turns out wrong, this call is where pledge(2)
- * would kill the process.
- */
+ /* NI_NUMERIC*: pure formatting of already-numeric address bytes, no resolver or network I/O -- the fact listener_main()'s pledge() comment rests dropping "inet" on. */
if (getnameinfo((const struct sockaddr *)ss, sslen, hbuf,
sizeof(hbuf), sbuf, sizeof(sbuf),
NI_NUMERICHOST | NI_NUMERICSERV) == 0)
/* RFC 9051 SS4.3 hard cap on a non-synchronizing literal. */
#define IMAP_NONSYNC_LITERAL_MAX 4096
-/*
- * True if `line` (CRLF already stripped) ends in a NON-synchronizing
- * literal announcement, "{n+}" (RFC 9051 SS4.3). Those octets are already
- * in flight when the line arrives -- unlike a synchronizing "{n}", whose
- * octets only follow our "+" continuation -- so the reader has to account
- * for them no matter what becomes of the command that announced them.
- * Octet count returned in *lenp; 0 is a legal announcement ("{0+}").
- */
+/* True if `line` ends in a non-synchronizing literal announcement "{n+}" (RFC 9051 SS4.3), whose octets are already in flight and must be accounted for regardless of the command's fate; octet count returned in *lenp. */
static int
line_nonsync_literal(const char *line, uint64_t *lenp)
{
return (1);
}
-/*
- * RFC 9051 SS9: tag = 1*<ASTRING-CHAR except "+">, i.e. no CTLs, no SP, no
- * 8-bit, and none of ( ) { % * DQUOTE backslash. Only the length was
- * checked before, so any other byte reached session_reply()'s "%s %s %s"
- * verbatim; a tag of "+" in particular turns our own reply into what the
- * client reads as a command continuation request.
- */
+/* RFC 9051 SS9: tag = 1*<ASTRING-CHAR except "+">; previously only length was checked, so a tag of "+" could turn session_reply()'s own reply into a command continuation request. */
static int
tag_is_valid(const char *tag)
{
struct session *s = arg;
ssize_t n;
char *crlf;
- size_t tls_want = 0; /* bytes offered to tls_read(); see the
- * re-arm at the end of this function.
- * Not "want": the literal-assembly loop
- * below has its own uint64_t want, and
- * shadowing it draws -Wshadow. */
+ size_t tls_want = 0; /* bytes offered to tls_read(); re-armed at the end of this function -- named tls_want, not want, to avoid shadowing the literal-assembly loop's own uint64_t want (-Wshadow). */
(void)event;
size_t consumed, linelen;
int alive;
- /*
- * Octets of a non-synchronizing literal whose command was
- * refused (bad syntax, over the size cap, arrived while the
- * session was busy, or simply wasn't APPEND). They are on
- * the wire regardless, so swallow them rather than let the
- * message body be parsed as further IMAP commands.
- */
+ /* Octets of a refused non-synchronizing literal (bad syntax, over cap, session busy, or not APPEND) are already on the wire, so swallow them instead of parsing the message body as further IMAP commands. */
if (s->literal_discard > 0) {
uint64_t take;
}
if (s->literal_discard > 0)
break; /* need more data */
- /*
- * The command line that announced this literal
- * still ends in CRLF (RFC 9051 SS9). Nothing
- * downstream wants it, and leaving it makes the
- * line parser below see a zero-length line and
- * answer "* BAD Empty command line" after every
- * refused literal. Deliberately tolerant: if what
- * follows is not CRLF then the command had more
- * arguments after the literal, and the line parser
- * should still get a crack at them.
- */
+ /* Swallow the announcing command line's trailing CRLF so the line parser doesn't see a spurious zero-length line and answer "* BAD Empty command line" after every refused literal; anything besides CRLF is left for the parser. */
if (s->inbuflen >= 2 && s->inbuf[0] == '\r' &&
s->inbuf[1] == '\n') {
memmove(s->inbuf, s->inbuf + 2,
linelen = (size_t)(crlf - s->inbuf);
consumed = linelen + 2;
- /*
- * RFC 9051 SS2.2/SS9: CR and LF appear in a command only as the
- * CRLF terminator, and NUL is not an ASTRING-CHAR. This is the
- * one chokepoint where that can be enforced, and enforcing it
- * here is what keeps every downstream site that echoes client
- * text back (FETCH's BODY[<label>], error text, the tag itself)
- * from being able to emit a line break into the response
- * stream. A NUL additionally made the C-string parsers below
- * silently ignore the rest of the line. No safe resync point,
- * so close, as the post-literal CRLF check already does.
- */
+ /* RFC 9051 SS2.2/SS9: CR/LF only appear as the CRLF terminator and NUL isn't an ASTRING-CHAR; this is the one chokepoint enforcing that before client text gets echoed back or silently truncated by the C-string parsers, so a violation closes the connection. */
if (memchr(s->inbuf, '\r', linelen) != NULL ||
memchr(s->inbuf, '\n', linelen) != NULL ||
memchr(s->inbuf, '\0', linelen) != NULL) {
*crlf = '\0';
- /*
- * Note a trailing "{n+}" before dispatching: its octets follow
- * immediately on the wire, so they have to be accounted for
- * even when the command is rejected or deferred. Over RFC
- * 9051 SS4.3's 4096 the client is already out of spec and we
- * have no idea how much to skip, so close instead of guessing.
- */
+ /* Note a trailing "{n+}" before dispatch since its octets follow immediately on the wire; over RFC 9051 SS4.3's 4096-octet cap the client is already out of spec and we can't guess how much to skip, so close instead. */
nonsync_len = 0;
if (line_nonsync_literal(s->inbuf, &nonsync_len) &&
nonsync_len > IMAP_NONSYNC_LITERAL_MAX) {
} else if (s->idling) {
alive = session_handle_idle_continuation(s, s->inbuf);
} else if (session_is_busy(s)) {
- /*
- * A prior async command hasn't replied yet; queue rather
- * than reject (RFC 9051 SS5.5 pipelining). A command
- * carrying a non-synchronizing literal can't be queued:
- * its octets are arriving now, but cmd_append() would
- * only enter literal-read mode when the line is finally
- * dequeued, by which point the body would already have
- * been parsed as commands. Refuse it and swallow.
- */
+ /* Queue rather than reject a command while one is already in flight (RFC 9051 SS5.5 pipelining), except one carrying a non-synchronizing literal: its octets are arriving now but cmd_append() wouldn't enter literal-read mode until dequeued, so refuse and swallow instead. */
if (nonsync_len > 0) {
session_reply(s, "*", "BAD",
"non-synchronizing literal not accepted on "
if (alive == 0)
return; /* s was torn down (LOGOUT), do not touch */
- /*
- * Anything cmd_append() did not take over (it sets literal_
- * pending) gets swallowed rather than parsed.
- */
+ /* Anything cmd_append() didn't take over (via literal_pending) gets swallowed rather than parsed. */
if (nonsync_len > 0 && !s->literal_pending)
s->literal_discard = nonsync_len;
if (consumed > s->inbuflen)
consumed = s->inbuflen;
- /* Don't leave a SASL response (base64 of the cleartext password)
- * sitting in a long-lived heap buffer after it's been consumed. */
+ /* Don't leave a SASL response (base64 of the cleartext password) sitting in a long-lived heap buffer after it's been consumed. */
if (s->scrub_inbuf) {
explicit_bzero(s->inbuf, consumed);
s->scrub_inbuf = 0;
return;
}
- /*
- * tls_read() is a single SSL_read(), and SSL_read() returns at
- * most what one TLS record holds. A record carries up to 16384
- * bytes of plaintext while inbuf is SESSION_INBUF_MAX (8192), so
- * a client that puts more than that in one record leaves the
- * remainder inside libtls -- NOT in the socket. client_ev is a
- * level-triggered EV_READ registration on the socket fd, so it
- * would never fire for those bytes: a client waiting on a reply
- * to a command sitting in them hangs forever, and (with no
- * inactivity timeout) so does its MaxStartups slot. The
- * cleartext path has no equivalent problem -- there the
- * remainder stays in the kernel socket buffer, which a
- * level-triggered event does see.
- *
- * So if the read returned everything we had room for, assume
- * more may be behind it and re-queue this callback.
- * event_active() schedules it for the next turn of the loop
- * rather than recursing. ncalls must be 1, not 0:
- * event_process_active()'s "while (ncalls)" skips the callback
- * entirely at 0 (lib/libevent/event.c), which is why libevent's
- * own backends all pass 1.
- *
- * This terminates: each extra pass consumes real buffered
- * plaintext, and once libtls has none left tls_read() returns
- * TLS_WANT_POLLIN, which is not > 0.
- */
+ /* A TLS record can hold more than inbuf's SESSION_INBUF_MAX, and level-triggered EV_READ won't refire for bytes libtls is still holding, so a full read re-queues this callback via event_active() (ncalls=1) to drain the rest; this terminates once tls_read() returns TLS_WANT_POLLIN. */
if (s->tls_active && n > 0 && (size_t)n == tls_want &&
s->inbuflen < sizeof(s->inbuf))
event_active(&s->client_ev, EV_READ, 1);
}
-/*
- * Blocking write(2)/tls_write(): retries EAGAIN/TLS_WANT_POLL* via poll(2),
- * bounded by SESSION_WRITE_POLL_TIMEOUT_MS below. On an unrecoverable
- * error or a timeout it does NOT tear s down itself -- it only records the
- * failure in s->write_failed, which session_dispatch_client() checks the
- * next time this session's fd becomes readable. See the s->write_failed
- * comment in listener.h.
- */
+/* Blocking write(2)/tls_write(): retries EAGAIN/TLS_WANT_POLL* via poll(2) up to SESSION_WRITE_POLL_TIMEOUT_MS; on error or timeout it only records the failure in s->write_failed rather than tearing s down itself -- see listener.h and session_dispatch_client(). */
#define SESSION_WRITE_POLL_TIMEOUT_MS 5000
void
if (s->write_failed)
return;
- /*
- * Permanent outbound-traffic diagnostic, kept deliberately. Gated on
- * log_getverbose() rather than relying on log_debug()'s own internal
- * check, since building/scrubbing dbuf below is real work this
- * function would otherwise do on every single write regardless of
- * whether anything will be printed.
- *
- * session_write() is every outbound byte this daemon ever sends --
- * not just short status lines but literal FETCH payloads too (see
- * fetch_cmd.c's session_write() calls with pending_header_buf/
- * pending_body_buf). So debug verbosity doesn't just show protocol
- * traffic, it can show real header/body content, addresses, and
- * subject lines. That's the whole point of it as a diagnostic, but
- * it means turning on -v -v (debug level) is a message-content-
- * exposure decision on a mail server, not a free logging knob --
- * said here explicitly rather than left as a side effect to notice
- * later.
- */
+ /* Permanent outbound-traffic diagnostic, gated on log_getverbose() since building/scrubbing dbuf is real work otherwise done on every write; session_write() carries every outbound byte including literal FETCH payloads, so -v -v is a message-content-exposure decision, not just a logging knob. */
if (log_getverbose() > 0) {
char dbuf[301];
size_t dlen = len < sizeof(dbuf) - 1 ? len : sizeof(dbuf) - 1;
}
+/* Formats one complete response line into a 512-byte buffer and writes it. The one thing this must never do is emit a line the client cannot frame, so on overflow it forces the last two bytes back to CRLF rather than send a truncated, unterminated line -- that fixup is the whole reason session_reply() and session_untagged() are two lines each instead of fifteen: they differed only in their format string, and this is everything else they had in common. fuzz/fuzz_session_reply.c exists to hold exactly this invariant and carries its own stub copy of all three. */
+static void session_writef(struct session *, const char *, ...)
+ __attribute__((__format__ (printf, 2, 3)));
+
+static void
+session_writef(struct session *s, const char *fmt, ...)
+{
+ char buf[512];
+ va_list ap;
+ int len;
+
+ va_start(ap, fmt);
+ len = vsnprintf(buf, sizeof(buf), fmt, ap);
+ va_end(ap);
+
+ if (len < 0)
+ return;
+ if ((size_t)len >= sizeof(buf)) {
+ buf[sizeof(buf) - 3] = '\r';
+ buf[sizeof(buf) - 2] = '\n';
+ len = sizeof(buf) - 1;
+ }
+ session_write(s, buf, (size_t)len);
+}
+
void
session_reply(struct session *s, const char *tag, const char *status,
const char *text)
{
- char buf[512];
- int len;
-
- len = snprintf(buf, sizeof(buf), "%s %s %s\r\n", tag, status, text);
- if (len < 0)
- return;
- if ((size_t)len >= sizeof(buf)) {
- /*
- * Force the last two bytes back to CRLF rather than send a non-terminated line.
- */
- buf[sizeof(buf) - 3] = '\r';
- buf[sizeof(buf) - 2] = '\n';
- len = sizeof(buf) - 1;
- }
- session_write(s, buf, (size_t)len);
+ session_writef(s, "%s %s %s\r\n", tag, status, text);
}
void
session_untagged(struct session *s, const char *text)
{
- char buf[512];
- int len;
-
- len = snprintf(buf, sizeof(buf), "* %s\r\n", text);
- if (len < 0)
- return;
- if ((size_t)len >= sizeof(buf)) {
- /* Same CRLF-preservation rationale as session_reply() above. */
- buf[sizeof(buf) - 3] = '\r';
- buf[sizeof(buf) - 2] = '\n';
- len = sizeof(buf) - 1;
- }
- session_write(s, buf, (size_t)len);
+ session_writef(s, "* %s\r\n", text);
}
-/* Shared client-composing send for the SELECT/FETCH/STORE/EXPUNGE/SEARCH/COPY/MOVE call sites' "fixed request struct + N elemsize-sized trailing elements" imsg shape to s->store_iev; malloc failure and imsg_compose failure are both reported back; the caller replies NO and restores s->state. */
+/* Shared client-composing send for every "fixed request struct + N elemsize-sized trailing elements" imsg to s->store_iev, plus the degenerate no-trailing-array form CREATE/DELETE/RENAME/LIST/STATUS use; malloc failure and imsg_compose failure are both reported back; the caller replies NO and restores s->state. */
int
send_mbox_request(struct session *s, int imsg_type, const char *what,
const char *imsgname, const void *req, size_t reqlen, const void *elems,
size_t bodylen = (size_t)nelems * elemsize;
char *combined;
+ /* Nothing trailing: compose the caller's request where it already sits rather than malloc a copy of it just to hand it straight to imsg_compose(). Reached by the five fixed-size commands (LIST with req NULL and reqlen 0, which is the no-payload compose IMSG_MBOX_LIST wants), and by SELECT/EXPUNGE whenever their sequence-set is legitimately empty. */
+ if (bodylen == 0) {
+ if (imsg_compose(&s->store_iev->ibuf, imsg_type, 0, 0, -1,
+ req, reqlen) == -1) {
+ log_warn("session %u: imsg_compose %s", s->id,
+ imsgname);
+ return (0);
+ }
+ return (1);
+ }
+
if ((combined = malloc(reqlen + bodylen)) == NULL) {
log_warn("session %u: malloc %s imsg buffer", s->id, what);
return (0);
}
memcpy(combined, req, reqlen);
- if (bodylen > 0)
- memcpy(combined + reqlen, elems, bodylen);
+ memcpy(combined + reqlen, elems, bodylen);
- /*
- * A compose failure used to be logged and reported as success, so the
- * caller left s->state at the busy value it had just set and nothing
- * was in flight to move it back: session_is_busy() then blocked every
- * further command while the client waited for a tagged reply that
- * would never be sent. Every one of this function's call sites already
- * handles a 0 return by replying NO and restoring s->state, so
- * reporting the failure here fixes SELECT, FETCH, STORE, COPY/MOVE,
- * EXPUNGE and SEARCH without touching any of them.
- */
+ /* Report a compose failure via return value instead of just logging it: previously s->state stayed at the busy value with nothing in flight, so session_is_busy() blocked forever waiting for a reply that would never be sent; every caller already handles a 0 return by replying NO and restoring state. */
if (imsg_compose(&s->store_iev->ibuf, imsg_type, 0, 0, -1, combined,
reqlen + bodylen) == -1) {
log_warn("session %u: imsg_compose %s", s->id, imsgname);
return (1);
}
- /*
- * AUTHENTICATE's optional initial response is the base64 of the
- * user's cleartext password (RFC 4616 SS2), and a client that
- * ignores LOGINDISABLED puts it in the clear on LOGIN. Log the
- * command, never its arguments. (parse_command_line() has already
- * split `line` in place, so it is rebuilt from the pieces here.)
- */
+ /* AUTHENTICATE's optional initial response is the base64 of the user's cleartext password (RFC 4616 SS2), and LOGIN sends it in the clear when LOGINDISABLED is ignored, so log the command but never its arguments. */
if (name != NULL && (strcasecmp(name, "AUTHENTICATE") == 0 ||
strcasecmp(name, "LOGIN") == 0))
log_debug("session %u: <<< %s %s <redacted>", s->id, tag,
if ((n = imsgbuf_read(&iev->ibuf)) == -1)
fatal("imsgbuf_read");
if (n == 0) {
- /*
- * SS7: this session's auth-worker may die while the
- * session itself lives on (already authenticated, so
- * it no longer needs auth at all; or never got this
- * far and just won't be able to AUTHENTICATE again --
- * auth_cmd.c's sasl_plain_finish() fails that
- * gracefully once iev_auth.ibuf.fd reads -1, the same
- * sentinel listener_main() sets when no auth-worker
- * was spawned in the first place). Close and mark it
- * dead rather than just event_del(), or a later
- * AUTHENTICATE would silently compose onto a fd
- * nothing reads any more and the client would hang
- * waiting for a reply that never comes.
- */
+ /* SS7: this session's auth-worker may die independently of the session (already authenticated, or unable to AUTHENTICATE again); close and mark it dead rather than just event_del(), or a later AUTHENTICATE would compose onto a dead fd and the client would hang waiting for a reply. */
log_warnx("auth-worker closed channel");
event_del(&iev->ev);
close(iev->ibuf.fd);
(void)fd;
}
-/*
- * SEARCH-ORACLE channel (SS8.1): the per-connection search-oracle's one
- * reply, IMSG_SEARCH_PARSE_RESULT, to whatever IMSG_SEARCH_PARSE_REQUEST
- * search_dispatch() (search_cmd.c) last sent it. At most one such round
- * trip is ever in flight for this process's one session (see imapd.h's
- * IMSG_SEARCH_PARSE_REQUEST comment), and this process serves exactly
- * one session for its whole life (SS7) -- TAILQ_FIRST(&sessions), looked
- * up once here, is unambiguously it; iev_auth's IMSG_AUTH_RESULT case
- * looks itself up by session_id instead, but that field predates SS7 and
- * is kept there for parity with auth.c's own imsg shape, not because
- * there's any real ambiguity left to resolve.
- */
+/* SEARCH-ORACLE channel (SS8.1): at most one IMSG_SEARCH_PARSE_REQUEST/RESULT round trip is ever in flight for this process's one session, so TAILQ_FIRST(&sessions) is unambiguously it; session_id lookup elsewhere is kept only for parity with auth.c's imsg shape. */
void
listener_dispatch_search(int fd, short event, void *arg)
{
if ((n = imsgbuf_read(&iev->ibuf)) == -1)
fatal("imsgbuf_read");
if (n == 0) {
- /*
- * SS8.1: this session's search-oracle may die while the
- * session itself lives on, same fail-soft shape as
- * listener_dispatch_auth()'s own channel-EOF handling
- * just above (see its comment) -- a SEARCH already in
- * flight gets a synthesized NO below instead of hanging
- * forever; a later SEARCH fails fast via
- * search_dispatch()'s iev_search.ibuf.fd == -1 check,
- * the same sentinel used when no oracle was ever spawned
- * in the first place.
- */
+ /* SS8.1: this session's search-oracle may die independently of the session, same fail-soft shape as listener_dispatch_auth()'s channel-EOF handling -- an in-flight SEARCH gets a synthesized NO, and a later one fails fast via iev_search.ibuf.fd == -1. */
log_warnx("search-oracle closed channel");
event_del(&iev->ev);
close(iev->ibuf.fd);
"[SERVERBUG] internal error");
break;
}
- /*
- * imsg_get_buf() guarantees size, not NUL
- * termination -- and search_dispatch_finish()
- * hands this field straight to session_reply(),
- * i.e. straight to the client, where snprintf("%s")
- * would read on past res into this stack frame.
- * The sender is the least-trusted process in the
- * system by design (SS8.1's whole premise), so its
- * framing is exactly what must not be taken on
- * trust. Same rule as auth.c's inbound username/
- * password, parent.c's maildir and keymgr.c's hash.
- */
+ /* imsg_get_buf() guarantees size, not NUL termination, and this field goes straight to session_reply() via snprintf("%s"), so treat the least-trusted process's framing as untrusted, same as auth.c's username/password and parent.c's maildir. */
res.errmsg[sizeof(res.errmsg) - 1] = '\0';
if (res.rc == 0) {
TAILQ_REMOVE(&sessions, s, entry);
log_debug("session %u: closed (peer %s)", s->id, s->remote_addr);
- /* inbuf can still hold a base64 SASL response; don't hand it back
- * to the allocator intact. */
+ /* inbuf can still hold a base64 SASL response; don't hand it back to the allocator intact. */
explicit_bzero(s, sizeof(*s));
free(s);
- /*
- * SS7: this process was spawned (parent.c's spawn_connection())
- * to serve exactly this one session and will never serve another
- * -- listener_start_session() is called exactly once, from
- * listener_main()'s boot sequence. Without this, the process
- * would sit in event_dispatch() forever with nothing left to do,
- * leaking one process per finished connection for the rest of
- * the daemon's uptime; parent.c's reap_child() already treats a
- * listener-worker exit as the ordinary, expected end of a
- * session (see its own comment), not something to warn about.
- * Matches store.c's store_shutdown() for the same reason on
- * that per-session worker.
- */
+ /* SS7: this process serves exactly this one session and never another, so exit rather than idle forever in event_dispatch() leaking a process per finished connection; parent.c's reap_child() already treats this exit as expected, matching store.c's store_shutdown(). */
log_debug("listener-worker: session closed, exiting");
exit(0);
}
blob - 8af7ee8bc99acd2fa6ff0402d695548150e97705
blob + 6fe957dc56d5fbcb2f274002f2b0ee09b78cece3
--- src/listener.h
+++ src/listener.h
uint32_t *store_modified;
uint32_t store_modified_n;
uint32_t store_modified_cap;
+ int store_modified_alloc_failed; /* 1 if a
+ * realloc(3) in session_handle_
+ * store_modified() ever failed,
+ * or the response itself could
+ * not be composed. RFC 7162
+ * SS3.1.3 requires MODIFIED to
+ * list ALL messages that failed
+ * UNCHANGEDSINCE, and a client
+ * never retries one it doesn't
+ * see there, so an incomplete
+ * set is refused rather than
+ * sent -- same reasoning as
+ * s->search_alloc_failed and
+ * s->qresync_alloc_failed */
/*
* RFC 9051 SS6.4.9 (UID command): 1 if the in-flight async
int copy_move_dispatch(struct session *, const char *, char *,
int, int);
int listener_mailbox_name_valid(const char *);
- int listener_mailbox_name_is_inbox(const char *);
int listener_reject_bad_utf8(struct session *, const char *, const char *);
int list_pattern_match(const char *, const char *, int);
int parse_mailbox_name(char **, char *, size_t, const char **);
blob - 62a7715c6161a70f8736f966a553b3dd561d7889
blob + 775c41c683dd3c567bd827e802d85a74da8b772f
--- src/log.c
+++ src/log.c
static int log_verbose = 0;
static char log_procname[32] = "imapd";
-/*
- * Neutralises control characters for the -d path below. Returns a
- * malloc(3)'d copy of `s`, or NULL.
- *
- * WHY THIS EXISTS, AND WHY ONLY HERE
- *
- * Plenty of what this daemon logs comes from a client and has been through
- * no validation at all -- listener.c's "<<<" echo is the whole command line,
- * verbatim, before authentication -- so an unauthenticated peer can put
- * arbitrary bytes into a log message. Written straight to a terminal those
- * bytes are escape sequences: clear the screen, move the cursor back over
- * lines that have already scrolled past, retitle the window.
- *
- * The syslog path needs none of this. OpenBSD's syslogd(8) already runs
- * every byte of every message it receives through vis(3) before writing it
- * anywhere (usr.sbin/syslogd/syslogd.c, printline()), so a backgrounded
- * imapd was never exposed. The gap was only ever -d, where vlog() writes to
- * stderr and the reader is a person watching a live daemon -- which is
- * exactly when what is on screen ought to be trustworthy.
- *
- * Doing it here rather than at each log site is the point. There are around
- * ninety call sites whose arguments carry client- or filesystem-derived
- * text, and escaping at each one would be ninety edits plus a rule everyone
- * has to remember forever. This is a property of the output channel, so it
- * belongs at the output channel.
- *
- * WHAT IS ENCODED
- *
- * C0 (0x00-0x1f) and DEL as vis(3)'s caret form, and the C1 controls
- * U+0080-U+009F -- two bytes, 0xc2 0x80..0xc2 0x9f, in the UTF-8 this
- * daemon speaks -- as its meta form. The notation matches vis(3) so that a
- * -d trace and a syslog line describe a control byte the same way.
- *
- * Everything else, including all other bytes >= 0x80, is passed through.
- * That is a deliberate divergence from syslogd, which octal-escapes them:
- * the threat is control characters, no UTF-8 sequence can contain a C0
- * byte, and a mailbox name that reads as "日本" rather than
- * "\346\227\245\346\234\254" is worth keeping in the one output a human
- * reads live.
- *
- * A backslash is NOT escaped, so the encoding is one-way. That is
- * deliberate and it is the same call syslogd makes by passing VIS_NOSLASH:
- * every flag name in this protocol starts with one ("\Seen", "\Deleted"),
- * and doubling all of them would cost more legibility than the ambiguity
- * costs correctness. Nothing reads this output back.
- */
+/* log_escape_ctl(): vis(3)-style caret/meta-escapes control bytes for the -d/stderr trace only (syslogd already escapes syslog output), done once here rather than at ~90 call sites; other high bytes and backslash pass through so UTF-8 and flag names stay legible and readable one-way. */
static char *
log_escape_ctl(const char *s)
{
if (log_foreground) {
char *msg = NULL, *safe = NULL;
- /*
- * Formatted first, then escaped: the untrusted text arrives
- * through `ap`, so there is nothing to neutralise until the
- * message has been built. vasprintf(3) rather than a fixed
- * buffer because the "<<<" echo carries a whole IMAP command
- * line and truncating it would lose the part worth reading.
- *
- * On allocation failure, say so rather than falling back to
- * an unescaped write -- a path that only runs when memory is
- * gone is exactly the one nobody would notice going raw.
- */
+ /* Formats first via vasprintf(3) (untrusted text only arrives through `ap`, and truncation would lose part of a command echo) then escapes; reports allocation failure explicitly rather than falling back to an unescaped write. */
if (vasprintf(&msg, fmt, ap) == -1)
msg = NULL;
if (msg != NULL)
va_list ap;
int saved_errno = errno;
- /*
- * saved_errno, not a bare errno, for two reasons. The obvious one is
- * that asprintf(3), vfprintf(3) and free(3) may each leave errno set,
- * and a caller is entitled to read errno after log_warn() returns --
- * so it is restored on the way out, the way the shared OpenBSD idiom
- * this file carries has always done it. The subtler one is the
- * asprintf failure path below, which logs strerror() a second time
- * from a point where errno is no longer the caller's.
- */
+ /* Uses saved_errno rather than bare errno since asprintf(3)/vfprintf(3)/free(3) can clobber it before it's restored for the caller, and since the asprintf-failure path below needs to log strerror() again after errno has already changed. */
if (emsg == NULL)
logit(LOG_ERR, "%s", strerror(saved_errno));
else {
/* best-effort in appending strerror() after the format */
if (asprintf(&nfmt, "%s: %s", emsg,
strerror(saved_errno)) == -1) {
- /*
- * Out of memory: log the caller's message without the
- * errno text, then the errno text on its own line, so
- * the reason is not lost just because asprintf() was
- * the thing that failed.
- */
+ /* On asprintf() failure, log the caller's message and the errno text on separate lines so the reason isn't lost just because asprintf() itself failed. */
va_start(ap, emsg);
vlog(LOG_ERR, emsg, ap);
va_end(ap);
blob - c6f31ba72dc92219094f4139283a07a05a77c726
blob + 4b2d958666ef48c612a6dc7c94f1c41b61bf0e10
--- src/mailbox_cmd.c
+++ src/mailbox_cmd.c
#include "imapd.h"
#include "log.h"
#include "listener.h"
+#include "mboxname.h"
#include "utf8.h"
int
*errmsg = "QRESYNC requires uidvalidity and mod-sequence";
return (-1);
}
- /*
- * RFC 7162 SS7: mod-sequence-value is "1*DIGIT ... (1 <= n <=
- * 9,223,372,036,854,775,807)". strtoull(3) accepts a leading sign,
- * so "-1" would arrive here as ULLONG_MAX with errno untouched --
- * the same trap auth.c, index.c and listener.c's literal parser
- * each guard with a first-character-is-a-digit test. Enforced here
- * too, along with the grammar's nonzero requirement and its 63-bit
- * ceiling.
- */
+ /* RFC 7162 mod-sequence-value is 1..2^63-1; strtoull(3) accepts a leading sign so "-1" would parse as ULLONG_MAX, so enforce a leading-digit check plus the nonzero and 63-bit-ceiling requirements. */
if (*tok < '0' || *tok > '9') {
*errmsg = "invalid QRESYNC mod-sequence";
return (-1);
{
uint32_t i;
- /*
- * known-uids is a full RFC 9051 SS9 sequence-set
- * (SS3.2.5.1's grammar is "known-uids = sequence-set"),
- * same as every other sequence-set consumer now parses;
- * only the "*" restriction below is specific to this call
- * site.
- */
+ /* known-uids is a full RFC 9051 sequence-set (SS3.2.5.1), parsed like any other sequence-set; only the "*" restriction below is specific to this call site. */
if (parse_sequence_set(tok, ranges, nranges, errmsg) == -1)
return (-1);
for (i = 0; i < *nranges; i++) {
return (1);
}
- /*
- * This used to scan for the argument's end and then, separately,
- * strip quotes only if the name both began and ended with one. The
- * two halves could disagree: SELECT "unterminated found no closing
- * quote, so the strip declined to fire and the name reached the store
- * with its leading DQUOTE still attached, coming back as "no such
- * mailbox" while CREATE rejected the identical input at parse.
- */
+ /* Previously scanned the argument end and stripped quotes separately, so an unterminated quote left the leading DQUOTE attached and reached the store as a bogus mailbox name. */
p = args;
if (parse_mailbox_name(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
session_reply(s, tag, "BAD", errmsg);
if (want_condstore)
s->condstore_enabled = 1;
- /*
- * RFC 9051 SS6.3.2: SELECT auto-deselects any current mailbox with
- * untagged OK [CLOSED]. The IDLE snapshot describes the mailbox being
- * deselected, so it goes with it -- a stale one would be diffed
- * against the NEW mailbox's UID list on the next IDLE and report its
- * messages as expunged.
- */
+ /* RFC 9051 SS6.3.2: SELECT auto-deselects the current mailbox with untagged OK [CLOSED], so its IDLE snapshot must go too, else a stale one gets diffed against the new mailbox and misreports expunges. */
if (s->state == SESSION_SELECTED)
session_untagged(s, "OK [CLOSED] Previous mailbox is now closed");
session_reset_idle_baseline(s);
return select_or_examine(s, tag, args, 1);
}
-/*
- * listener.c's own copy of store.c's mailbox_name_valid(), for a fast
- * BAD/NO with no store round trip. store.c re-validates every mailbox
- * name it is sent and is the authority; this copy exists only to save
- * the round trip, so the two MUST stay in step -- it had drifted, missing
- * store.c's rejection of "." / ".." and of the on-disk index filenames.
- * The two index names are open-coded here because their authoritative
- * definitions (STORE_INDEX_NAME, STORE_INDEX_TMP_NAME) live in
- * store_internal.h, which the listener deliberately does not include.
- */
+/* Why the listener validates a mailbox name at all, given the store validates it again: answering BAD/NO here saves a round trip for a name that can never be valid. This used to be a hand-copied duplicate of store.c's rule and had already drifted once; both sides now call the one predicate in mboxname.c, and testing/mailbox_name_test.c drives both entry points over one table so a future one-sided edit fails there. */
int
listener_mailbox_name_valid(const char *name)
{
- size_t i, len;
-
- len = strlen(name);
- if (len == 0 || len >= MBOX_NAME_MAX)
- return (0);
-
- /* RFC 9051 SS5.1, the same predicate store.c uses; see utf8.c */
- if (!utf8_mailbox_ok(name))
- return (0);
-
- for (i = 0; i < len; i++) {
- unsigned char c = (unsigned char)name[i];
-
- if (c == '/')
- return (0);
- if (c < 0x20 || c == 0x7f)
- return (0);
- }
-
- if (strcmp(name, "tmp") == 0 || strcmp(name, "new") == 0 ||
- strcmp(name, "cur") == 0)
- return (0);
-
- /* reject "." and ".." (DELETE "." would destroy INBOX) and the on-disk index filenames */
- if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0)
- return (0);
- if (strcmp(name, "imapd.index") == 0 ||
- strcmp(name, "imapd.index.tmp") == 0 ||
- strcmp(name, "imapd.index.lock") == 0 ||
- strcmp(name, "imapd.uidvalidity") == 0)
- return (0);
-
- return (1);
+ return (mailbox_name_syntax_ok(name));
}
-
+/* Shared refusal for a non-UTF-8 mailbox name on CREATE/RENAME-dest/COPY-MOVE-target; existing-name commands use NONEXISTENT instead, and NO (not BAD) matches SS6.3.4's CREATE failure wording and RFC 5530's CANNOT semantics. Returns 1 if it replied and the caller should stop. */
int
-listener_mailbox_name_is_inbox(const char *name)
-{
- return (strcasecmp(name, "INBOX") == 0);
-}
-
-/*
- * The shared refusal for a mailbox name that is not valid UTF-8, worded
- * identically at every command that asks the server to accept a NEW name:
- * CREATE, RENAME's destination, and COPY/MOVE's target.
- *
- * Commands that ask whether an EXISTING name is there -- DELETE, RENAME's
- * source, STATUS -- keep their RFC 5530 NONEXISTENT answer instead, because
- * for those it is the true and complete story: a name this server refuses to
- * create can never have existed here. SELECT and APPEND validate no name on
- * this side at all and reach store.c's mailbox_name_valid(), which now
- * carries the same check and answers "no such mailbox" -- also true.
- *
- * NO rather than BAD, though BAD is defensible. SS9's ASTRING-CHAR is 7-bit
- * and QUOTED-CHAR's only 8-bit alternatives are UTF8-2/3/4, so a name that is
- * not well-formed UTF-8 is outside the grammar and BAD would fit. But SS6.3.4
- * names NO as CREATE's failure code -- "create failure: can't create mailbox
- * with that name" -- in the same paragraph that requires this rejection, and
- * RFC 5530 SS3's CANNOT ("the operation attempted cannot be performed for
- * reasons that are permanent") says the part that matters to a client, which
- * TRYCREATE would get exactly backwards: do not retry by creating it.
- *
- * Returns 1 if it replied and the caller should stop, 0 if the name is fine.
- */
-int
listener_reject_bad_utf8(struct session *s, const char *tag, const char *name)
{
if (utf8_mailbox_ok(name))
return (1);
}
- if (listener_mailbox_name_is_inbox(mailbox)) {
+ if (mailbox_name_is_inbox(mailbox)) {
session_reply(s, tag, "NO", "[CANNOT] cannot create INBOX");
return (1);
}
return (1);
}
- /*
- * SS9 gives CREATE no arguments after the mailbox name, and
- * copy_move_dispatch() (store_cmd.c) already refuses trailing
- * garbage -- this was the fifth way the mailbox-argument handling
- * disagreed with itself. Silently ignoring the tail is how
- * `CREATE "a"b` used to succeed while naming only `a`. (If
- * CREATE-SPECIAL-USE, RFC 6154, is ever added, its "(USE (\Archive))"
- * goes here rather than through this check.)
- */
+ /* CREATE takes no arguments after the mailbox name; reject trailing garbage rather than silently ignoring it as `CREATE "a"b` used to. (Future CREATE-SPECIAL-USE, RFC 6154, would check here.) */
while (*p == ' ')
p++;
if (*p != '\0') {
s->mbox_op_prev_state = s->state;
s->state = SESSION_CREATING;
- /*
- * A failed compose used to log and fall through with s->state left
- * at SESSION_CREATING -- session_is_busy() (listener.c) then blocks
- * every further command while nothing is in flight to move the
- * state back, so the client waits for a tagged reply that will
- * never be sent. Same fail-soft shape listener_dispatch_search()
- * uses for a dead search-oracle: put the session back and answer.
- */
- if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_CREATE, 0, 0, -1,
- &req, sizeof(req)) == -1) {
- log_warn("session %u: imsg_compose IMSG_MBOX_CREATE", s->id);
+ /* A failed compose used to leave s->state stuck at SESSION_CREATING, so session_is_busy() blocked forever waiting for a reply that would never come; reset state and answer instead, same fail-soft shape every other send_mbox_request() caller uses. The helper logs the failure itself. */
+ if (!send_mbox_request(s, IMSG_MBOX_CREATE, "CREATE",
+ "IMSG_MBOX_CREATE", &req, sizeof(req), NULL, 0, 0)) {
s->state = s->mbox_op_prev_state;
session_reply(s, tag, "NO", "[SERVERBUG] internal error");
return (1);
return (1);
}
- if (listener_mailbox_name_is_inbox(mailbox)) {
+ if (mailbox_name_is_inbox(mailbox)) {
session_reply(s, tag, "NO", "[CANNOT] cannot delete INBOX");
return (1);
}
return (1);
}
- /*
- * SS9 gives DELETE no arguments after the mailbox name, and
- * copy_move_dispatch() (store_cmd.c) already refuses trailing
- * garbage -- this was the fifth way the mailbox-argument handling
- * disagreed with itself. Silently ignoring the tail is how
- * `DELETE "a"b` used to succeed while naming only `a`. (If
- * CREATE-SPECIAL-USE, RFC 6154, is ever added, its "(USE (\Archive))"
- * goes here rather than through this check.)
- */
+ /* DELETE takes no arguments after the mailbox name; reject trailing garbage rather than silently ignoring it as `DELETE "a"b` used to. (Future CREATE-SPECIAL-USE, RFC 6154, would check here.) */
while (*p == ' ')
p++;
if (*p != '\0') {
s->state = SESSION_DELETING;
/* see cmd_create()'s comment on this failure path */
- if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_DELETE, 0, 0, -1,
- &req, sizeof(req)) == -1) {
- log_warn("session %u: imsg_compose IMSG_MBOX_DELETE", s->id);
+ if (!send_mbox_request(s, IMSG_MBOX_DELETE, "DELETE",
+ "IMSG_MBOX_DELETE", &req, sizeof(req), NULL, 0, 0)) {
s->state = s->mbox_op_prev_state;
session_reply(s, tag, "NO", "[SERVERBUG] internal error");
return (1);
return (1);
}
- if (listener_mailbox_name_is_inbox(oldname)) {
+ if (mailbox_name_is_inbox(oldname)) {
/* RFC 9051 SS6.3.6 sanctions this refusal; RFC 5530 CANNOT is the closest fit */
session_reply(s, tag, "NO", "[CANNOT] cannot rename INBOX");
return (1);
}
if (listener_reject_bad_utf8(s, tag, newname))
return (1);
- if (listener_mailbox_name_is_inbox(newname) || !listener_mailbox_name_valid(newname)) {
+ if (mailbox_name_is_inbox(newname) || !listener_mailbox_name_valid(newname)) {
session_reply(s, tag, "BAD", "invalid mailbox name");
return (1);
}
- /*
- * SS9 gives RENAME no arguments after the mailbox name, and
- * copy_move_dispatch() (store_cmd.c) already refuses trailing
- * garbage -- this was the fifth way the mailbox-argument handling
- * disagreed with itself. Silently ignoring the tail is how
- * `RENAME "a"b` used to succeed while naming only `a`. (If
- * CREATE-SPECIAL-USE, RFC 6154, is ever added, its "(USE (\Archive))"
- * goes here rather than through this check.)
- */
+ /* RENAME takes no arguments after the mailbox name; reject trailing garbage rather than silently ignoring it as `RENAME "a"b` used to. (Future CREATE-SPECIAL-USE, RFC 6154, would check here.) */
while (*p == ' ')
p++;
if (*p != '\0') {
s->state = SESSION_RENAMING;
/* see cmd_create()'s comment on this failure path */
- if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_RENAME, 0, 0, -1,
- &req, sizeof(req)) == -1) {
- log_warn("session %u: imsg_compose IMSG_MBOX_RENAME", s->id);
+ if (!send_mbox_request(s, IMSG_MBOX_RENAME, "RENAME",
+ "IMSG_MBOX_RENAME", &req, sizeof(req), NULL, 0, 0)) {
s->state = s->mbox_op_prev_state;
session_reply(s, tag, "NO", "[SERVERBUG] internal error");
return (1);
return (*p == '\0');
}
-/*
- * Renders one mailbox name as an RFC 9051 SS4.3 quoted string, INCLUDING
- * the surrounding DQUOTEs, escaping the two quoted-specials:
- *
- * quoted = DQUOTE *QUOTED-CHAR DQUOTE
- * QUOTED-CHAR = <any TEXT-CHAR except quoted-specials> /
- * "\" quoted-specials / UTF8-2 / UTF8-3 / UTF8-4
- * quoted-specials = DQUOTE / "\"
- *
- * The response builders used to substitute the name into "\"%s\"" raw, so
- * a mailbox whose name contained a DQUOTE ended the quoted string early
- * and one ending in a backslash escaped its own closing quote -- either
- * way the client's parser loses the response boundary. Names like that
- * are reachable: nothing on the input side rejects the characters, and a
- * name can also arrive on disk without passing through IMAP at all.
- *
- * out must be MBOX_QUOTED_MAX bytes for a MBOX_NAME_MAX-bounded name.
- * Returns 0, or -1 if the name did not fit -- in which case out still
- * holds a well-formed but shorter quoted string, the same all-or-nothing
- * discipline envbuf_append_nstring() keeps for ENVELOPE nstrings.
- */
+/* Renders a mailbox name as an RFC 9051 SS4.3 quoted string (with DQUOTEs), escaping DQUOTE and backslash -- without this, a name containing those characters broke the client's parse of the response. out must be MBOX_QUOTED_MAX bytes; returns 0, or -1 (with a shorter well-formed result) if it didn't fit. */
int
quote_mailbox(char *out, size_t outsize, const char *name)
{
char c = name[i];
size_t need;
- /*
- * SS4.3's TEXT-CHAR excludes NUL, CR and LF; substitute
- * rather than drop, the same choice envbuf_append_nstring()
- * makes. Both mailbox_name_valid()s already refuse bytes
- * below 0x20, so this is a boundary guard, not a live path.
- */
+ /* SS4.3's TEXT-CHAR excludes NUL/CR/LF; substitute rather than drop, matching envbuf_append_nstring() -- a boundary guard since mailbox_name_valid() already refuses bytes below 0x20. */
if (c == '\r' || c == '\n')
c = ' ';
return (0);
}
-/*
- * SS9's ATOM-CHAR, for the two productions this file has to tell apart:
- *
- * ATOM-CHAR = <any CHAR except atom-specials>
- * atom-specials = "(" / ")" / "{" / SP / CTL / list-wildcards /
- * quoted-specials / resp-specials
- * ASTRING-CHAR = ATOM-CHAR / resp-specials
- * list-char = ATOM-CHAR / list-wildcards / resp-specials
- *
- * Both productions add resp-specials ("]") back, so "]" is fine either way.
- * The ONLY difference is list-wildcards: "%" and "*" are legal unquoted in a
- * LIST pattern and not in a mailbox name. Getting that backwards breaks
- * LIST "" *, which is the most common command in IMAP, so it is the one thing
- * `wildcards` decides.
- *
- * DELIBERATELY LENIENT ABOUT 8-BIT. Core ABNF's CHAR is %x01-7F, so strictly
- * an unquoted atom cannot carry UTF-8 at all and a non-ASCII mailbox name
- * must be quoted. We accept it unquoted anyway. The atom-specials are refused
- * because each of them actively corrupts something -- DQUOTE and backslash
- * break the server's own quoted-string responses, "{" is a literal
- * announcement, "(" and ")" and SP break argument splitting, "%" and "*" are
- * wildcards -- whereas a bare UTF-8 byte harms nothing and some clients emit
- * it. utf8_mailbox_ok() still judges the bytes afterwards. RFC 9051 SS5.1's
- * own client guidance names only the atom-specials as requiring quoting.
- */
+/* Distinguishes ASTRING-CHAR from list-char: both add resp-specials back, but only list-wildcards ("%","*") differ, mattering for LIST "" * ; deliberately lenient on 8-bit bytes (unquoted UTF-8 allowed) since only atom-specials actually corrupt parsing. */
static int
atom_char_ok(unsigned char c, int wildcards)
{
return (1);
}
-/*
- * The one mailbox-argument parser. Pulls a single SS9 astring (or
- * list-mailbox, when `wildcards`) off *pp, DECODED -- quoted-string escapes
- * resolved -- into out, and advances *pp past it.
- *
- * It replaced three hand-written copies: parse_list_token(), the mailbox half
- * of parse_append_args() (append_cmd.c), and nine lines inline in
- * select_or_examine(). They disagreed with each other about unterminated
- * quotes, empty arguments, over-length names and "", and all three shared the
- * two defects that mattered:
- *
- * - none of them undid quote_mailbox()'s escaping, so the server could not
- * read back a name it had itself just written: CREATE "a\"b" created a
- * mailbox called a\ and LIST then advertised a name SELECT could not
- * find;
- * - none of them enforced atom-specials, so CREATE a"b succeeded and every
- * later LIST response carried a quoted string that ended early.
- *
- * LITERALS ARE REFUSED, NOT PARSED. SS9 allows one here (mailbox = "INBOX" /
- * astring, astring = 1*ASTRING-CHAR / string, string = quoted / literal), and
- * supporting one would mean resuming command parsing after the octets arrive
- * -- the listener's literal machinery is terminal by construction
- * (line_nonsync_literal() matches only a trailing "{n+}", and literal_pending
- * has exactly one setter, in cmd_append()). Until that exists, refusing is the
- * honest answer and the safe one: before this, "CREATE {5}" was read as an
- * atom and created a directory literally named "{5}" while the client sat
- * waiting for a "+" continuation that was never coming.
- *
- * It does NOT validate the name. Whether these bytes are an acceptable
- * mailbox is a separate question, asked by listener_mailbox_name_valid() and
- * again by store.c's mailbox_name_valid() on the far side of the imsg
- * boundary. Merging the two would undo that double-check.
- *
- * Returns 0, or -1 with *errmsg set to text fit for a tagged BAD.
- */
+/* The one mailbox-argument parser: pulls a decoded SS9 astring (or list-mailbox when wildcards) off *pp, replacing three divergent hand-written copies that mishandled quote-escaping and atom-specials; literals are refused outright since the listener's literal machinery is terminal-only; does not validate the name itself. Returns 0, or -1 with *errmsg set for a tagged BAD. */
static int
parse_mailbox_arg(char **pp, char *out, size_t outsize, int wildcards,
const char **errmsg)
return (0);
}
-/*
- * The two grammars, named at the call site rather than hidden behind a
- * boolean. RFC 9051 SS9: list = "LIST" [SP list-select-opts] SP mailbox SP
- * mbox-or-pat -- so LIST's REFERENCE is a plain mailbox and only its PATTERN
- * may carry wildcards unquoted. Every other command taking a mailbox
- * (create/delete/rename/select/examine/status/copy/move/append) uses the
- * strict one.
- */
+/* Two grammars named explicitly rather than via a boolean: LIST's REFERENCE is a plain mailbox while its PATTERN allows wildcards (RFC 9051 SS9); every other mailbox-taking command uses the strict grammar. */
int
parse_mailbox_name(char **pp, char *out, size_t outsize, const char **errmsg)
{
/* SS6.3.9: unaccepted pattern MUST be silently ignored; INBOX answered synchronously, no store round trip */
if (list_pattern_match(canon, "INBOX", 1)) {
- /*
- * "()", SS7.3.1 makes every attribute optional; INBOX has no
- * children and is selectable.
- *
- * Quoted, though bare INBOX is a legal atom and SS9's
- * mailbox rule names it explicitly ("INBOX" / astring), so
- * either spelling parses to the same name. This line is
- * answered here in the listener without a store round trip,
- * so it is the one mailbox name that never passes through
- * quote_mailbox() -- and the result was a server that spelled
- * INBOX two different ways in one session, bare here and
- * quoted in SELECT's own LIST line (store_ipc.c). Nothing was
- * wrong with either, but a server should name a mailbox the
- * same way every time it names it.
- */
+ /* "()" -- SS7.3.1 makes attributes optional, INBOX has none and is selectable; quoted here (though bare INBOX also parses) so this listener-only answer matches how SELECT's LIST line spells INBOX elsewhere, keeping one consistent spelling per session. */
snprintf(text, sizeof(text), "%s () \"/\" \"INBOX\"", kw);
session_untagged(s, text);
}
s->mbox_op_prev_state = s->state;
s->state = SESSION_LISTING;
- /* see cmd_create()'s comment on this failure path */
- if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_LIST, 0, 0, -1,
- NULL, 0) == -1) {
- log_warn("session %u: imsg_compose IMSG_MBOX_LIST", s->id);
+ /* see cmd_create()'s comment on this failure path; IMSG_MBOX_LIST carries no payload at all, so this is send_mbox_request()'s no-trailing-array form with a NULL request too */
+ if (!send_mbox_request(s, IMSG_MBOX_LIST, cmdname, "IMSG_MBOX_LIST",
+ NULL, 0, NULL, 0, 0)) {
s->state = s->mbox_op_prev_state;
session_reply(s, tag, "NO", "[SERVERBUG] internal error");
return (1);
else if (strcasecmp(tok, "HIGHESTMODSEQ") == 0)
attrs |= STATUS_ATT_HIGHESTMODSEQ;
else if (strcasecmp(tok, "RECENT") == 0)
- /* IMAP4rev2 dropped RECENT (RFC 9051 SS2.3.2), but
- * real clients (Canary Mail) still ask for it --
- * accept it and always answer 0 rather than BAD-
- * failing the whole command over one legacy token. */
+ /* IMAP4rev2 dropped RECENT (RFC 9051 SS2.3.2), but real clients (Canary Mail) still ask for it -- accept it and answer 0 rather than BAD-failing the whole command. */
attrs |= STATUS_ATT_RECENT;
else {
session_reply(s, tag, "BAD", "unknown status-att");
return (1);
}
- if (!listener_mailbox_name_is_inbox(mailbox) && !listener_mailbox_name_valid(mailbox)) {
+ if (!mailbox_name_is_inbox(mailbox) && !listener_mailbox_name_valid(mailbox)) {
/* RFC 5530 NONEXISTENT: a malformed name can never have existed */
session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
return (1);
s->status_prev_state = s->state;
s->state = SESSION_STATUSING;
- /* see cmd_create()'s comment on this failure path */
- if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_STATUS, 0, 0, -1,
- &req, sizeof(req)) == -1) {
- log_warn("session %u: imsg_compose IMSG_MBOX_STATUS", s->id);
+ /* see cmd_create()'s comment on this failure path; STATUS restores its own status_prev_state, not mbox_op_prev_state */
+ if (!send_mbox_request(s, IMSG_MBOX_STATUS, "STATUS",
+ "IMSG_MBOX_STATUS", &req, sizeof(req), NULL, 0, 0)) {
s->state = s->status_prev_state;
session_reply(s, tag, "NO", "[SERVERBUG] internal error");
return (1);
blob - c3ffa39ac8f46be40627a05f38e6af585447814a
blob + 3fcd064ddce9640cd84f6e7c6ea2304f73b3760b
--- src/main.c
+++ src/main.c
__dead static void
usage(void)
{
- /*
- * -x is deliberately absent, matching imapd.8's SYNOPSIS: it names
- * the role a re-exec'd child takes and is not an operator-facing
- * option (see imapd.8's DESCRIPTION, which says so in those words).
- */
+ /* -x is deliberately absent from getopt, per imapd.8: it names a re-exec'd child's role, not an operator-facing option. */
fprintf(stderr,
"usage: %s [-dVv] [-D macro=value] [-f file]\n",
getprogname());
memset(&conf, 0, sizeof(conf));
- /*
- * Every role (parent, listener, auth, store) passes through this
- * same main(). Set here, before anything forks, SIG_IGN
- * survives fork(2)+execve(2) into each child, so this is the one
- * place that reliably reaches all of them.
- */
+ /* Set before any fork(2) so SIG_IGN survives into every role (parent/listener/auth/store) via fork+execve -- this is the one place that reliably reaches all of them. */
signal(SIGPIPE, SIG_IGN);
while ((ch = getopt(argc, argv, "D:df:Vvx:")) != -1) {
usage();
}
- /*
- * log_procinit() BEFORE log_init(), not after. log_init() hands
- * log_procname's address to openlog(3), which stores the pointer
- * rather than a copy (libc's openlog_r(): data->log_tag = ident) and
- * dereferences it when each message is formatted. Setting the role
- * name first means openlog(3) is given a buffer that already says
- * "listener"/"auth"/... and log.c never mutates a string syslog(3)
- * is holding.
- */
+ /* log_procinit() runs before log_init() because openlog(3) stores log_procname's pointer rather than a copy, so the role name must be set first or log.c would later mutate a string syslog(3) still holds. */
log_procinit(log_procname(role));
log_init(debug, verbose);
blob - 42996699578eecbbbd94fab1b89340132bc1404a
blob + eb4f077dea5b2e1e6ebfa58f99ab8275bb409e10
--- src/mbox_copy.c
+++ src/mbox_copy.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * mbox_copy.c, COPY and MOVE request handling, both
- * same-mailbox and cross-mailbox.
- */
+/* mbox_copy.c: COPY and MOVE request handling, both same-mailbox and cross-mailbox. */
#include <sys/types.h>
#include <sys/file.h>
struct seq_range resolved[SEQSET_MAX_RANGES];
uint32_t nresolved, max_hi;
- /*
- * "*" and the backwards-range swap (RFC 9051 SS9: a seq-range is
- * unordered) are both handled by seqset_resolve() now: seqno-space hi is clamped to idx->nlines,
- * UID-space hi is left alone (an explicit UID above the highest
- * in use is just a range matching nothing extra, not an error).
- */
+ /* "*" and backwards-range swaps (RFC 9051 SS9) are handled by seqset_resolve(): seqno-space hi is clamped to idx->nlines, UID-space hi is left alone since an out-of-range UID just matches nothing extra. */
nresolved = seqset_resolve(ranges, nranges, req->by_uid ?
index_max_uid(idx) : (uint32_t)idx->nlines, !req->by_uid,
resolved);
for (i = 0; i < idx->nlines; i++) {
struct index_rec rec;
const char *lp;
+ enum seqset_pos pos;
char suffix[64];
off_t size;
char path[600];
if (index_parse_line(idx->lines[i], &rec) == -1)
continue;
- if (req->by_uid) {
- if (rec.uid > max_hi)
- break;
- if (!seqset_contains(resolved, nresolved, rec.uid))
- continue;
- } else {
- if ((uint32_t)(i + 1) > max_hi)
- break;
- if (!seqset_contains(resolved, nresolved,
- (uint32_t)(i + 1)))
- continue;
- }
+ /* same range check as handle_mbox_fetch()/handle_mbox_store(); this loop is 0-based, so the seqno it passes is i + 1 */
+ pos = seqset_position(resolved, nresolved, max_hi, req->by_uid,
+ rec.uid, (uint32_t)(i + 1));
+ if (pos == SEQSET_PAST_END)
+ break;
+ if (pos == SEQSET_SKIP)
+ continue;
if (locate_message_file(rec.basename, &size, suffix,
sizeof(suffix)) == -1) {
"%s, failing COPY", session_id, rec.basename);
goto fail;
}
- /*
- * The index-format check used to run in commit_copy_messages()'s
- * second loop -- after every message file had already been
- * renamed into the destination, so a source record with a ':'
- * or a newline in its keywords failed the COPY with the whole
- * commit already on disk. It is knowable here, before anything
- * is written, which is where every other refusal in this
- * function lives. commit_copy_messages() keeps its own copy of
- * the check as defence in depth.
- */
+ /* Moved here from commit_copy_messages()'s second loop, which used to fail a bad ':'/newline in keywords only after files were already renamed; checking before anything is written matches every other refusal in this function, and commit_copy_messages() keeps its own copy as defence in depth. */
if (!index_field_valid(cs.keywords)) {
log_warnx("session %u: COPY: unsafe keywords field on "
"%s, failing COPY", session_id, rec.basename);
"long", session_id);
goto fail;
}
- /* same reasoning as the keywords check above; this name is
- * generated locally, so a failure here means a hostname or
- * timestamp carrying a ':' or a newline. */
+ /* Same reasoning as the keywords check above; a failure here means a locally generated hostname or timestamp carries a ':' or newline. */
if (!index_basename_valid(cs.basename)) {
log_warnx("session %u: COPY: generated basename is "
"unsafe for the index, failing COPY", session_id);
goto fail;
}
if (n == 0) {
- /*
- * The file shrank between
- * locate_message_file()'s stat and this
- * read. Copying the prefix would put a
- * truncated message in the destination
- * and still answer OK; RFC 9051 SS6.4.7
- * makes COPY all-or-nothing, and every
- * other integrity failure in this
- * function fails the whole operation.
- */
+ /* The file shrank between locate_message_file()'s stat and this read; copying the truncated prefix would answer OK for a partial message, violating RFC 9051 SS6.4.7's all-or-nothing COPY, so fail the whole operation like every other integrity failure here. */
log_warnx("session %u: COPY: %s shrank "
"during staging (%zu of %zu bytes "
"read), failing COPY", session_id,
for (i = 0; i < nstaged; i++) {
uint32_t dest_uid = destidx->uidnext;
- /* stage_copy_messages() already refused these; kept as defence
- * in depth, since a bad line here would corrupt the index. */
+ /* stage_copy_messages() already refused these; kept as defence in depth since a bad line here would corrupt the index. */
if (!index_basename_valid(staged[i].basename) ||
!index_field_valid(staged[i].keywords)) {
log_warnx("session %u: COPY: unsafe field in staged "
return (1);
rollback:
- /*
- * RFC 9051 SS6.4.7: "partial copy MUST NOT be done". The destination
- * index is only written by the index_save() above, so a failure before
- * it already leaves the mailbox's OBSERVABLE state untouched --
- * refresh_index() adopts unknown files from new/ only, never cur/, so
- * an orphan here is invisible to LIST, FETCH, SEARCH and every UID.
- * What it is not is reclaimed: nothing ever deletes it, and a client
- * retrying a COPY that fails on ENOSPC leaks another set each time.
- * So undo the renames rather than leave them.
- *
- * A failure to unlink is logged and stepped over: this path is already
- * the error path, and one stubborn file should not stop the rest from
- * being cleaned up.
- */
+ /* RFC 9051 SS6.4.7 forbids partial copy: a failure before index_save() leaves observable state untouched but orphans unlinked files (invisible but never reclaimed, leaking on retry), so undo the renames; an unlink failure here is logged and stepped over rather than aborting cleanup. */
while (ncommitted > 0) {
char curpath[320];
char letters[8];
struct index_lock *il_a, struct index_lock *il_b, int ok,
struct imsg_mbox_result *result, struct imsgev *iev)
{
- /* both are idempotent, and safe on an INDEX_LOCK_INIT struct that
- * lock_copy_move_mailboxes() never got as far as acquiring */
+ /* both are idempotent, and safe on an INDEX_LOCK_INIT struct that lock_copy_move_mailboxes() never got as far as acquiring. */
index_lock_release(il_a);
index_lock_release(il_b);
finish_copy_move(&idx_a, &idx_b, &il_a, &il_b, ok, &result, iev);
}
-/*
- * Repairs move_same_mailbox()'s in-place compaction when it has to abandon
- * partway, and returns the new write index.
- *
- * Mid-compaction the array is not something index_free() can walk. Slots
- * [0, out) hold the kept pointers; slots [out, dropped] hold STALE DUPLICATES
- * of pointers that are either still live at [0, out) or have already been
- * passed to free(); and idx->nlines still records the length the array had
- * before compaction started. index_free() frees every slot in
- * [0, idx->nlines), so it would free those duplicates a second time.
- *
- * `dropped` is the index whose line the caller has just freed. Sliding the
- * not-yet-visited tail (dropped + 1 onward) down over the stale region and
- * publishing the resulting length leaves the array holding exactly one live,
- * distinct pointer per slot -- no double free, and no leak of the entries the
- * loop never reached. The caller is on its failure path and never calls
- * index_save(), so the on-disk index is untouched either way; what this
- * guarantees is that index_free() can run.
- */
+/* Repairs move_same_mailbox()'s in-place compaction when it abandons partway: slides the unvisited tail down over the stale duplicate region left by the partial compaction and returns the new length, so index_free() doesn't double-free; the on-disk index is untouched since the caller never reaches index_save() on this path. */
static size_t
compaction_bail(struct mbox_index *idx, size_t dropped, size_t out)
{
*nmoved_out = 0;
- /*
- * "*" and the backwards-range swap (RFC 9051 SS9: a seq-range is
- * unordered) are both handled by seqset_resolve() now.
- */
+ /* "*" and backwards-range swaps (RFC 9051 SS9) are both handled by seqset_resolve() now. */
nresolved = seqset_resolve(ranges, nranges, req->by_uid ?
index_max_uid(idx) : (uint32_t)idx->nlines, !req->by_uid,
resolved);
blob - d0fe034dfe684964a06008c9365052a2ef7d948c
blob + 0eef36a8c22b55dc011e10ccafac378d8cf8bd30
--- src/mbox_fetch.c
+++ src/mbox_fetch.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * mbox_fetch.c, FETCH and STATUS request handling.
- */
+/* FETCH and STATUS request handling. */
#include <sys/types.h>
#include <sys/file.h>
#include "log.h"
#include "store_internal.h"
-/*
- * Shared compose-and-send for the four IMSG_MBOX_FETCH_{HEADER,BODY,
- * ENVELOPE,BODYSTRUCTURE} messages handle_mbox_fetch() below sends per
- * message.
- */
+/* Shared compose-and-send helper for the four IMSG_MBOX_FETCH_{HEADER,BODY,ENVELOPE,BODYSTRUCTURE} messages handle_mbox_fetch() sends. */
static void
fetch_send_part(struct imsgev *iev, int imsg_type, const char *what,
const void *meta, size_t metalen, int found, const void *buf,
for (i = 1; i <= (uint32_t)idx.nlines; i++) {
struct imsg_mbox_fetch_meta meta;
struct index_rec rec;
+ enum seqset_pos pos;
off_t size = 0;
char suffix[64];
int have_file = 0;
if (index_parse_line(idx.lines[i - 1], &rec) == -1)
continue;
- /* position-space (i) or UID-space (rec.uid) range check, per req->by_uid; single ascending pass */
- if (req->by_uid) {
- if (rec.uid > max_hi)
- break;
- if (!seqset_contains(resolved, nresolved, rec.uid))
- continue;
- } else {
- if (i > max_hi)
- break;
- if (!seqset_contains(resolved, nresolved, i))
- continue;
- }
+ /* position-space (i) or UID-space (rec.uid) range check, per req->by_uid; single ascending pass, so PAST_END can stop it */
+ pos = seqset_position(resolved, nresolved, max_hi, req->by_uid,
+ rec.uid, i);
+ if (pos == SEQSET_PAST_END)
+ break;
+ if (pos == SEQSET_SKIP)
+ continue;
if (req->has_changedsince && rec.modseq <= req->changedsince)
continue;
goto send;
}
- /*
- * STATUS is the one command that reaches a mailbox without SELECTing
- * it first, so it is the one read-only path that can be the first
- * thing ever to touch a mailbox. Persist the header index_load() just
- * invented, or the UIDVALIDITY reported here is not the one the next
- * caller will see.
- */
+ /* STATUS can be the first command to touch a mailbox without a prior SELECT, so it must persist any header index_load() just invented -- otherwise the UIDVALIDITY reported here won't match what the next caller sees. */
if (idx.fresh && index_save(&idx) == -1)
log_warnx("session %u: STATUS: could not persist the new "
"index header", session_id);
blob - ab1f06c627ed1f742f109973e80b724c04feb825
blob + ae8fbfb76152385535d2ef3b6ad95205d9297596
--- src/mbox_manage.c
+++ src/mbox_manage.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * mbox_manage.c, CREATE/DELETE/RENAME/LIST/APPEND/SELECT
- * request handling.
- */
+/* mbox_manage.c: CREATE/DELETE/RENAME/LIST/APPEND/SELECT request handling. */
#include <sys/types.h>
#include <sys/file.h>
0 : -1);
}
+/* Saves the current selection and moves to the maildir root, the opening move of CREATE/DELETE/RENAME/LIST -- all of which operate on mailbox names relative to the root rather than on whatever a SELECT left the cwd at. Returns 0 with *saved holding the previous directory (restore it with select_mailbox_dir(saved), which each caller's own send: epilogue does differently), or -1 having logged. `subject` names the mailbox in the log and may be NULL where the command has none (LIST). handle_mbox_append() and handle_mbox_status() deliberately don't use this: both visit a specific target rather than the root, and both report a failure to get there as "no such mailbox" rather than as an internal error. */
+static int
+mbox_root_enter(char *saved, size_t savedsize, const char *what,
+ const char *subject)
+{
+ const char *sep = subject != NULL ? " " : "";
+ const char *who = subject != NULL ? subject : "";
+
+ if (save_current_mailbox_dir(saved, savedsize) == -1) {
+ log_warnx("session %u: %s%s%s: current_mailbox_dir truncated, "
+ "can't happen (same-size buffers)", session_id, what, sep,
+ who);
+ return (-1);
+ }
+ if (select_mailbox_dir("") == -1) {
+ log_warnx("session %u: %s%s%s: couldn't reach maildir root",
+ session_id, what, sep, who);
+ return (-1);
+ }
+ return (0);
+}
+
/* IMSG_MBOX_CREATE (RFC 9051 SS6.3.4); mkdir's EEXIST here means "already exists" (required refusal), unlike ensure_maildir_dirs()'s idempotent use */
void
}
/* mkdir/ensure_maildir_dirs() below need cwd at maildir root, not wherever a SELECTed mailbox left it, visit root first */
- if (save_current_mailbox_dir(saved, sizeof(saved)) == -1) {
- log_warnx("session %u: CREATE %s: current_mailbox_dir "
- "truncated, can't happen (same-size buffers)",
- session_id, req->mailbox);
+ if (mbox_root_enter(saved, sizeof(saved), "CREATE",
+ req->mailbox) == -1) {
result.error = MBOX_OP_ERR_GENERIC;
goto send;
}
- if (select_mailbox_dir("") == -1) {
- log_warnx("session %u: CREATE %s: couldn't reach maildir "
- "root", session_id, req->mailbox);
- result.error = MBOX_OP_ERR_GENERIC;
- goto send;
- }
switched = 1;
if (mkdir(req->mailbox, 0700) == -1) {
}
}
- /*
- * The index, its lock file, and any temp left behind by an
- * index_save() that was interrupted between creating the temp and
- * renaming it. The caller rmdir(2)s the mailbox directory next, so
- * every file this daemon puts there has to be named here -- the temp
- * was already missing, which meant a crash mid-save left a file that
- * made DELETE fail with ENOTEMPTY from then on.
- */
+ /* Removes the index, its lock, and any leftover index_save() temp -- rmdir(2) follows, so every file this daemon creates here must be named or a crash mid-save leaves DELETE failing with ENOTEMPTY. */
{
static const char *files[] = {
STORE_INDEX_NAME,
}
/* visit root first, same as handle_mbox_create() */
- if (save_current_mailbox_dir(saved, sizeof(saved)) == -1) {
- log_warnx("session %u: DELETE %s: current_mailbox_dir "
- "truncated, can't happen (same-size buffers)",
- session_id, req->mailbox);
+ if (mbox_root_enter(saved, sizeof(saved), "DELETE",
+ req->mailbox) == -1) {
result.error = MBOX_OP_ERR_GENERIC;
goto send;
}
- if (select_mailbox_dir("") == -1) {
- log_warnx("session %u: DELETE %s: couldn't reach maildir "
- "root", session_id, req->mailbox);
- result.error = MBOX_OP_ERR_GENERIC;
- goto send;
- }
switched = 1;
if (lstat(req->mailbox, &st) == -1 || !S_ISDIR(st.st_mode)) {
send:
if (switched) {
- /*
- * Deleting this session's own selection: there is nothing to
- * restore to, so don't try (and don't warn -- the client
- * asked for exactly this). cwd is already the maildir root
- * and current_mailbox_dir is already "", both set by the
- * select_mailbox_dir("") above, so this process's own state
- * stays consistent; what must not survive is the SS6.2 gate.
- * "" means INBOX and every handler resolves its mailbox from
- * cwd, so leaving mailbox_selected set would let a later
- * FETCH/STORE/EXPUNGE operate on INBOX under a name the
- * client believes it just deleted. Mirrors
- * handle_mbox_expunge()'s CLOSE reset (mbox_store.c) and
- * listener's own post-DELETE transition to
- * SESSION_AUTHENTICATED (store_ipc.c's
- * session_finish_mbox_op()). Note this branch is reachable
- * with saved == "" only if req->mailbox were "", which
- * mailbox_name_is_inbox() refused above.
- */
+ /* Deleting this session's own selection: cwd/current_mailbox_dir are already reset, but mailbox_selected must also clear -- else a later FETCH/STORE/EXPUNGE could still operate on INBOX under the deleted name. */
if (result.error == MBOX_OP_OK &&
strcmp(saved, req->mailbox) == 0)
mailbox_selected = 0;
goto send;
}
- if (save_current_mailbox_dir(saved, sizeof(saved)) == -1) {
- log_warnx("session %u: RENAME %s -> %s: current_mailbox_dir "
- "truncated, can't happen (same-size buffers)",
- session_id, req->oldname, req->newname);
+ /* the log names the source only; the destination is not yet involved in getting to the root */
+ if (mbox_root_enter(saved, sizeof(saved), "RENAME",
+ req->oldname) == -1) {
result.error = MBOX_OP_ERR_GENERIC;
goto send;
}
- if (select_mailbox_dir("") == -1) {
- log_warnx("session %u: RENAME %s -> %s: couldn't reach "
- "maildir root", session_id, req->oldname, req->newname);
- result.error = MBOX_OP_ERR_GENERIC;
- goto send;
- }
switched = 1;
if (lstat(req->oldname, &st) == -1 || !S_ISDIR(st.st_mode)) {
memset(&result, 0, sizeof(result));
- if (save_current_mailbox_dir(saved, sizeof(saved)) == -1) {
- log_warnx("session %u: LIST: current_mailbox_dir truncated "
- "-- can't happen (same-size buffers)", session_id);
+ /* LIST names no mailbox, so it has no subject for the log */
+ if (mbox_root_enter(saved, sizeof(saved), "LIST", NULL) == -1) {
result.error = MBOX_OP_ERR_GENERIC;
goto send;
}
- if (select_mailbox_dir("") == -1) {
- log_warnx("session %u: LIST: couldn't reach maildir root",
- session_id);
- result.error = MBOX_OP_ERR_GENERIC;
- goto send;
- }
switched = 1;
if ((dp = opendir(".")) == NULL) {
strcmp(de->d_name, "..") == 0)
continue;
- /*
- * Directory-ness FIRST, before any name check, so that the
- * refusal below only ever sees something that could actually
- * be a mailbox.
- *
- * The maildir root also holds imapd.index, imapd.index.tmp,
- * imapd.index.lock and imapd.uidvalidity. Only the first of
- * those is named in the skip list below; the other three used
- * to fall through to mailbox_name_valid(), which refuses them
- * by its reserved-name list -- correct, but for a reason that
- * has nothing to do with a user losing a mailbox. That was
- * invisible while the skip was silent. The moment it started
- * logging, the very first LIST reported imapd.index.lock as a
- * mailbox that would "not be listed or selectable", which is
- * true of a lock file and alarming to say. A warning that
- * cries wolf on every connection is worse than no warning:
- * it teaches the operator to skip the line that matters.
- *
- * lstat(2) here is safe before validation: d_name comes from
- * readdir(2) on the current directory and so cannot contain
- * "/", which means the lookup cannot leave cwd.
- */
+ /* Check directory-ness first so the refusal below only fires on real mailbox candidates; the index/lock/uidvalidity files must stay in the silent skip list or a correct-but-alarming warning fires on every LIST. lstat(2) is safe pre-validation since readdir(2) d_name can't contain "/". */
if (lstat(de->d_name, &st) == -1 || !S_ISDIR(st.st_mode))
continue;
- /*
- * Directories that are not mailboxes. tmp/new/cur are
- * INBOX's own maildir subdirectories and are the reason this
- * list has to survive the reordering above. STORE_INDEX_NAME
- * is now reachable only as a DIRECTORY so named, which imapd
- * will not create; skipping it quietly rather than warning
- * keeps a reserved name from ever being reported as lost
- * mail.
- */
+ /* Directories that aren't mailboxes: tmp/new/cur (INBOX's own maildir subdirs) and a directory named like STORE_INDEX_NAME, skipped quietly so a reserved name is never reported as lost mail. */
if (strcmp(de->d_name, "tmp") == 0 ||
strcmp(de->d_name, "new") == 0 ||
strcmp(de->d_name, "cur") == 0 ||
continue;
if (!mailbox_name_valid(de->d_name)) {
- /*
- * Not silent, and now it cannot be a false alarm:
- * everything reaching here is a directory that is not
- * one of this daemon's own, so it is somebody's mail.
- * Skipping it makes that mail unreachable over IMAP
- * with no error the user can see -- a client is told
- * only that the mailbox is not there. Since the SS5.1
- * UTF-8 rule arrived after this server had already
- * been creating mailboxes, the operator's log is the
- * one place the reason can still be found.
- *
- * Once per store child, though, not once per LIST: a
- * store child serves one connection (see store.c's
- * per-connection fork), so this is about one line per
- * client session, and Apple Mail LISTs often enough
- * that repeating it would bury the log it is meant to
- * improve.
- */
+ /* Logged (not silent): anything reaching here is real mail made invisible over IMAP, likely by the later SS5.1 UTF-8 rule, and the operator's log is the only place to see why -- logged once per store child, not once per LIST, so frequent Apple Mail LISTs don't bury it. */
if (!skip_warned) {
skip_warned = 1;
log_warnx("session %u: LIST: skipping %s: "
blob - 1854a72f0dc60c2e2f21686eb75c7b61bb93a827
blob + d0484b76bce282d8bf32bb8b6d5e97b61f295c21
--- src/mbox_search.c
+++ src/mbox_search.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * mbox_search.c, SEARCH key evaluation.
- */
+/* SEARCH key evaluation. */
#include <sys/types.h>
#include <sys/file.h>
static int
append_kw_token(char *out, size_t outsize, int *firstp, const char *tok)
{
- /*
- * This CAN happen, on the ADD path: out is MBOX_FLAGS_MAX and so are
- * both of merge_keywords()'s inputs, but ADD concatenates them. (The
- * comment here used to say "can't happen (same-size MBOX_FLAGS_MAX
- * buffers)", which is true of SET and REMOVE and false of ADD.)
- * strlcat(3) has already written its truncated prefix by the time it
- * reports this, so *out now ends mid-keyword; merge_keywords()'s
- * caller must discard it rather than store it.
- */
+ /* Can happen on the ADD path: out and both inputs are MBOX_FLAGS_MAX but ADD concatenates them, and strlcat(3) has already written a truncated prefix by the time it reports failure, so the caller must discard *out rather than store it. */
if (!*firstp && strlcat(out, ",", outsize) >= outsize) {
log_warnx("session %u: merge_keywords: keyword set does not "
"fit in %zu bytes", session_id, outsize);
return (1);
}
-/*
- * Computes *out by applying mode/new_kws to old_kws; RFC 9051 SS6.4.6: SET
- * ignores old_kws entirely, unlike ADD/REMOVE. Deduplicates always.
- *
- * Returns 0, or -1 if the result did not fit. That can really happen on the
- * ADD path and only there: out, old_kws and new_kws are all MBOX_FLAGS_MAX,
- * which is enough for either INPUT but not for ADD's CONCATENATION of the two.
- * It used to return void, and append_kw_token()'s strlcat(3) writes its
- * truncated prefix before reporting failure -- so an over-long ADD left a
- * keyword cut mid-token in *out ("important" becoming "impor"), which
- * handle_mbox_store() then wrote to the index under a tagged OK, losing the
- * message's other keywords permanently. The caller now fails the STORE
- * instead; parse_store_flags() already takes the same line with the client's
- * own list, rejecting rather than truncating.
- */
+/* Applies mode/new_kws to old_kws into *out (SET ignores old_kws per RFC 9051 SS6.4.6, dedup always); returns -1 if ADD's concatenation overflows MBOX_FLAGS_MAX, since silently truncating used to cut a keyword mid-token and drop the others under a tagged OK. */
int
merge_keywords(int mode, const char *old_kws, const char *new_kws,
char *out, size_t outsize)
max_uid = index_max_uid(&idx); /* shared with UID FETCH/STORE/EXPUNGE's own "*" resolution */
- /*
- * Resolve each SEQSET/UIDSET node's "*" (and, since RFC 9051 SS9
- * treats a seq-range as unordered, swap a backwards range) via
- * the same seqset_resolve() every other sequence-set consumer
- * (FETCH/STORE/COPY/MOVE/UID EXPUNGE) already uses, one range at
- * a time -- SEARCH's nodes are scattered through a postfix tree
- * possibly mixed with AND/OR/NOT, so they can't be batched into
- * one seqset_resolve() call the way a single command's whole
- * sequence-set can. Same clamp_hi convention as
- * handle_mbox_fetch()/handle_mbox_store(): SEQSET clamps hi down
- * to idx.nlines, UIDSET doesn't (an explicit UID above the
- * highest in use just matches nothing extra, not an error).
- */
+ /* Resolves each SEQSET/UIDSET node's "*" and normalizes backwards ranges one at a time via the shared seqset_resolve() (SEARCH's nodes sit scattered in a postfix AND/OR/NOT tree, so they can't be batched like a single sequence-set); SEQSET clamps hi to idx.nlines like FETCH/STORE, UIDSET doesn't. */
for (i = 0; i < nnodes; i++) {
struct search_node *n = &nodes[i];
struct seq_range in, out;
blob - 9857d7314f5d59282511982e74888f12a9595f4c
blob + bd678679600d47a3a57b7ab7ec7b60a7bd061791
--- src/mbox_store.c
+++ src/mbox_store.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * mbox_store.c, STORE and EXPUNGE request handling.
- */
+/* mbox_store.c, STORE and EXPUNGE request handling. */
#include <sys/types.h>
#include <sys/file.h>
char oldpath[600], newpath[600];
off_t size;
uint64_t this_modseq;
+ enum seqset_pos pos;
int in_new, len, real_change, send_fetch;
if (index_parse_line(idx.lines[i - 1], &rec) == -1)
continue;
- /* same position-space/UID-space range check as handle_mbox_fetch() */
- if (req->by_uid) {
- if (rec.uid > max_hi)
- break;
- if (!seqset_contains(resolved, nresolved, rec.uid))
- continue;
- } else {
- if (i > max_hi)
- break;
- if (!seqset_contains(resolved, nresolved, i))
- continue;
- }
+ /* same range check as handle_mbox_fetch(), and now literally the same function */
+ pos = seqset_position(resolved, nresolved, max_hi, req->by_uid,
+ rec.uid, i);
+ if (pos == SEQSET_PAST_END)
+ break;
+ if (pos == SEQSET_SKIP)
+ continue;
if (req->has_unchangedsince &&
rec.modseq > req->unchangedsince) {
}
if (merge_keywords(req->mode, rec.keywords, req->keywords,
newkeywords, sizeof(newkeywords)) == -1) {
- /*
- * The merged set does not fit, and newkeywords now
- * holds a mid-keyword truncation. Storing it would
- * lose this message's other keywords permanently and
- * invent one it never had, under a tagged OK, so fail
- * the STORE instead -- RFC 9051 SS6.4.6 imposes no
- * all-or-nothing requirement, and a NO is the only
- * answer that does not corrupt the index.
- */
+ /* Merged keyword set doesn't fit -- fail the STORE rather than store a mid-keyword truncation under a tagged OK; RFC 9051 SS6.4.6 permits this, and a NO is the only answer that doesn't corrupt the index. */
log_warnx("session %u: STORE: merged keyword set for "
"uid %u exceeds %zu bytes, failing STORE",
session_id, rec.uid, sizeof(newkeywords));
}
done:
- /*
- * SS6.2's gate: mirrors listener's own unconditional post-CLOSE
- * state transition (store_ipc.c's session_handle_mbox_result():
- * s->state = was_close ? SESSION_AUTHENTICATED : SESSION_SELECTED,
- * set before checking res->error) -- CLOSE deselects regardless of
- * whether the expunge itself succeeded, so this does too.
- */
+ /* CLOSE deselects regardless of expunge success, mirroring store_ipc.c's unconditional post-CLOSE state transition (RFC 9051 SS6.2). */
if (req->silent)
mailbox_selected = 0;
blob - 4e80f70a1e17498db4c0df965a2c82ca5d9de501
blob + 99ef172756d73563a0f92eec8aaa755466147e42
--- src/mime.c
+++ src/mime.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * mime.c, header and MIME parsing shared across FETCH,
- * ENVELOPE, and BODYSTRUCTURE: content-type, multipart splitting, and
- * per-part location.
- */
+/* mime.c: header and MIME parsing shared across FETCH, ENVELOPE, and BODYSTRUCTURE -- content-type, multipart splitting, and per-part location. */
#include <sys/types.h>
#include <sys/file.h>
#include "log.h"
#include "store_internal.h"
-/* Scans cur/ for the first "<basename>:*" entry (maildir's flag-suffix convention);
-* shared cur/ fallback dirent scan for locate_message_file()/open_message_file() below.
-* Writes the matched entry's "cur/<name>" path to path[]; if suffix_out != NULL,
-* also copies the matched entry's suffix there (truncation there counts as no-match,
-* same as the inline check this replaces). Returns 0 on a match, -1 on opendir failure
-* (already logged) or no match.
-*/
+/* Shared cur/ fallback scan for locate_message_file()/open_message_file(): finds the first "<basename>:*" entry, writes its "cur/<name>" path to path[] and optionally its suffix to suffix_out; returns 0 on match, -1 on opendir failure or no match. */
static int
scan_cur_for_basename(const char *basename, char *path, size_t pathsize,
char *suffix_out, size_t suffix_out_size)
return (0);
}
-/* same new/->cur/ fallback lookup as locate_message_file(), but opens the file and
-* returns a readable fd instead of stat()'ing it.
-*/
+/* Same new/ -> cur/ fallback lookup as locate_message_file(), but opens the file and returns a readable fd instead of stat()'ing it. */
int
open_message_file(const char *basename)
{
return (open(path, O_RDONLY));
}
-/* returns basename's raw RFC 5322 header block through the blank-line separator
-* ("\r\n\r\n" or "\n\n"); -1 past FETCH_HEADER_MAX or on NUL
-*/
+/* Returns basename's raw RFC 5322 header block through the blank-line separator ("\r\n\r\n" or "\n\n"); -1 past FETCH_HEADER_MAX or on NUL. */
int
read_message_header(const char *basename, char **buf_out, uint32_t *len_out)
{
found_sep = 1;
}
- /*
- * NUL check bounded to where the separator was found (or the whole
- * buffer, if it wasn't) -- matches the interleaved scan this replaces.
- *
- * The "i + 1 < total" term means the byte at total-1 is not examined
- * when limit == total, which looks like an off-by-one and is not:
- * limit == total only when the separator ends exactly at the end of
- * the buffer, and find_header_body_split() sets hdrend to i+4 past a
- * CRLFCRLF or i+2 past an LFLF, so that unexamined byte is always the
- * separator's own trailing '\n'. Never a NUL.
- */
+ /* NUL check is bounded to where the separator was found; the "i + 1 < total" term looks like an off-by-one but isn't -- when limit == total the unexamined final byte is always the separator's trailing '\n', never a NUL. */
limit = found_sep ? sepindex : (size_t)total;
for (i = 0; i < limit && i + 1 < (size_t)total; i++) {
if (readbuf[i] == '\0') {
return (0);
}
-/* reads the whole message (text_only=0) or just past the header separator
-* (text_only=1, SS6.4.5.1 TEXT); maxlen/label vary per call site
-*/
+/* Reads the whole message (text_only=0) or just past the header separator (text_only=1, SS6.4.5.1 TEXT); maxlen/label vary per call site. */
int
read_message_body(const char *basename, int text_only, size_t maxlen,
const char *label, char **buf_out, uint32_t *len_out)
struct stat st;
int fd;
ssize_t n, total = 0;
- /*
- * sepindex is a size_t, not an int: it holds an offset into readbuf,
- * whose size comes from maxlen, a CONFIGURATION value for this
- * function's two main callers (bodystructure_read_max). Narrowing it
- * to int was safe only because parse.y happens to cap that setting at
- * 1GB, half of INT_MAX, with nothing in either file recording the
- * dependency. Past 2GB the cast would go negative, making
- * "readbuf + sepindex" point before the allocation and
- * "total - sepindex" wrap into an enormous memcpy length.
- */
+ /* sepindex is size_t, not int: readbuf's size comes from the configurable maxlen (bodystructure_read_max), and narrowing to int was only safe because parse.y caps it at 1GB -- past 2GB it would go negative, pointing before the allocation and wrapping the memcpy length. */
size_t i, hdrend, sepindex = 0;
*buf_out = NULL;
if ((fd = open_message_file(basename)) == -1)
return (-1);
- /*
- * Size the buffer to the message, not to the configured ceiling:
- * this function runs once per message inside handle_mbox_fetch()'s
- * loop, so allocating the cap (bodystructure_read_max, up to 1GB
- * per parse.y) made "FETCH 1:* BODYSTRUCTURE" one huge malloc(3)+
- * read(2) per message regardless of actual message size. The
- * maxlen+1 slack is kept when the file is at or over the cap, so
- * the "exceeds maxlen" check below still fires.
- */
+ /* Size the buffer to the message, not the configured ceiling -- allocating the full cap (up to 1GB) per message made "FETCH 1:* BODYSTRUCTURE" do one huge malloc(3)+read(2) per message; maxlen+1 slack is kept at/over the cap so the "exceeds maxlen" check still fires. */
readbuf_size = maxlen + 1;
if (fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && st.st_size >= 0 &&
(uint64_t)st.st_size < (uint64_t)maxlen)
return (0);
}
-/* name[0..namelen) matches a space-separated name in list, ASCII-range case-insensitively
-* (RFC 9051 SS6.4.5.1); re-tokenized each call.
-*/
+/* name[0..namelen) matches a space-separated name in list, ASCII-range case-insensitively (RFC 9051 SS6.4.5.1); re-tokenized each call. */
int
header_field_name_matches(const char *name, size_t namelen, const char *list)
{
return (0);
}
-/* BODY.PEEK[HEADER.FIELDS[.NOT] (fields_spec)] (RFC 9051 SS6.4.5.1); splits the raw
-* header on RFC 5322 obs-fold lines, copies matching fields verbatim.
-*/
+/* One logical header field: the byte range [start, end) covering its first line and every RFC 5322 SS2.2.3 obs-fold continuation that belongs to it, plus line_end (the first line's end, before CR/LF) and colon (the ':' index, or line_end when the line has none). */
+struct hdr_field {
+ size_t start;
+ size_t colon;
+ size_t line_end;
+ size_t end;
+};
+
+/* Walks one field forward from *off. Returns 1 for a field, 0 for the blank line that ends the header (with [start, end) covering that line, since a HEADER.FIELDS response has to include it), and -1 for a header that ran out without one. Both callers used to write this out themselves; the -1/0 split in particular was expressed at each site as two different breaks setting two different values, which is exactly the distinction that is easy to get wrong. */
+static int
+hdr_next_field(const char *hdr, size_t hdrlen, size_t *off,
+ struct hdr_field *f)
+{
+ size_t i, j;
+
+ if (*off >= hdrlen)
+ return (-1);
+
+ f->start = *off;
+
+ i = *off;
+ while (i < hdrlen && hdr[i] != '\n')
+ i++;
+ if (i >= hdrlen)
+ return (-1); /* malformed: no trailing blank line */
+
+ f->line_end = (i > f->start && hdr[i - 1] == '\r') ? i - 1 : i;
+ *off = i + 1;
+
+ if (f->line_end == f->start) {
+ f->colon = f->line_end;
+ f->end = *off;
+ return (0);
+ }
+
+ for (f->colon = f->start; f->colon < f->line_end; f->colon++) {
+ if (hdr[f->colon] == ':')
+ break;
+ }
+
+ /* obs-fold continuation lines start with SP/HTAB and belong to this same field, so *off must land past them either way -- a caller that ignored them would resume mid-field */
+ while (*off < hdrlen && (hdr[*off] == ' ' || hdr[*off] == '\t')) {
+ j = *off;
+ while (j < hdrlen && hdr[j] != '\n')
+ j++;
+ if (j >= hdrlen) {
+ *off = hdrlen;
+ break;
+ }
+ *off = j + 1;
+ }
+
+ f->end = *off;
+ return (1);
+}
+
+/* Implements BODY.PEEK[HEADER.FIELDS[.NOT] (fields_spec)] (RFC 9051 SS6.4.5.1): splits the raw header on RFC 5322 obs-fold lines and copies matching fields verbatim. */
int
read_message_header_fields(const char *basename, const char *fields_spec,
int want_not, char **buf_out, uint32_t *len_out)
return (-1);
}
- while (off < hdrlen) {
- size_t field_start = off, line_end, i;
- int is_blank, matched, include;
+ for (;;) {
+ struct hdr_field f;
+ int matched, include, r;
- i = off;
- while (i < hdrlen && hdrbuf[i] != '\n')
- i++;
- if (i >= hdrlen)
- break; /* malformed: no trailing blank line, shouldn't happen */
- line_end = (i > field_start && hdrbuf[i - 1] == '\r') ?
- i - 1 : i;
- off = i + 1;
-
- is_blank = (line_end == field_start);
- if (is_blank) {
- memcpy(out + outlen, hdrbuf + field_start,
- off - field_start);
- outlen += off - field_start;
+ r = hdr_next_field(hdrbuf, hdrlen, &off, &f);
+ if (r == -1)
+ break; /* rc stays -1: no terminating blank line */
+ if (r == 0) {
+ /* the blank line is copied too: SS6.4.5's HEADER.FIELDS data is a header block, and a header block ends with one */
+ memcpy(out + outlen, hdrbuf + f.start,
+ f.end - f.start);
+ outlen += f.end - f.start;
rc = 0;
break;
}
- /* consume obs-fold continuation lines (start with SP/HTAB) belonging to this same field */
- while (off < hdrlen &&
- (hdrbuf[off] == ' ' || hdrbuf[off] == '\t')) {
- size_t j = off;
+ matched = header_field_name_matches(hdrbuf + f.start,
+ f.colon - f.start, fields_spec);
+ include = want_not ? !matched : matched;
- while (j < hdrlen && hdrbuf[j] != '\n')
- j++;
- if (j >= hdrlen) {
- off = hdrlen;
- break;
- }
- off = j + 1;
+ if (include) {
+ memcpy(out + outlen, hdrbuf + f.start,
+ f.end - f.start);
+ outlen += f.end - f.start;
}
-
- {
- size_t namelen = 0, k;
-
- for (k = field_start; k < line_end; k++) {
- if (hdrbuf[k] == ':')
- break;
- }
- namelen = k - field_start;
-
- matched = header_field_name_matches(hdrbuf +
- field_start, namelen, fields_spec);
- include = want_not ? !matched : matched;
-
- if (include) {
- memcpy(out + outlen, hdrbuf + field_start,
- off - field_start);
- outlen += off - field_start;
- }
- }
}
free(hdrbuf);
return (0);
}
-/* finds the first field named `name`, returns its *unfolded* value
-* (RFC 5322 SS2.2.3: CRLF+WSP -> WSP kept); NIL vs "" per SS7.5.2
-*/
+/* Finds the first field named `name`, returns its unfolded value (RFC 5322 SS2.2.3: CRLF+WSP -> WSP kept); NIL vs "" per SS7.5.2. */
int
extract_header_field(const char *hdr, size_t hdrlen, const char *name,
char **val_out, size_t *vallen_out)
*val_out = NULL;
*vallen_out = 0;
- while (off < hdrlen) {
- size_t field_start = off, line_end, i, k;
+ for (;;) {
+ struct hdr_field f;
+ size_t vstart, vend, p;
+ char *out;
+ size_t outlen = 0;
- i = off;
- while (i < hdrlen && hdr[i] != '\n')
- i++;
- if (i >= hdrlen)
- break; /* malformed, no trailing blank line found */
- line_end = (i > field_start && hdr[i - 1] == '\r') ? i - 1 : i;
- off = i + 1;
+ /* a blank line (header ended, name never seen) and a header that ran out both mean "no value", which is what this returned for either before the walk was shared */
+ if (hdr_next_field(hdr, hdrlen, &off, &f) != 1)
+ return (-1);
- if (line_end == field_start)
- break; /* blank line, end of header, not found */
-
- for (k = field_start; k < line_end; k++) {
- if (hdr[k] == ':')
- break;
- }
-
- /* consume obs-fold lines regardless of name match, off must land past them to stay positioned for the next field */
- while (off < hdrlen && (hdr[off] == ' ' || hdr[off] == '\t')) {
- size_t j = off;
-
- while (j < hdrlen && hdr[j] != '\n')
- j++;
- if (j >= hdrlen) {
- off = hdrlen;
- break;
- }
- off = j + 1;
- }
-
- if (k - field_start != namelen ||
- strncasecmp(hdr + field_start, name, namelen) != 0)
+ if (f.colon - f.start != namelen ||
+ strncasecmp(hdr + f.start, name, namelen) != 0)
continue;
- {
- size_t vstart = k + 1;
- size_t vend = off;
- size_t p;
- char *out;
- size_t outlen = 0;
+ /* RFC 5322 SS2.2.3: the value runs from the colon to the end of the last fold line, with CRLF/LF dropped and leading WSP trimmed -- f.end already covers the folds */
+ vstart = f.colon + 1;
+ vend = f.end;
+ if (vend > vstart && hdr[vend - 1] == '\n')
+ vend--;
+ if (vend > vstart && hdr[vend - 1] == '\r')
+ vend--;
+ while (vstart < vend &&
+ (hdr[vstart] == ' ' || hdr[vstart] == '\t'))
+ vstart++;
- if (vend > vstart && hdr[vend - 1] == '\n')
- vend--;
- if (vend > vstart && hdr[vend - 1] == '\r')
- vend--;
- while (vstart < vend &&
- (hdr[vstart] == ' ' || hdr[vstart] == '\t'))
- vstart++;
+ if ((out = malloc(vend - vstart + 1)) == NULL)
+ return (-1);
- if ((out = malloc(vend - vstart + 1)) == NULL)
- return (-1);
-
- for (p = vstart; p < vend; ) {
- if (hdr[p] == '\r' && p + 1 < vend &&
- hdr[p + 1] == '\n') {
- p += 2;
- continue;
- }
- if (hdr[p] == '\n') {
- p += 1;
- continue;
- }
- out[outlen++] = hdr[p];
- p++;
+ for (p = vstart; p < vend; ) {
+ if (hdr[p] == '\r' && p + 1 < vend &&
+ hdr[p + 1] == '\n') {
+ p += 2;
+ continue;
}
-
- *val_out = out;
- *vallen_out = outlen;
- return (0);
+ if (hdr[p] == '\n') {
+ p += 1;
+ continue;
+ }
+ out[outlen++] = hdr[p];
+ p++;
}
+
+ *val_out = out;
+ *vallen_out = outlen;
+ return (0);
}
-
- return (-1);
}
int
(*pos)++;
c = s[*pos];
}
- /*
- * The unquoted-token branch below rejects CTLs; this
- * branch applied no character class at all, so a
- * lone CR that extract_header_field() doesn't unfold
- * away could ride a quoted Content-Type token or
- * parameter value into the BODYSTRUCTURE sent to the
- * client. parse_content_type() degrades to its RFC
- * 2045 SS5.2 default on -1.
- */
+ /* Unlike the unquoted-token branch below, this branch applied no character class, letting a lone CR that extract_header_field() didn't unfold ride into the BODYSTRUCTURE; parse_content_type() degrades to its RFC 2045 SS5.2 default on -1. */
if (c == '\0' || c == '\r' || c == '\n')
return (-1);
if (outlen + 1 >= outsize)
blob - /dev/null
blob + 0fcbab43e49074d99b374784b8306b7ec06004aa (mode 644)
--- /dev/null
+++ src/mboxname.c
+/*
+ * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <string.h>
+
+#include "imapd.h"
+#include "mboxname.h"
+#include "utf8.h"
+
+/* RFC 9051 SS5.1: "INBOX" names this user's primary mailbox, case-insensitively, in every command that takes a mailbox name. Callers peel it off before mailbox_name_syntax_ok(), which would accept it as an ordinary name and let a CREATE or DELETE through. */
+int
+mailbox_name_is_inbox(const char *name)
+{
+ return (strcasecmp(name, "INBOX") == 0);
+}
+
+/* Returns 1 if `name` is acceptable as a mailbox name on this server, 0 otherwise. RFC 9051 SS5.1/SS5.1.1 plus this implementation's own storage rules; the caller decides what a refusal means on the wire, and INBOX is not handled here (it is a name this predicate accepts, and mailbox_name_is_inbox() peels it off before anyone asks). The store additionally checks that the name arrived NUL-terminated before calling this, since its copy comes off the imsg wire rather than out of its own parser -- see mailbox_name_valid() in store.c. */
+int
+mailbox_name_syntax_ok(const char *name)
+{
+ size_t i, len;
+
+ len = strlen(name);
+ if (len == 0 || len >= MBOX_NAME_MAX)
+ return (0);
+
+ /* RFC 9051 SS5.1: 8-bit mailbox names must comply with Net-Unicode; the encoding test is its own shared predicate again (see utf8.c). */
+ if (!utf8_mailbox_ok(name))
+ return (0);
+
+ for (i = 0; i < len; i++) {
+ unsigned char c = (unsigned char)name[i];
+
+ /* RFC 9051 SS5.1.1: "/" is the hierarchy delimiter */
+ if (c == '/')
+ return (0);
+ /* SS5.1 point 2: MAY refuse CTL/non-graphic names; taking that MAY for ASCII C0/DEL */
+ if (c < 0x20 || c == 0x7f)
+ return (0);
+ }
+
+ /* "tmp"/"new"/"cur" are INBOX's own maildir internals, refusing them here prevents cross-mailbox corruption */
+ if (strcmp(name, "tmp") == 0 || strcmp(name, "new") == 0 ||
+ strcmp(name, "cur") == 0)
+ return (0);
+
+ /* reject "." and ".." (DELETE "." would destroy INBOX) and the on-disk index filenames */
+ if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0)
+ return (0);
+ if (strcmp(name, STORE_INDEX_NAME) == 0 ||
+ strcmp(name, STORE_INDEX_TMP_NAME) == 0 ||
+ strcmp(name, STORE_INDEX_LOCK_NAME) == 0 ||
+ strcmp(name, STORE_UIDVALIDITY_NAME) == 0)
+ return (0);
+
+ return (1);
+}
blob - 538aad82c86d6de663a9b113ebd3a66055332c4c
blob + 8420f4c90a170f5f92aa85c97a5a17b3a7d43848
--- src/parent.c
+++ src/parent.c
/*
* Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ * Copyright (c) 2009 Jacek Masiulaniec <jacekm@dobremiasto.net>
+ * Copyright (c) 2008 Gilles Chehade <gilles@poolp.org>
+ * Copyright (c) 2008 Pierre-Yves Ritschard <pyr@openbsd.org>
*
+ * This file's boot-time peer-wiring handshake -- setup_peer_send() and
+ * setup_done_send(), and the IMSG_SETUP_PEER/IMSG_SETUP_DONE message
+ * names they and imapd.h use -- follows smtpd's setup_peers()/
+ * setup_done() (usr.sbin/smtpd/smtpd.c:861-902) and its identically-
+ * named IMSG_SETUP_PEER/IMSG_SETUP_DONE enumerators (smtpd.h:211-213),
+ * which no other daemon in the OpenBSD base system defines under those
+ * names. The functions here are reworked onto imapd's own struct imsgev
+ * and multi-child bookkeeping rather than copied verbatim, but the
+ * message protocol and handshake shape are smtpd's; see the inline
+ * citations at this file's setup_peer_send() and setup_done_send().
+ *
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * parent.c, privileged supervisor: process management, SETUP_PEER/
- * SETUP_DONE handshake, privilege drop, and (SS7's replicated-listener
- * model) the accept() loop itself.
- *
- * docs/openimap-tls-privsep-design.md SS7: rather than one long-lived
- * listener process accepting every connection itself, parent now owns
- * the bound listen sockets for the daemon's whole lifetime and forks a
- * fresh, paired listener-worker + auth-worker for each connection it
- * accepts (spawn_connection() below). A worker crash or compromise
- * therefore affects only its own one connection -- the blast-radius
- * goal the design doc scoped this work for -- rather than every
- * concurrent session, as a shared listener process crashing would.
- * keymgr remains the one boot-time, daemon-lifetime child: unlike
- * listener/auth it holds no per-connection state, just the loaded TLS
- * private key, and every listener-worker needs a peer wired to it (see
- * keymgr.c's own multi-peer rewrite).
- *
- * This retires three pieces of machinery that existed only because a
- * single listener process needed to tell parent things about sessions
- * it couldn't otherwise know:
- *
- * - IMSG_SESSION_OPEN/IMSG_SESSION_CLOSE (SS6.2 target 1): parent
- * now mints session_id and holds each session's listener_iev/
- * auth_iev itself, the instant it forks the pair, so there's
- * nothing left for a listener-worker to announce. Teardown is
- * reap_child() noticing that session's listener-worker pid
- * exited, in place of an explicit close message.
- *
- * - IMSG_LISTENER_MAXSTARTUPS: the throttle now runs here, checked
- * in parent_accept() before either fork happens (previously a
- * refused connection still cost listener a full IMSG_SESSION_OPEN
- * round trip; now it costs nothing beyond the accept() itself).
- * count_startups()/startups_should_drop() are otherwise unchanged
- * from their listener.c originals -- see those functions' own
- * comments below.
- *
- * - IMSG_LISTENER_INIT/IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS: parent
- * already binds these sockets (bind_listen_socket() below,
- * unchanged); it now keeps them instead of handing them off.
- *
- * A new IMSG_LISTENER_SESSION_INIT (imapd.h) replaces IMSG_LISTENER_
- * INIT/SOCKET_*'s old role: parent accept()s the connection itself and
- * fd-passes the one already-accepted client_fd to the listener-worker
- * spawned for it, along with session_id/implicit_tls/the peer address.
- *
- * spawn_connection() deliberately does not use the blocking
- * setup_done_send()/setup_recv_done_and_ack() boot handshake for the
- * new pair's peer wiring: that handshake exists to catch a boot-time
- * child failing to even start up, at a point where fatal()ing the
- * whole daemon is acceptable and a one-time blocking wait is cheap.
- * Per connection, neither property holds -- a bad connection must
- * degrade only itself (parent_handle_store_fork()'s per-session store
- * spawn already established this "fail the session, not the daemon"
- * discipline below), and a blocking round-trip on every accept() would
- * stall the whole daemon's admission rate on every new connection.
- * Each worker's own boot-drain loop already reads a fixed, statically
- * known set of messages before touching its own event loop regardless
- * of any ack (see listener.c/auth.c), so no ack is needed for
- * correctness either.
- */
+/* parent.c: privileged supervisor -- owns listen sockets and forks a paired listener-worker/auth-worker per accepted connection (spawn_connection()) so a compromise's blast radius is one connection; keymgr remains the sole boot-time child; retires the old single-listener IMSG_SESSION_OPEN/CLOSE and listener-side MaxStartups machinery now that parent tracks sessions and throttling itself. */
#include <sys/types.h>
#include <sys/queue.h>
#define STORE_CHILD_MAX 64 /* caps concurrent store children so a fork flood can't exhaust PIDs/fds */
-/*
- * Caps open_sessions tracking, same fork/flood-cap reasoning as
- * STORE_CHILD_MAX, sized generously above it since these entries are
- * much cheaper (no pid/fd/process beyond the paired workers already
- * counted against the OS's own process limits) and cover every open,
- * not-yet-authenticated connection, not just authenticated ones. In
- * the SS7 replicated-listener model this is a secondary backstop, not
- * the primary admission control -- count_startups()/
- * startups_should_drop() below (SS7's MaxStartups-style throttle) is
- * what actually caps concurrent connections; this only bounds what
- * this specific tracking structure will hold if the throttle is
- * disabled or misconfigured. Past the cap, spawn_connection() refuses
- * the connection outright rather than forking untracked workers.
- */
+/* Caps open_sessions tracking as a generous secondary backstop above STORE_CHILD_MAX; count_startups()/startups_should_drop() below are the real admission throttle, and past this cap spawn_connection() refuses the connection outright. */
#define OPEN_SESSION_MAX 4096
struct child {
TAILQ_ENTRY(store_child) entry;
};
-/*
- * One entry per connection spawn_connection() has forked a
- * listener-worker (and, usually, a paired auth-worker) for, from the
- * fork itself to reap_child() noticing the listener-worker exit.
- * "authenticated" flips true the moment a matching IMSG_AUTH_CRED is
- * accepted, so a second grant for the same session_id is refused as a
- * duplicate rather than silently re-processed, and so count_startups()
- * below can tell "counts against MaxStartups" apart from "doesn't".
- * listener_iev/auth_iev are this session's own paired worker channels
- * (not a global -- SS6.2 target 1's IMSG_AUTH_CRED gate below checks a
- * claimed session_id's IMSG_AUTH_CRED came from THAT session's own
- * auth_iev, not merely from some auth-worker somewhere); either
- * becomes NULL when reap_child() sees that worker's pid exit.
- * listener_pid/auth_pid are what let reap_child() find this entry from
- * a bare pid in the first place.
- */
+/* Tracks one spawn_connection()-forked session from fork to reap_child()'s exit notice; authenticated flips true on a validated IMSG_AUTH_CRED (rejecting duplicates), and listener_iev/auth_iev are this session's own paired channels, cleared to NULL when reap_child() sees that worker exit. */
struct open_session {
uint32_t session_id;
int authenticated;
void (*)(int, short, void *));
static pid_t fork_child_nonfatal(enum openimap_proc_type, struct imsgev **,
void (*)(int, short, void *));
-static int setup_peer_send(struct imsgev *, struct imsgev *, uint32_t);
-static int setup_search_peer_send(struct imsgev *, struct imsgev *);
+static int setup_peer_send(struct imsgev *, struct imsgev *, int,
+ const char *, uint32_t);
static void setup_done_send(struct imsgev *);
static void parent_dispatch_child(int, short, void *);
static struct open_session *open_session_find(uint32_t);
static int bind_one(int, const struct sockaddr *, socklen_t, uint16_t);
static int bind_listen_socket(const char *, uint16_t,
int[LISTENER_MAX_ADDRS]);
+static int read_file_capped(FILE *, char *, size_t, size_t *,
+ const char *, const char *);
static int send_tls_cert(struct imsgev *, struct openimap_config *);
static int send_keymgr_init(struct imsgev *, struct openimap_config *);
static int send_auth_init(struct imsgev *, struct openimap_config *);
if (geteuid() != 0)
fatalx("parent must start as root");
- /*
- * SS7 forks three children per connection and keeps one imsg channel
- * to each, so this process holds 3 descriptors per open session (4
- * once a store child exists) for that session's whole life.
- * rc.subr(8) starts a daemon under the login.conf(5) class named
- * after it if one exists, else "daemon" -- and "daemon" is
- * openfiles-cur=128, i.e. about 40 connections. That is well under
- * the 100 concurrent unauthenticated connections
- * "startups begin 10 rate 30 full 100" permits by default, so the
- * shipped configuration contradicts its own descriptor budget.
- *
- * Raise the soft limit to the hard one (openfiles-max, 1024 in that
- * class) here: still root, before pledge(2), before anything is
- * bound or forked. An operator who needs more can add an "imapd"
- * login class; this only stops the default from being
- * self-contradictory.
- */
+ /* Raise the descriptor soft limit to the hard limit (root, pre-pledge, pre-bind/fork): the default "daemon" login class caps descriptors well below what MaxStartups' default concurrency needs, at 3-4 fds per session. */
if (getrlimit(RLIMIT_NOFILE, &rl) == -1)
log_warn("getrlimit RLIMIT_NOFILE");
else if (rl.rlim_cur < rl.rlim_max) {
event_init();
- /*
- * keymgr is the one remaining boot-time, daemon-lifetime child;
- * listener and auth are spawned per-connection by
- * spawn_connection() below instead. IMSG_KEYMGR_INIT before the
- * peer handshake: keymgr_main() needs real key material in
- * place before its SS6.1 permission gate can open (see
- * keymgr.c's boot comment).
- */
+ /* keymgr is the sole remaining boot-time child (per-connection listener/auth are spawned later by spawn_connection()); send IMSG_KEYMGR_INIT before the peer handshake so real key material is in place before its permission gate opens. */
fork_child(PROC_KEYMGR, &iev_keymgr, parent_dispatch_child);
- /* Boot: a keymgr that cannot be initialized is not a daemon worth
- * starting, so this one caller keeps the old fatal behaviour. */
+ /* Boot: a keymgr that cannot be initialized is not a daemon worth starting, so this one caller keeps the old fatal() behavior. */
if (send_keymgr_init(iev_keymgr, conf) == -1)
fatalx("send_keymgr_init: could not initialize keymgr at boot");
- /*
- * IMSG_SETUP_DONE with no preceding IMSG_SETUP_PEER: keymgr
- * gets its first peer only once spawn_connection() wires the
- * first accepted connection's listener-worker to it
- * (setup_peer_send() below), so there is nothing to wire at
- * boot -- this is purely the same boot-failure-detection
- * handshake every boot-time child gets (see setup_done_send()'s
- * own comment), just with zero peers instead of one.
- */
+ /* keymgr gets no peer at boot (its first peer is wired later by spawn_connection()), so this IMSG_SETUP_DONE with zero preceding IMSG_SETUP_PEER is just the same boot-failure-detection handshake every boot-time child gets. */
setup_done_send(iev_keymgr);
signal_set(&ev_sighup, SIGHUP, sighup_handler, NULL);
n = 0;
nargv[n++] = progpath;
nargv[n++] = "-x";
- /* role is const char *; execv(3) requires char *const argv[] for
- * historical reasons predating C const-correctness, and never
- * writes through argv's pointers, so this cast is the standard,
- * unavoidable idiom for building an exec() argv array.
- */
+ /* Casting away const on role for argv is the standard, unavoidable idiom: execv(3) requires char *const argv[] for historical reasons and never writes through the pointers. */
nargv[n++] = (char *)(uintptr_t)role;
for (i = 1; saved_argv[i] != NULL; i++) {
/* skip a pre-existing -x <role> from our own argv, everything else passes through */
if (strcmp(saved_argv[i], "-x") == 0) {
- /*
- * A trailing "-x" with no value: the body's i++ plus
- * the loop's own would step past argv's NULL
- * terminator, and the next test would read one
- * element beyond the array -- in practice environ[0],
- * which then gets copied into the child's argv.
- * getopt(3) rejects a bare -x ("x:" takes an
- * argument), but an operand after "--" reaches here
- * unexamined because main() never checks optind.
- */
+ /* A trailing -x with no value would step the index past argv's NULL terminator and read one element beyond the array (in practice environ[0]); getopt(3) can't catch this because it fires on an operand after "--", which main() never checks via optind. */
if (saved_argv[i + 1] == NULL)
break;
i++;
continue;
}
if (n >= (int)(sizeof(nargv) / sizeof(nargv[0])) - 1) {
- /*
- * Refuse rather than truncate. Silently dropping the
- * tail can split an option from its value ("-f" with
- * no path), and the child's own getopt(3) then calls
- * usage() and exits -- surfacing as every connection
- * failing, with a stray "usage:" line as the only
- * clue. This runs in the freshly forked child, so the
- * parent simply sees the channel close.
- */
+ /* Refuse rather than truncate an overlong argv: silently dropping the tail could split an option from its value, and the child's own getopt(3) would then fail with a misleading usage() in the freshly forked child. */
log_warnx("exec_self_as: argv too long to pass to the "
"%s child, refusing to exec a truncated command "
"line", role);
}
nargv[n] = NULL;
- /*
- * execv(3), not execvp(3). nargv[0] is progpath, which realpath(3)
- * guarantees is absolute, so execvp() would never have searched PATH
- * -- it only does so for names containing no slash. But that safety
- * sat 120 lines away in parent_main(), and the OpenBSD ports guide
- * says plainly to avoid execvp. execv() cannot consult PATH at all,
- * which makes the property local and true by construction rather than
- * by an argument about a caller. (smtpd.c:856 still uses execvp for
- * the same re-exec; this is not a bug there either, for the same
- * reason.)
- */
+ /* Use execv(3) not execvp(3): progpath is always absolute (realpath(3)) so PATH search was never needed, and execv() makes that property true by construction rather than by relying on a caller's care (see smtpd.c:856 for the same non-bug). */
execv(nargv[0], nargv);
_exit(1);
}
-/*
- * re-exec mechanism (smtpd.c's start_child()): socketpair/fork/dup2
- * onto fd 3/closefrom/execv -x <role>. fatal()s on failure, the
- * right behavior for a boot-time child the daemon cannot run without
- * (keymgr is now the only caller). fork_child_nonfatal() below is the
- * identical mechanism for spawn_connection()'s per-connection forks,
- * which must degrade only the one connection instead.
- */
+/* Re-exec mechanism (per smtpd.c's start_child()): socketpair/fork/dup2 onto fd 3/closefrom/execv -x <role>; fatal()s on failure since keymgr, its only caller, is boot-time-required, unlike fork_child_nonfatal() below for per-connection forks. */
static pid_t
fork_child(enum openimap_proc_type type, struct imsgev **ievp,
void (*handler)(int, short, void *))
return (pid);
}
-/* IMSG_SETUP_PEER (smtpd.c's setup_peers()): fresh socketpair, fd-passed to "a"/"b"; id is 0 at boot or when the receiving side has exactly one peer for its whole life, else session_id (keymgr's multi-peer case, store's own case below) */
+/* IMSG_SETUP_PEER / IMSG_SETUP_SEARCH_PEER (smtpd.c's setup_peers()): fresh socketpair, fd-passed to "a"/"b". id is 0 at boot, or when the receiving side has exactly one peer for its whole life (both search-oracle wirings and the listener/auth pair), else session_id (keymgr's multi-peer case, store's own case below). imsgname is carried alongside imsg_type purely so a failure names the wiring that failed, the same pairing send_mbox_request() uses in listener.c. */
static int
-setup_peer_send(struct imsgev *a, struct imsgev *b, uint32_t id)
+setup_peer_send(struct imsgev *a, struct imsgev *b, int imsg_type,
+ const char *imsgname, uint32_t id)
{
int sp[2];
- /*
- * Returns -1 rather than fatal()ing: every caller is now a
- * per-connection path (spawn_connection(),
- * parent_handle_store_fork()), where the rule is "fail the session,
- * not the daemon". A socketpair(2) that fails with EMFILE must
- * refuse one connection, not exit the root process and take every
- * established session with it.
- *
- * Descriptor ownership on the error paths below follows
- * imsg_compose(3): it takes ownership of the fd only once it
- * succeeds (ibuf_fd_set()), and libutil then closes it after
- * sendmsg(2) -- so an fd handed to a compose that RETURNED
- * SUCCESS must not be closed here, and one whose compose failed
- * must be.
- */
+ /* Returns -1 instead of fatal()ing since every caller is now per-connection ("fail the session, not the daemon"); on the error paths, an fd is closed here only if imsg_compose() did NOT already take ownership of it. */
if (socketpair(AF_UNIX, SOCK_STREAM, PF_UNSPEC, sp) == -1) {
- log_warn("setup_peer_send: socketpair");
+ log_warn("setup_peer_send %s: socketpair", imsgname);
return (-1);
}
- if (imsg_compose(&a->ibuf, IMSG_SETUP_PEER, id, 0, sp[0], NULL, 0)
- == -1) {
- log_warn("setup_peer_send: imsg_compose (a)");
+ if (imsg_compose(&a->ibuf, imsg_type, id, 0, sp[0], NULL, 0) == -1) {
+ log_warn("setup_peer_send %s: imsg_compose (a)", imsgname);
close(sp[0]); /* neither end was taken */
close(sp[1]);
return (-1);
}
- if (imsg_compose(&b->ibuf, IMSG_SETUP_PEER, id, 0, sp[1], NULL, 0)
- == -1) {
- log_warn("setup_peer_send: imsg_compose (b)");
+ if (imsg_compose(&b->ibuf, imsg_type, id, 0, sp[1], NULL, 0) == -1) {
+ log_warn("setup_peer_send %s: imsg_compose (b)", imsgname);
close(sp[1]); /* sp[0] now belongs to a's msgbuf */
return (-1);
}
if (imsgbuf_flush(&a->ibuf) == -1) {
- log_warn("setup_peer_send: imsgbuf_flush (a)");
+ log_warn("setup_peer_send %s: imsgbuf_flush (a)", imsgname);
return (-1);
}
if (imsgbuf_flush(&b->ibuf) == -1) {
- log_warn("setup_peer_send: imsgbuf_flush (b)");
+ log_warn("setup_peer_send %s: imsgbuf_flush (b)", imsgname);
return (-1);
}
return (0);
}
-/*
- * SS8.1: same wiring dance as setup_peer_send() above, but hardcoded to
- * IMSG_SETUP_SEARCH_PEER with id always 0 -- listener-worker's boot-drain
- * expects exactly one of these (search-oracle is a short-lived
- * per-connection peer, not a long-lived multi-peer one like keymgr, so it
- * needs no session_id discriminator; see imapd.h's IMSG_SETUP_SEARCH_PEER
- * comment for why this got its own imsg type instead of reusing
- * IMSG_SETUP_PEER's id field as a third discriminator value).
- */
-static int
-setup_search_peer_send(struct imsgev *a, struct imsgev *b)
-{
- int sp[2];
-
- /* Non-fatal for the same reason as setup_peer_send() above, and
- * with the same descriptor-ownership rules on the error paths. */
- if (socketpair(AF_UNIX, SOCK_STREAM, PF_UNSPEC, sp) == -1) {
- log_warn("setup_search_peer_send: socketpair");
- return (-1);
- }
-
- if (imsg_compose(&a->ibuf, IMSG_SETUP_SEARCH_PEER, 0, 0, sp[0], NULL, 0)
- == -1) {
- log_warn("setup_search_peer_send: imsg_compose (a)");
- close(sp[0]);
- close(sp[1]);
- return (-1);
- }
- if (imsg_compose(&b->ibuf, IMSG_SETUP_SEARCH_PEER, 0, 0, sp[1], NULL, 0)
- == -1) {
- log_warn("setup_search_peer_send: imsg_compose (b)");
- close(sp[1]);
- return (-1);
- }
-
- if (imsgbuf_flush(&a->ibuf) == -1) {
- log_warn("setup_search_peer_send: imsgbuf_flush (a)");
- return (-1);
- }
- if (imsgbuf_flush(&b->ibuf) == -1) {
- log_warn("setup_search_peer_send: imsgbuf_flush (b)");
- return (-1);
- }
- return (0);
-}
-
/* IMSG_SETUP_DONE: tell a child no more peers are coming, block for its ack (smtpd.c's setup_done()); boot-time only -- see this file's header comment on why spawn_connection() does not use this for per-connection peer wiring */
static void
setup_done_send(struct imsgev *iev)
imsg_free(&imsg);
}
-/*
- * dispatch for every non-boot child channel: keymgr (the one
- * remaining boot-time singleton) and every spawn_connection()-forked
- * listener-worker/auth-worker. Demuxes by imsg type only, not by
- * sender -- IMSG_AUTH_CRED's case below is the one that needs to
- * verify sender identity per session_id (SS6.2 target 1); the others
- * are either sender-agnostic (IMSG_KEYMGR_*) or unreachable from a
- * child at all.
- */
+/* Dispatches every non-boot child channel (keymgr plus each spawn_connection()-forked listener/auth-worker) by imsg type alone, except IMSG_AUTH_CRED below, which also verifies sender identity per session_id. */
static void
parent_dispatch_child(int fd, short event, void *arg)
{
log_warnx("bad IMSG_AUTH_CRED");
break;
}
- /*
- * imsg_get_data() guarantees size, not NUL termination
- * (auth.c forces it on its own inbound imsgs for the same
- * reason). Everything downstream treats this as a C
- * string -- maildir_path_is_safe() walks it with strchr(3),
- * and strlcpy(3) reads the WHOLE source to compute its
- * return value -- so an unterminated field is an unbounded
- * read past a stack array, in the root process, driven by
- * the one child privsep exists to contain.
- */
+ /* imsg_get_data() guarantees size but not NUL termination, so an unterminated field here would be an unbounded read past a stack array in the root process, driven by the one child privsep exists to contain -- so force it, like auth.c does for its own inbound imsgs. */
req.maildir[sizeof(req.maildir) - 1] = '\0';
- /*
- * SS6.2's retrofit target 1: verify session_id against a
- * session parent independently knows is open (tracked
- * since spawn_connection() forked this session's pair),
- * rather than trusting whatever auth claims.
- */
+ /* Verify the claimed session_id against a session parent independently knows is open, rather than trusting whatever auth claims (SS6.2 retrofit target 1). */
if ((os = open_session_find(req.session_id)) == NULL) {
log_warnx("refusing IMSG_AUTH_CRED for session "
"%u: not a session parent knows is open "
req.session_id);
break;
}
- /*
- * Per-connection auth-workers (SS7) replace the old
- * single global iev_auth sender check (SS6.2 target
- * 1's original fix): each open_session remembers
- * exactly which auth-worker channel spawn_connection()
- * paired it with, and only that channel's
- * IMSG_AUTH_CRED is honored for it. A forged claim
- * from a different session's auth-worker is refused
- * exactly like the old global check refused a forgery
- * from listener or keymgr.
- */
+ /* Per-connection auth-workers (SS7) replace the old single global iev_auth sender check: each open_session remembers which auth-worker channel spawn_connection() paired it with, and only that channel's IMSG_AUTH_CRED is honored. */
if (iev != os->auth_iev) {
log_warnx("refusing IMSG_AUTH_CRED for "
"session %u: not from that session's own "
log_warnx("refusing IMSG_AUTH_CRED for session "
"%u: already authenticated, refusing "
"duplicate grant (SS6.2)", req.session_id);
- /*
- * Refuse the grant, but do not go silent: that
- * session's listener-worker is sitting in
- * SESSION_STORE_PENDING waiting for a store
- * peer it is never going to get, and nothing
- * times it out. This is reachable without any
- * misbehaviour -- a store spawn that failed
- * (STORE_CHILD_MAX, fork, socketpair) puts the
- * client back in SESSION_NOT_AUTH, and an
- * ordinary client retry lands right here.
- */
+ /* Refuse the grant but still reply: that session's listener-worker is waiting in SESSION_STORE_PENDING with no timeout, a state reachable via an ordinary retry after a failed store spawn, not just misbehavior. */
store_fork_failed(os);
break;
}
return (NULL);
}
-/*
- * SS7: count of open_sessions entries not yet authenticated --
- * imapd's equivalent of sshd's "concurrent unauthenticated
- * connections", moved here from listener.c's identical function of
- * the same name now that parent tracks every open session itself
- * (struct open_session's own "authenticated" field, flipped the
- * moment IMSG_AUTH_CRED is accepted above, is the exact same boundary
- * listener.c's SESSION_NOT_AUTH state check was -- see struct
- * open_session's comment). O(n) over open_sessions, same tradeoff as
- * before: n is bounded by max_startups_full by construction, so a
- * live walk is simpler than a separately-synchronized counter and
- * just as cheap at this scale.
- */
+/* SS7: counts not-yet-authenticated open_sessions entries (imapd's analog of sshd's concurrent-unauthenticated-connections), moved here from listener.c now that parent tracks every session itself; O(n) is fine since n is bounded by max_startups_full. */
static unsigned int
count_startups(void)
{
return (n);
}
-/*
- * SS7: sshd_config(5)'s MaxStartups algorithm, per its own
- * documentation -- below max_startups_begin, always accept; between
- * begin and full, refuse with probability rising linearly from
- * max_startups_rate% (at begin) to 100% (at full); at or above full,
- * always refuse. max_startups_full == 0 disables the throttle
- * entirely (accept unconditionally) -- an explicit opt-out, needed
- * because the check below would otherwise treat max_startups_full ==
- * 0 as "already at capacity" and refuse every connection. Moved here
- * verbatim from listener.c; now reads gconf->max_startups_* directly
- * (see this file's header comment -- SIGHUP already updates gconf in
- * place, so there's no more separate push/reload imsg to keep in
- * sync).
- */
+/* SS7: sshd_config(5)'s MaxStartups algorithm verbatim (accept below begin, ramp refusal probability linearly to full, always refuse at/above full, 0 disables it), moved from listener.c to read gconf->max_startups_* directly since SIGHUP updates gconf in place. */
static int
startups_should_drop(unsigned int nstartups)
{
return (1);
if (gconf->max_startups_rate >= 100)
return (1);
- /* Misconfigured (full <= begin, shouldn't happen -- parse.y
- * validates this at config-load time): treat as "no ramp
- * region", fail toward refusing rather than silently
- * accepting past what the operator called "full". */
+ /* Misconfigured (full <= begin, which parse.y should already prevent): treat as no ramp region and fail toward refusing rather than silently accepting past the configured full. */
if (gconf->max_startups_full <= gconf->max_startups_begin)
return (1);
return (arc4random_uniform(100) < (uint32_t)p);
}
-/*
- * parent's own accept loop (SS7, moved from listener.c's
- * listener_accept()); arg is (void *)0 for the cleartext/STARTTLS
- * socket, (void *)1 for the implicit-TLS one, same convention
- * listener_accept() used. MaxStartups is checked here, before any
- * fork -- no session_id minted, no open_session calloc'd -- so a
- * refused connection costs as little as possible, same intent as the
- * original listener.c placement.
- */
+/* parent's own accept loop (SS7, moved from listener.c's listener_accept()); arg selects cleartext(0)/implicit-TLS(1) socket; MaxStartups is checked before any fork so a refused connection costs almost nothing. */
static void
parent_accept(int fd, short event, void *arg)
{
return;
}
- /*
- * accept(2) does not inherit O_NONBLOCK from the listening socket.
- * The listener-worker this fd is about to be handed to assumes a
- * non-blocking fd throughout (tls_handshake()'s TLS_WANT_POLL*
- * handling, session_write()'s EAGAIN/poll retry); O_NONBLOCK is a
- * file-status flag on the underlying open file description, not
- * per-descriptor, so setting it here before the fd-pass is
- * equivalent to the listener-worker setting it itself after.
- */
+ /* accept(2) doesn't inherit O_NONBLOCK from the listening socket, and the listener-worker this fd is handed to assumes non-blocking throughout, so set it here before the fd-pass. */
if ((flags = fcntl(client_fd, F_GETFL)) == -1 ||
fcntl(client_fd, F_SETFL, flags | O_NONBLOCK) == -1) {
log_warn("fcntl O_NONBLOCK");
spawn_connection(client_fd, implicit_tls, &ss, sslen);
}
-/*
- * SS7's replicated-listener model: forks a paired listener-worker (+,
- * usually, an auth-worker) for one newly-accepted connection, wires
- * them to each other and to keymgr, and hands the listener-worker its
- * one session via IMSG_LISTENER_SESSION_INIT. Never fatal()s -- every
- * failure here degrades to closing this one client_fd or leaving this
- * one session without an auth-worker, never the daemon; see this
- * file's header comment for why no setup-done ack is used.
- *
- * client_fd is always either consumed (fd-passed to the new
- * listener-worker) or closed before this function returns.
- */
+/* SS7's replicated-listener model: forks a paired listener-worker(+auth-worker) for one accepted connection and wires them to each other and keymgr; never fatal()s -- failures degrade only this connection/session, and client_fd is always either consumed or closed before returning. */
static void
spawn_connection(int client_fd, int implicit_tls,
const struct sockaddr_storage *ss, socklen_t sslen)
unsigned int nopen = 0;
session_id = next_session_id++;
- /*
- * 0 is not an ordinary id: listener.c's boot-drain loop uses it as
- * a hard discriminator ("0 == the auth peer, anything else == the
- * keymgr peer", see imapd.h's IMSG_SETUP_PEER comment), so a
- * session whose id wrapped to 0 would have its keymgr descriptor
- * filed as its auth peer and would block in that loop forever.
- */
+ /* session_id 0 is reserved: listener.c's boot-drain loop treats peer id 0 as the auth peer and anything else as keymgr, so a session_id that wrapped to 0 would misfile its keymgr descriptor as its auth peer and hang. */
if (next_session_id == 0)
next_session_id = 1;
}
os->listener_pid = listener_pid;
os->listener_iev = new_listener_iev;
- /*
- * Inserted as soon as the listener-worker exists, even though
- * the rest of this function can still fail below: from here on
- * a live process is tracked against this session_id, and
- * reap_child() needs a matching open_session to find when that
- * process eventually exits, however incompletely it was wired
- * up. Simpler than unwinding a half-spawned session on every
- * later failure path.
- */
+ /* Inserted as soon as the listener-worker exists, even though this function can still fail below, so reap_child() always has a matching open_session to find and clean up regardless of how incompletely spawning finished. */
TAILQ_INSERT_TAIL(&open_sessions, os, entry);
if ((auth_pid = fork_child_nonfatal(PROC_AUTH, &new_auth_iev,
os->auth_iev = new_auth_iev;
/* id 0: listener-worker and auth-worker have exactly one peer each for their whole (short) life, no discriminator needed on either side -- see setup_peer_send()'s own comment */
if (send_auth_init(new_auth_iev, gconf) == -1 ||
- setup_peer_send(new_listener_iev, new_auth_iev, 0) == -1)
+ setup_peer_send(new_listener_iev, new_auth_iev,
+ IMSG_SETUP_PEER, "IMSG_SETUP_PEER", 0) == -1)
goto fail_close;
}
- /*
- * SS8.1: search-oracle, forked and wired the same fail-soft way as
- * auth-worker just above -- a missing oracle degrades this session's
- * SEARCH to "NO [UNAVAILABLE]" rather than the connection itself. No
- * config to send it (unlike send_auth_init() above): search_oracle.c
- * needs none, see its own file header comment.
- */
+ /* SS8.1: search-oracle is forked and wired the same fail-soft way as auth-worker above; a missing oracle just degrades this session's SEARCH to NO [UNAVAILABLE], and it needs no config push unlike send_auth_init(). */
if ((search_pid = fork_child_nonfatal(PROC_SEARCH, &new_search_iev,
parent_dispatch_child)) == -1) {
log_warnx("session %u: no search-oracle available, this "
/* os->search_pid stays 0; nothing to wire below. */
} else {
os->search_pid = search_pid;
- if (setup_search_peer_send(new_listener_iev, new_search_iev)
- == -1)
+ if (setup_peer_send(new_listener_iev, new_search_iev,
+ IMSG_SETUP_SEARCH_PEER, "IMSG_SETUP_SEARCH_PEER", 0) == -1)
goto fail_close;
}
/* id session_id: keymgr is a long-lived, multi-peer process now (keymgr.c), and needs it to know which peer entry this is */
- if (setup_peer_send(new_listener_iev, iev_keymgr, session_id) == -1)
+ if (setup_peer_send(new_listener_iev, iev_keymgr, IMSG_SETUP_PEER,
+ "IMSG_SETUP_PEER", session_id) == -1)
goto fail_close;
memset(&init, 0, sizeof(init));
init.implicit_tls = implicit_tls;
init.remote_ss = *ss;
init.remote_sslen = sslen;
- /* read fresh from gconf per connection, so a SIGHUP that changes
- * "idle poll" reaches every later connection -- see sighup_handler() */
+ /* Read fresh from gconf per connection so a SIGHUP-changed "idle poll" reaches every later connection -- see sighup_handler(). */
init.idle_poll_secs = gconf->idle_poll_secs;
if (imsg_compose(&new_listener_iev->ibuf, IMSG_LISTENER_SESSION_INIT,
0, 0, client_fd, &init, sizeof(init)) == -1) {
close(client_fd);
goto fail_workers;
}
- /*
- * From here on client_fd belongs to imsg (ibuf_fd_set(3) took it on
- * the successful compose above, and libutil closes it after
- * sendmsg(2)) -- so the unwind below must NOT close it. Hence two
- * labels rather than one.
- */
+ /* From here on client_fd belongs to imsg (ownership taken by the successful ibuf_fd_set(3) compose, closed by libutil after sendmsg(2)), so the unwind below must not close it -- hence two separate labels. */
if (send_tls_cert(new_listener_iev, gconf) == -1)
goto fail_workers;
return;
fail_close:
close(client_fd); /* not yet handed to imsg; still ours */
fail_workers:
- /*
- * Fail this connection, not the daemon. The workers forked above
- * are killed; everything else is left to reap_child(), which
- * removes and frees this open_session when the listener-worker's
- * pid exits -- exactly what the TAILQ_INSERT_TAIL comment above
- * says it is there for. Do NOT free(os) here: reap_child() owns
- * it from the moment it was inserted.
- */
+ /* Fail this connection, not the daemon: kill the workers forked above and leave the rest to reap_child(), which frees this open_session on the listener-worker's exit -- so do NOT free(os) here. */
log_warnx("session %u: refusing connection: worker wiring failed",
session_id);
kill(os->listener_pid, SIGKILL);
unsigned int nchildren = 0;
uint32_t session_id = os->session_id;
- /*
- * os->listener_iev is who the fail: path reports to, and who the
- * new child's fd gets passed to. A session's listener-worker is
- * never restarted when it dies (see reap_child()), so this can
- * legitimately be NULL if it died in the narrow window between
- * auth accepting credentials and this call running.
- */
+ /* os->listener_iev is who the fail: path reports to and who the new child's fd is passed to; it can legitimately be NULL if the listener-worker died between auth accepting credentials and this call, since it's never restarted. */
if (os->listener_iev == NULL) {
log_warnx("refusing store spawn for session %u: "
"listener-worker is gone", session_id);
}
TAILQ_FOREACH(it, &store_children, entry) {
- /*
- * A second spawn for a live session would hand the listener
- * a second IMSG_SETUP_PEER with the same id, and listener.c's
- * handler overwrites s->store_iev with a fresh calloc(3) --
- * leaking the old struct and its fd and orphaning a store
- * child. Only a broken or compromised auth can send one.
- */
+ /* A second spawn for a live session would overwrite s->store_iev in listener.c's handler with a fresh calloc, leaking the old struct/fd and orphaning a store child; only a broken or compromised auth can trigger this. */
if (it->session_id == session_id) {
log_warnx("refusing store spawn: session %u already "
"has a store child", session_id);
/* allocated once, in place, sc->iev is never copied afterward (same reason as struct store_child) */
sc = calloc(1, sizeof(*sc));
if (sc == NULL) {
- /*
- * Fail this session, not the daemon: every other failure in
- * this function degrades to one NO reply, and a transient
- * allocation failure should not be the exception that takes
- * the whole service down with it.
- */
+ /* Fail this session, not the daemon: every other failure here degrades to one NO reply, and a transient allocation failure shouldn't be the exception that takes down the whole service. */
log_warn("calloc store child (session %u)", session_id);
kill(pid, SIGKILL);
close(pair[0]);
imsgev_init(&sc->iev, pair[0], store_child_dispatch, sc);
/* IMSG_STORE_INIT: the one message a store child needs that boot-time children don't, runtime privilege target */
- /*
- * memset first: strlcpy(3) writes only up to its NUL, so without
- * this the unused tails of spool_root[1024] and maildir[] -- plus
- * every byte of inter-field padding -- travel to the store child as
- * whatever was on the root parent's stack, roughly a kilobyte per
- * session. Every other imsg payload in this file is zeroed first
- * (fail_payload below, the sockaddrs in bind_listen_socket()); this
- * one was the exception.
- */
+ /* memset first: without it, strlcpy(3)'s unused tail bytes and inter-field padding would leak roughly a kilobyte of root's stack contents to the store child, unlike every other imsg payload in this file which is already zeroed. */
memset(&init_payload, 0, sizeof(init_payload));
init_payload.session_id = session_id;
init_payload.uid = uid;
}
/* session_id rides as the imsg "id" field so listener.c can tell which in-flight handshake this fd belongs to */
- if (setup_peer_send(&sc->iev, os->listener_iev, session_id) == -1)
+ if (setup_peer_send(&sc->iev, os->listener_iev, IMSG_SETUP_PEER,
+ "IMSG_SETUP_PEER", session_id) == -1)
goto fail_kill;
if (imsg_compose(&sc->iev.ibuf, IMSG_SETUP_DONE, 0, 0, -1, NULL, 0)
store_fork_failed(os);
}
-/*
- * Tell one session's listener-worker that no store child is coming, so it
- * answers its client instead of waiting in SESSION_STORE_PENDING forever
- * (there is no inactivity timeout anywhere in a session's life).
- *
- * Lifted out of parent_handle_store_fork()'s own fail: tail so that
- * parent_dispatch_child()'s duplicate-IMSG_AUTH_CRED refusal can reach it
- * too: that path marks the session authenticated, refuses to spawn a
- * second store child (correctly -- it is a deliberate SS6.2 control), and
- * used to return without replying, which hung the waiting listener-worker
- * for good.
- */
+/* Tells a session's listener-worker no store child is coming, so it answers its client instead of hanging forever in SESSION_STORE_PENDING; lifted out of parent_handle_store_fork()'s fail: tail so the duplicate-IMSG_AUTH_CRED path can reach it too. */
static void
store_fork_failed(struct open_session *os)
{
0, 0, -1, &fail_payload, sizeof(fail_payload)) == -1)
log_warn("imsg_compose IMSG_STORE_FORK (failure reply)");
}
- /*
- * Unconditional: pending is cleared in store_child_dispatch() right
- * after its own evtimer_del(), so this is a no-op on that path, but
- * it stops a future clear-without-del from arming a timer on freed
- * memory. Also moved out of the "pending" branch above: sc is torn
- * down either way, so the timer must be disarmed either way, not
- * only when the listener-worker is reachable to notify.
- */
+ /* Unconditional: though pending is already cleared in store_child_dispatch(), disarming here too prevents a future clear-without-del from arming a timer on freed memory, regardless of whether the listener-worker is still reachable. */
evtimer_del(&sc->timeout_ev);
if (!already_dead)
kill(sc->pid, SIGKILL);
"IPv4/IPv6 address", addr);
}
-/*
- * Sends IMSG_TLS_CERT; used for both a new listener-worker (which
- * needs the certificate for its own tls_config, at spawn_connection()
- * time) and keymgr (which needs it to compute the certificate's
- * pubkey hash, see keymgr.c's keymgr_pubkey_hash(), at boot and on
- * SIGHUP reload). A listener-worker gets a fresh read of the cert
- * file on every spawn -- see this file's header comment -- so unlike
- * keymgr there is no separate reload push for it to receive.
- */
+/* Reads at most bufsize bytes of an already-open file into the CALLER's buffer; feof(3) alone can't tell "the file is exactly bufsize bytes" from "there is more to come", so on an exactly-full read one extra fgetc(3) probes for a byte past the cap, and a short read that isn't a clean EOF is refused for the same reason. Returns 0 with *n_out set, or -1 (already logged) for an oversized file or an unclean short read -- and leaves *n_out 0 on -1, so a caller that composes anyway sends nothing rather than a silent truncation. */
+/* Takes an open FILE * rather than a path so the key file's ownership and permission policy stays at its own call site, and never allocates, so the bytes live only in the caller's buffer where a caller holding key material can explicit_bzero() them. */
static int
+read_file_capped(FILE *fp, char *buf, size_t bufsize, size_t *n_out,
+ const char *path, const char *what)
+{
+ size_t n;
+
+ *n_out = 0;
+
+ n = fread(buf, 1, bufsize, fp);
+ if (n == bufsize) {
+ if (fgetc(fp) != EOF) {
+ log_warnx("%s: larger than %zu bytes, refusing to "
+ "send a truncated %s (TLS will be disabled)",
+ path, bufsize, what);
+ return (-1);
+ }
+ } else if (!feof(fp)) {
+ log_warnx("%s: short read that wasn't a clean EOF, refusing "
+ "to send a possibly-truncated %s (TLS will be disabled)",
+ path, what);
+ return (-1);
+ }
+
+ *n_out = n;
+ return (0);
+}
+
+/* Sends IMSG_TLS_CERT to both a fresh listener-worker (for its tls_config) and keymgr (to compute the cert's pubkey hash); only the listener-worker case reads a fresh file per spawn, so keymgr needs a separate reload push. */
+static int
send_tls_cert(struct imsgev *iev, struct openimap_config *conf)
{
FILE *fp;
if ((fp = fopen(conf->tls_cert_file, "r")) == NULL) {
log_warn("fopen %s", conf->tls_cert_file);
} else {
- n = fread(buf, 1, sizeof(buf), fp);
- /*
- * A short buffer used to be sent as if it were the whole
- * file, which then failed to parse in the listener with a
- * misleading error; listener.c's own size check can never
- * fire because the truncation happens here. feof(3) alone
- * is not sufficient: if the file is EXACTLY sizeof(buf)
- * bytes, fread(3) satisfies the request in a single read
- * and never attempts to read past the end, so feof() comes
- * back false even though nothing was actually truncated --
- * an extra one-byte probe is the only reliable way to tell
- * "exactly full" apart from "more data follows".
- */
- if (n == sizeof(buf)) {
- if (fgetc(fp) != EOF) {
- log_warnx("%s: larger than %zu bytes, "
- "refusing to send a truncated "
- "certificate (TLS will be disabled)",
- conf->tls_cert_file, sizeof(buf));
- n = 0;
- }
- } else if (!feof(fp)) {
- log_warnx("%s: short read that wasn't a clean EOF, "
- "refusing to send a possibly-truncated "
- "certificate (TLS will be disabled)",
- conf->tls_cert_file);
- n = 0;
- }
+ /* A short buffer used to silently be sent as if it were the whole file, failing later in the listener with a misleading error; read_file_capped() carries that check, shared with send_keymgr_init()'s key half. */
+ if (read_file_capped(fp, buf, sizeof(buf), &n,
+ conf->tls_cert_file, "certificate") == -1)
+ n = 0; /* send an empty cert, never a truncated one */
fclose(fp);
}
return (0);
}
-/*
- * Sends keymgr its copy of IMSG_TLS_CERT (see send_tls_cert() above)
- * followed by IMSG_KEYMGR_INIT, the real private key. Two composes
- * rather than one combined blob: cert and key are each capped at
- * sizeof(buf) (8192) below, and packing both into a single imsg
- * would leave uncomfortably little headroom under imsg's
- * MAX_IMSGSIZE (16384) if both were near that cap at once. Always
- * sends both, even on failure (zero-length = unusable), so keymgr
- * never hangs waiting -- same convention the old send_tls_certs()
- * used for listener.
- */
+/* Sends keymgr its IMSG_TLS_CERT then IMSG_KEYMGR_INIT as two separate composes (each capped at 8192 bytes) rather than one combined blob, to leave headroom under imsg's 16384-byte MAX_IMSGSIZE; always sends both, even empty on failure, so keymgr never hangs. */
static int
send_keymgr_init(struct imsgev *iev, struct openimap_config *conf)
{
"rwxr----- (TLS will be disabled)", conf->tls_key_file);
fclose(fp);
} else {
- n = fread(buf, 1, sizeof(buf), fp);
- /* see the matching comment in send_tls_cert() -- feof(3)
- * alone cannot detect an exactly-sizeof(buf)-byte file */
- if (n == sizeof(buf)) {
- if (fgetc(fp) != EOF) {
- log_warnx("%s: larger than %zu bytes, "
- "refusing to send a truncated key "
- "(TLS will be disabled)",
- conf->tls_key_file, sizeof(buf));
- n = 0;
- }
- } else if (!feof(fp)) {
- log_warnx("%s: short read that wasn't a clean EOF, "
- "refusing to send a possibly-truncated key "
- "(TLS will be disabled)", conf->tls_key_file);
- n = 0;
- }
+ if (read_file_capped(fp, buf, sizeof(buf), &n,
+ conf->tls_key_file, "key") == -1)
+ n = 0; /* send an empty key, never a truncated one */
fclose(fp);
}
/* imsg_compose() copies buf immediately, so it's safe to scrub our stack copy right after this call */
- /*
- * Non-fatal: this runs at boot (where parent_main() turns -1 into a
- * fatalx(), the old behaviour) and from sighup_handler(), where a
- * failed reload push must NOT kill a daemon that is otherwise
- * serving fine.
- */
+ /* Non-fatal here: parent_main() turns a boot-time -1 into a fatalx() itself, but a failed sighup_handler() reload push must not kill an otherwise-healthy daemon. */
if (imsg_compose(&iev->ibuf, IMSG_KEYMGR_INIT, 0, 0, -1, buf, n) == -1) {
explicit_bzero(buf, sizeof(buf));
log_warn("imsg_compose IMSG_KEYMGR_INIT");
{
struct imsg_auth_init init;
- /* Non-fatal: spawn_connection() is now the only caller, once per
- * connection. (The truncation check below cannot fire today --
- * both fields are 1024 bytes -- but it stays as the wire-struct
- * invariant it was written to be.) */
+ /* Non-fatal, since spawn_connection() is now the only caller (once per connection); the truncation check below can't fire today since both fields are 1024 bytes, but it stays as the wire-struct invariant it documents. */
memset(&init, 0, sizeof(init));
if (strlcpy(init.cred_file, conf->cred_file, sizeof(init.cred_file))
>= sizeof(init.cred_file)) {
sizeof(gconf->spool_root));
gconf->bodystructure_read_max = newconf.bodystructure_read_max;
gconf->idle_poll_secs = newconf.idle_poll_secs;
- /*
- * No corresponding push needed for either of these any more
- * (see this file's header comment): startups_should_drop()
- * reads gconf->max_startups_* directly on every parent_accept(),
- * and spawn_connection() reads gconf->tls_cert_file fresh via
- * send_tls_cert() on every spawn -- both already pick up
- * whatever was just written here on the very next connection.
- */
+ /* No corresponding push needed for max_startups_* or tls_cert_file: startups_should_drop() and send_tls_cert() already read gconf fresh on every accept/spawn, so they pick up a SIGHUP change on the very next connection. */
gconf->max_startups_begin = newconf.max_startups_begin;
gconf->max_startups_rate = newconf.max_startups_rate;
gconf->max_startups_full = newconf.max_startups_full;
reap_child(pid, status);
}
-/*
- * children reaching here always means an unexpected exit. keymgr
- * (the one remaining boot-time, daemon-lifetime child) is
- * deliberately not auto-restarted, same as before SS7: log and
- * degrade daemon-wide. A per-connection listener-worker, auth-worker,
- * or search-oracle (SS8.1) exiting is the ordinary, expected way one
- * connection's resources get reclaimed (a clean session end included,
- * not just a crash) -- see this file's header comment -- so those are
- * logged at debug level and only ever affect their own open_session
- * entry.
- */
+/* An unexpected child exit: keymgr (the sole boot-time child) is deliberately not auto-restarted and degrades the whole daemon; a per-connection listener/auth-worker or search-oracle exiting is the ordinary, expected way one session's resources get reclaimed, logged at debug level. */
static void
reap_child(pid_t pid, int status)
{
log_debug("session %u: listener-worker[%d] "
"exited (status %d), session closed",
os->session_id, pid, status);
- /* the paired auth-worker and search-oracle, if
- * still alive, are each left to notice their
- * own peer channel EOF and exit on their own --
- * parent isn't in that data path
- * (setup_peer_send()/setup_search_peer_send()
- * wired them directly to listener-worker). */
+ /* The paired auth-worker and search-oracle, if still alive, notice their own peer-channel EOF and exit on their own -- parent isn't in that data path (they're wired directly to listener-worker). */
os->listener_iev = NULL;
TAILQ_FOREACH(s, &store_children, entry) {
if (s->session_id == os->session_id)
blob - /dev/null
blob + 76c1a8120b66228859ccae6b480c878bb0264f72 (mode 644)
--- /dev/null
+++ src/mboxname.h
+/*
+ * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef IMAPD_MBOXNAME_H
+#define IMAPD_MBOXNAME_H
+
+/*
+ * The one mailbox-name syntax rule, shared by the listener's
+ * listener_mailbox_name_valid() and the store's mailbox_name_valid().
+ *
+ * Those two validators stay separate on purpose, for the reason utf8.h gives:
+ * the store does not trust the listener, and re-checking on the far side of
+ * the imsg boundary is the point. What they must not do is disagree about the
+ * RULE, and they have. mailbox_cmd.c's copy had drifted once already, missing
+ * store.c's rejection of "." / ".." and of the on-disk index filenames; utf8.c
+ * exists because the UTF-8 half of the same rule drifted before that. Twice is
+ * enough: the predicate lives here now, and each side calls it.
+ *
+ * The reserved filenames below are part of the rule, not incidental to it. A
+ * mailbox may not be named after one, because creating it would collide with
+ * the real file in that maildir root. They moved here from store_internal.h,
+ * which the listener deliberately does not include -- so the listener used to
+ * open-code them as string literals, which is the drift that already happened
+ * spelled out in advance. store_internal.h includes this header, so the store
+ * side sees exactly the same four strings; what each file is FOR is documented
+ * there, where the locking and UIDVALIDITY design that needs it lives.
+ *
+ * Like utf8.h, this header deliberately depends on nothing, so both sides can
+ * include it without dragging in listener.h or store_internal.h.
+ */
+
+#define STORE_INDEX_NAME "imapd.index"
+#define STORE_INDEX_TMP_NAME "imapd.index.tmp"
+#define STORE_INDEX_LOCK_NAME "imapd.index.lock"
+#define STORE_UIDVALIDITY_NAME "imapd.uidvalidity"
+
+int mailbox_name_syntax_ok(const char *);
+
+/*
+ * RFC 9051 SS5.1: INBOX is case-insensitive and always exists, so it is not a
+ * name either side validates -- it is peeled off first and mapped to the
+ * maildir root. One definition rather than two: the store and the listener
+ * each used to carry an identical one-liner under a different name.
+ */
+int mailbox_name_is_inbox(const char *);
+
+#endif /* IMAPD_MBOXNAME_H */
blob - 178c548bc9999be2067d74a90196fc79526e4f7b
blob + eea834acd4869670b5e32b0af3bad5d9eec0270a
--- src/search_cmd.c
+++ src/search_cmd.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * search_cmd.c, SEARCH: key parsing, evaluation dispatch, and
- * the async IMSG_MBOX_SEARCH_MATCH/IMSG_MBOX_RESULT completion path.
- */
+/* search_cmd.c: SEARCH key parsing, evaluation dispatch, and the async IMSG_MBOX_SEARCH_MATCH/IMSG_MBOX_RESULT completion path. */
#include <sys/types.h>
#include <sys/queue.h>
return (0);
}
-/*
- * Reads one sequence-set token -- RFC 9051 SS9's full grammar, a
- * comma-separated list of ranges/bare numbers, not just one -- and
- * pushes it as one or more SEARCH_OP_SEQSET/SEARCH_OP_UIDSET leaf
- * nodes OR'd together left to right, the same postfix-combinator
- * technique parse_search_key_list() below already uses to AND
- * multiple space-separated keys together. No new size cap is needed
- * for the range count: SEARCH_PROGRAM_MAX_NODES (already enforced by
- * search_push()) bounds the whole program long before
- * parse_sequence_set()'s own SEQSET_MAX_RANGES (500) would matter.
- *
- * The token is copied out first rather than NUL-terminated in place
- * the way FETCH/STORE terminate their own sequence-set token: here it
- * can sit right before a ')' that parse_search_key_list()'s caller
- * still needs to see (e.g. "SEARCH (1,3,5)"), and clobbering that
- * with a NUL would misparse the list as unterminated. The copy buffer
- * is sized off SESSION_INBUF_MAX (listener.h) -- the existing hard
- * cap on an entire command line -- so it can never truncate a token a
- * client could actually send.
- */
+/* Reads one sequence-set token (RFC 9051 SS9's full comma-separated grammar) and pushes it as OR'd SEARCH_OP_SEQSET/SEARCH_OP_UIDSET leaf nodes; copied out first (not NUL-terminated in place) since a trailing ')' must remain visible to the caller, into a buffer sized off SESSION_INBUF_MAX so it can never truncate. */
static int
parse_search_seqset(char **pp, struct search_parse_ctx *ctx, int op,
const char **errmsg)
*errmsg = "malformed octet count";
return (-1);
}
- /*
- * node.num is int64_t and mbox_search.c compares it signed, so
- * an unchecked cast turns "LARGER 18446744073709551615" into
- * "size > -1", i.e. every message matches. numbuf holds 23
- * digits and ULLONG_MAX is 20, so this is reachable.
- */
+ /* node.num is int64_t compared signed by mbox_search.c, so an unchecked cast turns "LARGER 18446744073709551615" into "size > -1" (matches everything) -- reachable since numbuf holds 23 digits and ULLONG_MAX is 20. */
if (v > (unsigned long long)INT64_MAX) {
*errmsg = "octet count out of range";
return (-1);
}
}
-/*
- * search_oracle.c's one entry point into this file's otherwise-
- * private struct search_parse_ctx (docs/openimap-tls-privsep-
- * design.md SS8.1's per-connection SEARCH-parsing oracle):
- * parses already-buffered SEARCH argument text (RETURN/CHARSET
- * already stripped by search_dispatch()) into nodes_out, a
- * caller-supplied buffer of at least SEARCH_PROGRAM_MAX_NODES
- * struct search_node elements -- struct search_parse_ctx itself
- * never crosses this boundary, staying private to this file
- * exactly as before this split. Returns parse_search_key_list()'s
- * own rc (0 ok, -1 BAD, -2 NO), copying its errmsg into
- * errmsg_out/errmsg_outsize whenever rc != 0. The "SEARCH
- * requires search criteria" empty-result check that used to
- * live in search_dispatch() moves here too: it's grammar
- * validation like everything else in this function, not
- * protocol/session handling.
- */
+/* search_oracle.c's one entry point into this file's private struct search_parse_ctx: parses already-stripped SEARCH argument text into nodes_out (a caller-supplied SEARCH_PROGRAM_MAX_NODES buffer), returning parse_search_key_list()'s rc (0/-1/-2) with errmsg copied out on failure; the empty-criteria check moved here too as grammar validation. */
int
search_oracle_parse(char *args, struct search_node *nodes_out,
uint32_t *nnodes_out, int *uses_modseq_out, char *errmsg_out,
return (1);
}
- /*
- * SS8.1: grammar parsing itself no longer happens in this process --
- * p (whatever's left after RETURN/CHARSET, the highest-risk part of
- * the SEARCH grammar) is handed to the per-connection search-oracle
- * instead, same fail-soft "unwired channel" check auth_cmd.c's
- * sasl_plain_finish() already uses for iev_auth. The old inline
- * parse_search_key_list() call and everything that used to run
- * right after it now live in search_dispatch_finish() (below),
- * called from listener_dispatch_search() (listener.c) once
- * IMSG_SEARCH_PARSE_RESULT arrives.
- */
+ /* SS8.1: grammar parsing no longer happens in this process -- the highest-risk remainder is handed to the per-connection search-oracle (same fail-soft channel check sasl_plain_finish() uses); the old inline parsing and its aftermath now live in search_dispatch_finish(), invoked once IMSG_SEARCH_PARSE_RESULT arrives. */
if (strlen(p) >= SEARCH_ORACLE_ARGS_MAX) {
session_reply(s, tag, "BAD", "SEARCH criteria too long");
return (1);
if (imsg_compose(&iev_search.ibuf, IMSG_SEARCH_PARSE_REQUEST, 0, 0,
-1, p, strlen(p)) == -1) {
- /*
- * s->state is already SESSION_SEARCH_PARSING, and nothing
- * will ever answer a request that was never sent -- the
- * oracle has not heard of it. Without this the session
- * waits in that state for good (no inactivity timeout
- * anywhere), queueing every later command until
- * SESSION_CMD_QUEUE_MAX drops the connection.
- */
+ /* The oracle never received this request, so without an explicit reply here the session would wait forever in SESSION_SEARCH_PARSING (no inactivity timeout), queueing commands until SESSION_CMD_QUEUE_MAX drops the connection. */
log_warn("session %u: imsg_compose IMSG_SEARCH_PARSE_REQUEST",
s->id);
session_reply(s, tag, "NO",
return (1);
}
-/*
- * SS8.1: completes search_dispatch() once listener_dispatch_search()
- * (listener.c) gets this session's IMSG_SEARCH_PARSE_RESULT -- BAD/NO
- * straight from the oracle's (rc, errmsg) on a rejected parse, or (on
- * rc == 0) exactly what search_dispatch() itself used to do right after
- * a successful parse_search_key_list() call, now fed from the oracle's
- * reply instead of a local struct search_parse_ctx. nodes is non-NULL
- * only when res->rc == 0 and res->nnodes > 0; the caller (listener.c)
- * owns freeing it either way, this function only reads it.
- */
+/* SS8.1: completes search_dispatch() once listener_dispatch_search() gets this session's IMSG_SEARCH_PARSE_RESULT -- replies BAD/NO from the oracle's (rc, errmsg) or, on rc==0, does what a successful parse_search_key_list() call used to; nodes is non-NULL only when rc==0 and nnodes>0, and the caller owns freeing it. */
void
search_dispatch_finish(struct session *s,
const struct imsg_search_parse_result *res, struct search_node *nodes)
s->search_max_modseq = m->modseq;
}
-/*
- * Fixed part of an ESEARCH line: correlator + tag + " UID" + MIN/MAX/
- * COUNT/MODSEQ items + CRLF, with headroom. The variable part is the ALL
- * list, budgeted separately at SEARCH_ALL_PER_MATCH below.
- */
+/* Fixed part of an ESEARCH line (correlator + tag + " UID" + MIN/MAX/COUNT/MODSEQ + CRLF) with headroom; the variable ALL list is budgeted separately via SEARCH_ALL_PER_MATCH below. */
#define SEARCH_RESP_PREFIX_MAX 256
#define SEARCH_ALL_PER_MATCH 11 /* "4294967295" + one separator */
s->state = SESSION_SELECTED;
- /*
- * res->error is enum mbox_op_error (imapd.h), not a boolean --
- * MBOX_OP_OK is 1, not 0 (MBOX_ERR_UNSET occupies 0), so printing
- * it raw under an "error=" label reads as a fault even on
- * success. mbox_search.c:374 only ever sets this to MBOX_OP_OK or
- * MBOX_OP_ERR_GENERIC for a SEARCH result, so a plain two-way
- * label is complete here.
- */
+ /* res->error is enum mbox_op_error, not a boolean (MBOX_OP_OK is 1, MBOX_ERR_UNSET is 0), so print it as an OK/error label rather than a raw number that would misread success as a fault. */
log_debug("session %u: SEARCH done, status=%s, %u match(es)", s->id,
res->error == MBOX_OP_OK ? "OK" : "ERROR", res->count);
if (res->error != MBOX_OP_OK || s->search_alloc_failed)
goto fail;
- /*
- * Heap-allocated and sized for the worst case, rather than a fixed
- * 8KB stack buffer: format_seq_list() truncates on a token boundary,
- * so an over-long ALL list used to produce a well-formed but SHORT
- * ESEARCH -- the client was told those messages simply did not
- * match, with only a server-side log line to say otherwise. A
- * client driving a bulk MOVE/STORE/EXPUNGE off SEARCH results would
- * then quietly operate on a subset.
- */
+ /* Heap-allocated for the worst case rather than a fixed 8KB stack buffer -- format_seq_list() truncates on a token boundary, so an over-long ALL list used to silently produce a short-but-valid ESEARCH, making a bulk MOVE/STORE/EXPUNGE quietly operate on a subset. */
bufsize = SEARCH_RESP_PREFIX_MAX +
(size_t)s->search_nmatches * SEARCH_ALL_PER_MATCH + 1;
if ((buf = malloc(bufsize)) == NULL) {
n = snprintf(buf, bufsize, "* ESEARCH (TAG \"%s\")", s->pending_tag);
if (n < 0 || (size_t)n >= bufsize) {
- /* pending_tag is IMAP_TAG_MAX-bounded and tag_is_valid()-checked
- * by session_handle_line(), so it holds no quote or backslash to
- * break the quoting above; checked rather than assumed. */
+ /* pending_tag is IMAP_TAG_MAX-bounded and tag_is_valid()-checked by session_handle_line(), so it can't hold a quote or backslash that would break the quoting above -- checked rather than assumed. */
log_warnx("session %u: SEARCH response prefix did not fit",
s->id);
goto fail;
" MODSEQ %llu",
(unsigned long long)s->search_max_modseq);
- /*
- * len holds snprintf(3)'s "would-be" length. bufsize is sized for
- * the worst case so this can't actually fire, but keep the same
- * defensive clamp the fixed-buffer version had rather than trust
- * that reasoning blindly.
- */
+ /* len holds snprintf(3)'s "would-be" length; bufsize is sized for the worst case so this can't actually fire, but keep the defensive clamp anyway rather than trust that reasoning blindly. */
if (len >= bufsize - 1)
len = bufsize - 2;
blob - e62225ce861ded6292483b35dbe047249efbd577
blob + e1d5652a643230ab97c02d67a8bb286fc2538f9f
--- src/search_oracle.c
+++ src/search_oracle.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * search_oracle.c, SEARCH-grammar parsing process (docs/openimap-tls-
- * privsep-design.md SS8.1): a per-connection worker, forked alongside
- * listener/auth by parent.c's spawn_connection(), that does nothing but
- * turn already-buffered SEARCH argument text into a parsed struct
- * search_node[] array or an error. The parsing logic itself is
- * unchanged and stays in search_cmd.c (search_oracle_parse(), this
- * file's one call into it) -- SS8's narrow variant only moves *where*
- * that already-existing, already-fuzzed-in-isolation grammar runs, not
- * what it does.
- *
- * Deliberately holds nothing else: no TLS context, no client fd, no
- * credentials, no channel to store/auth/keymgr, just its one peer
- * channel to the listener-worker it's paired with for that one
- * connection's whole life. A memory-safety bug in the SEARCH grammar
- * (the reason this process exists at all, see SS8.1's addendum) can
- * therefore reach none of that. Exits when its one peer's channel
- * closes, same as auth.c (SS7's per-connection exit-on-EOF discipline).
- */
+/* search_oracle.c: per-connection SEARCH-grammar parsing process (SS8.1) forked by parent.c, holding only its one peer channel and no TLS/creds/other channels, so a grammar memory-safety bug reaches nothing else; exits on peer EOF like auth.c. */
#include <sys/types.h>
/* fd-passing is allowed on this channel for the IMSG_SETUP_SEARCH_PEER peer fd below; see imsgev_ibuf_init()'s own comment */
imsgev_ibuf_init(&ibuf3, 3);
- /*
- * Unlike auth.c's IMSG_AUTH_INIT or listener.c's IMSG_TLS_CERT/
- * IMSG_LISTENER_SESSION_INIT, this process needs no config at
- * all -- no cred file, no TLS material, nothing session-
- * specific beyond the one peer fd itself. Its whole boot
- * sequence is draining this one message.
- */
+ /* Unlike auth.c or listener.c, this process needs no config at all beyond the one peer fd -- its whole boot sequence is draining this one message. */
for (;;) {
if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
fatal("imsgbuf_get");
if (peer_fd == -1)
fatalx("search-oracle: IMSG_SETUP_SEARCH_PEER carried no fd");
- /*
- * search-oracle's own daemon-user identity, distinct from
- * listener's _imapd and auth's _imapauth. It holds no secret of
- * its own -- the whole point is that it holds nothing worth
- * taking -- but a dedicated uid still keeps its failure/
- * compromise domain visibly separate, matching every other
- * role's convention in this project.
- */
+ /* search-oracle's own daemon-user identity, distinct from listener's/auth's -- it holds no secrets, but a dedicated uid still keeps its compromise domain separate, per project convention. */
if ((pw = getpwnam("_imapsearch")) == NULL)
fatalx("getpwnam _imapsearch: no such user "
"(expected, not yet provisioned by an install script)");
imsgev_init_from_ibuf(&iev_parent, &ibuf3, search_oracle_dispatch_parent,
NULL);
- /*
- * No rpath: this process touches no file, ever -- unlike auth.c
- * (the credential file) or listener.c (the TLS cert), SEARCH
- * parsing is a pure function over already-buffered text. No
- * inet: no socket beyond its one already-open peer channel, no
- * accept/connect of any kind, unlike listener's carried-over
- * "inet" promise (SS8.1's separate finding). That's the whole
- * point of this process existing (SS8.1's addendum). No recvfd or
- * sendfd either: the one descriptor this process ever receives is
- * the peer fd drained above, before this line, and it never sends
- * one -- the parent is the only process in the tree that attaches a
- * descriptor to an imsg (parent.c's setup_peer_send(),
- * setup_search_peer_send() and IMSG_LISTENER_SESSION_INIT are the
- * only five such call sites). So "stdio" alone, matching auth.c's
- * own final pledge minus its rpath.
- *
- * An earlier version of this comment kept both promises on the theory
- * that an imsgbuf configured with imsgbuf_allow_fdpass() uses
- * sendmsg(2)/recvmsg(2) for all of its traffic, not only fd-carrying
- * messages. It does -- but that is not what the two promises gate.
- * SYS_sendmsg and SYS_recvmsg are PLEDGE_STDIO
- * (sys/kern/kern_pledge.c); "sendfd"/"recvfd" are checked in
- * unp_internalize()/unp_externalize() (sys/kern/uipc_usrreq.c), which
- * the kernel reaches only when SCM_RIGHTS is actually attached to the
- * message. A plain imsg with fd == -1 needs neither promise.
- */
+ /* No rpath (touches no file, ever), no inet (only its one already-open peer channel), no recvfd/sendfd (never receives or attaches a descriptor beyond the one peer fd drained at boot) -- leaving just "stdio", matching auth.c minus rpath. */
#ifdef __OpenBSD__
if (pledge("stdio", NULL) == -1)
fatal("pledge");
if ((n = imsgbuf_read(&iev->ibuf)) == -1)
fatal("imsgbuf_read");
if (n == 0) {
- /*
- * SS7/SS8: this process was spawned to serve
- * exactly this one connection's listener-worker
- * and will never serve another -- exit now rather
- * than sit in event_dispatch() forever with
- * nothing left to do. Matches auth.c's
- * auth_dispatch()/listener.c's session_teardown()
- * (see either's own comment for the full SS7
- * reasoning); parent.c's reap_child() already
- * treats this as the ordinary, expected end of a
- * session, not something to warn about.
- */
+ /* SS7/SS8: this process serves exactly one connection and exits when it's done rather than idling in event_dispatch(), matching auth.c/listener.c; parent.c's reap_child() treats this as expected, not a warning. */
log_debug("search-oracle: listener closed channel, "
"exiting");
exit(0);
0, 0, -1, combined, sizeof(res) + bodylen) == -1) {
log_warn("imsg_compose "
"IMSG_SEARCH_PARSE_RESULT");
- /* the small reply may still fit where the
- * full one did not */
+ /* the small reply may still fit where the full one did not */
search_oracle_fail(iev,
"SEARCH temporarily unavailable");
}
(void)fd;
}
-/*
- * Answers a parse request this process could not carry out, so the
- * listener-worker is never left waiting.
- *
- * A SEARCH is a two-hop async round trip: search_dispatch() puts the
- * session in SESSION_SEARCH_PARSING and it leaves that state only when
- * IMSG_SEARCH_PARSE_RESULT arrives. listener.c handles this process
- * DYING (it synthesizes a NO), but has nothing for this process staying
- * alive and simply not answering -- and there is no inactivity timeout
- * anywhere in a session's life, so a missing reply wedges that session
- * for good.
- *
- * So every path out of the request handler answers, even the ones that
- * "cannot happen". Same discipline as keymgr.c, where a refused or
- * failed private-key operation is a reply with ok = 0 rather than
- * silence, and for the same reason: the requester is synchronous.
- *
- * rc = -2 (NO, not BAD): these are local failures of this process, not
- * anything wrong with the client's search criteria.
- */
+/* Answers a parse request this process couldn't carry out so the listener-worker is never left waiting -- every handler path replies, even "cannot happen" ones, since a missing reply wedges the session forever; rc = -2 (NO) since these are local failures, not bad search criteria. */
static void
search_oracle_fail(struct imsgev *iev, const char *errmsg)
{
log_warn("imsg_compose IMSG_SEARCH_PARSE_RESULT (failure)");
}
-/*
- * parent never sends search-oracle anything post-boot (it needs no
- * config, no reload -- see search_oracle_main()'s own comment), so this
- * exists only to notice if parent's end closes, same as auth.c's
- * auth_dispatch_parent().
- */
+/* parent never sends search-oracle anything post-boot, so this only notices if parent's end closes, same as auth.c's auth_dispatch_parent(). */
static void
search_oracle_dispatch_parent(int fd, short event, void *arg)
{
blob - c74ccc0319f36edbb70800cd2d9bdaec395e94fc
blob + 5d2c2f86c509d2d7f1b06c1f850cf4f11f3f7995
--- src/store.c
+++ src/store.c
#include "imapd.h"
#include "log.h"
+#include "mboxname.h"
#include "store_internal.h"
-#include "utf8.h"
static struct imsgev iev_listener;
-/* Defined below store_main(); declared here so the boot sequence can apply
- * the same NUL-termination rule to IMSG_STORE_INIT that every runtime
- * message already gets. */
+/* Defined below store_main(); declared here so boot can apply the same NUL-termination rule to IMSG_STORE_INIT as every runtime message. */
static int imsg_field_valid(const char *, size_t, const char *);
/* definitions for store_internal.h's extern globals, shared across the split store_*.c units */
session_id = init.session_id;
bodystructure_read_max = init.bodystructure_read_max;
- /*
- * imsg_get_data() guarantees the payload's size, not that the
- * strings inside it are terminated -- and both of these are used as
- * C strings immediately below, by chroot(2) and by snprintf("%s").
- * struct imsg_store_init puts maildir[] straight after
- * spool_root[], so an unterminated spool_root would read on into
- * it.
- *
- * This is the one message that skipped the check this file applies
- * to every runtime message (imsg_field_valid() below,
- * mailbox_name_valid()'s own memchr, search_nodes_valid()) -- and
- * it is the one that picks this process's chroot(2) target.
- * fatalx() rather than a graceful refusal: this is boot-time
- * plumbing from the trusted parent, same as the malformed-payload
- * case two lines above.
- */
+ /* imsg_get_data() guarantees payload size, not NUL-termination, and these fields feed chroot(2)/snprintf("%s") directly; fatalx() here since this is trusted boot-time data, not a runtime message that gets a graceful refusal. */
if (!imsg_field_valid(init.spool_root, sizeof(init.spool_root),
"IMSG_STORE_INIT.spool_root") ||
!imsg_field_valid(init.maildir, sizeof(init.maildir),
fatal("session %u: cannot drop privileges to uid %u gid %u",
session_id, init.uid, init.gid);
- /*
- * Confinement BEFORE the handshake ack below, not after.
- *
- * setup_recv_done_and_ack()'s IMSG_SETUP_DONE is what parent takes
- * as this child's readiness signal: parent_handle_store_fork()
- * holds sc->pending until it arrives, and store_child_teardown()
- * only tells the listener-worker the spawn failed while pending is
- * still set (parent.c). Acking first meant a failure in the two
- * steps below -- the plausible ones, since they are the only ones
- * that touch the filesystem -- produced no failure reply at all:
- * the client had already been told "OK Success", the listener saw
- * the store channel close and tore the session down, and the client
- * got an unexplained disconnect instead of "authentication
- * succeeded but mailbox store unavailable".
- *
- * With the ack last, a maildir that does not exist leaves pending
- * set, parent's STORE_SETUP_TIMEOUT_SEC timer fires, and the
- * designed failure path runs.
- *
- * unveil() only needs the chroot() and the privilege drop above; it
- * has no dependency on the peer fd or the event loop.
- */
+ /* Confinement happens before the handshake ack, not after: acking first would leave a later filesystem failure with no reply, while acking last lets a bad maildir trip parent's pending-timeout and the designed failure path. */
/* unveil() scoped to THIS session's own mailbox subdir, narrowing the view past chroot alone */
{
event_init();
imsgev_init(&iev_listener, peer_fd, store_dispatch, NULL);
- /*
- * flock for index r-m-w, rpath/wpath/cpath for delivery+renames; no
- * fattr (no chmod/utimes here).
- *
- * No recvfd, no sendfd. This process receives exactly one descriptor
- * in its life -- the peer fd from setup_recv_one_peer() above, which
- * has already arrived by the time this line runs -- and it never
- * sends one: the parent is the only process in the tree that attaches
- * a descriptor to an imsg. SYS_sendmsg and SYS_recvmsg are
- * PLEDGE_STDIO (sys/kern/kern_pledge.c); the two promises are checked
- * in unp_internalize()/unp_externalize() (sys/kern/uipc_usrreq.c),
- * which the kernel reaches only when SCM_RIGHTS is actually attached,
- * so an imsgbuf_allow_fdpass() channel carrying only fd == -1
- * messages does not need either.
- */
+ /* pledges flock/rpath/wpath/cpath for index and delivery, no fattr; no recvfd/sendfd since this process's one peer fd already arrived and it never attaches a descriptor to an imsg itself. */
#ifdef __OpenBSD__
if (pledge("stdio rpath wpath cpath flock", NULL) == -1)
fatal("pledge");
int
mailbox_name_valid(const char *name)
{
- size_t i, len;
-
+ /* This name came off the imsg wire as a fixed-size field, so before treating it as a C string at all: imsg_get_data() guarantees the payload's SIZE, never that the field inside it is terminated. The listener's own copy has no equivalent check because its names come out of its own parser already terminated. Everything after this is the shared rule. */
if (memchr(name, '\0', MBOX_NAME_MAX) == NULL) {
log_warnx("session %u: mailbox name field is not "
"NUL-terminated, refusing", session_id);
return (0);
}
- len = strlen(name);
- if (len == 0 || len >= MBOX_NAME_MAX)
- return (0);
-
- /*
- * RFC 9051 SS5.1: 8-bit mailbox names MUST comply with Net-Unicode.
- * The listener checks this too; that is the point of having two
- * validators, and the shared predicate is why they cannot drift.
- * See utf8.c for what is and is not enforced.
- */
- if (!utf8_mailbox_ok(name))
- return (0);
-
- for (i = 0; i < len; i++) {
- unsigned char c = (unsigned char)name[i];
-
- /* RFC 9051 SS5.1.1: "/" is the hierarchy delimiter */
- if (c == '/')
- return (0);
- /* SS5.1 point 2: MAY refuse CTL/non-graphic names; taking that MAY for ASCII C0/DEL */
- if (c < 0x20 || c == 0x7f)
- return (0);
- }
-
- /* "tmp"/"new"/"cur" are INBOX's own maildir internals, refusing them here prevents cross-mailbox corruption */
- if (strcmp(name, "tmp") == 0 || strcmp(name, "new") == 0 ||
- strcmp(name, "cur") == 0)
- return (0);
-
- /* reject "." and ".." (DELETE "." would destroy INBOX) and the on-disk index filenames */
- if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0)
- return (0);
- if (strcmp(name, STORE_INDEX_NAME) == 0 ||
- strcmp(name, STORE_INDEX_TMP_NAME) == 0 ||
- strcmp(name, STORE_INDEX_LOCK_NAME) == 0 ||
- strcmp(name, STORE_UIDVALIDITY_NAME) == 0)
- return (0);
-
- return (1);
+ return (mailbox_name_syntax_ok(name));
}
-
-int
-mailbox_name_is_inbox(const char *name)
-{
- return (strcasecmp(name, "INBOX") == 0);
-}
-
/* chdir's to `target` (empty string = INBOX root), tracked in current_mailbox_dir; restores cwd on failure */
int
select_mailbox_dir(const char *target)
}
}
- /*
- * cwd is about to become a different mailbox, so index.c's IDLE probe
- * is now holding another mailbox's stat(2) sample. Invalidate it here
- * rather than in the IDLE path: this is the only place the mailbox
- * can change, and a stale sample would make the first poll after a
- * SELECT report "unchanged" about a directory it has never looked at.
- */
+ /* Invalidate index.c's IDLE stat(2) sample here, the only place the mailbox changes -- otherwise the first poll after SELECT would report "unchanged" for a directory it never looked at. */
idle_probe_reset();
if (strlcpy(current_mailbox_dir, target, sizeof(current_mailbox_dir))
}
-/*
- * NUL-termination check for an imsg-carried field that isn't a mailbox name.
- */
+/* NUL-termination check for an imsg-carried field that isn't a mailbox name. */
static int
imsg_field_valid(const char *field, size_t size, const char *what)
{
return (0);
}
-/*
- * Same check applied to every node's keyword field of a SEARCH program.
- */
+/* Same check applied to every node's keyword field of a SEARCH program. */
static int
search_nodes_valid(const struct search_node *nodes, uint32_t nnodes)
{
return (out);
}
-/*
- * SS6.2's retrofit: does IMSG_MBOX_FETCH/STORE/EXPUNGE/SEARCH/COPY/
- * MOVE/IDLE_REFRESH actually arrive only after this session's own
- * IMSG_MBOX_SELECT succeeded, verified by this process's own state --
- * not merely inferred from listener.c's ST_SELECTED command-table
- * gating, which is listener's own state machine, not a guarantee
- * store itself checks anything. A compromised listener could send
- * these out of order; mailbox_selected makes the refusal explicit
- * and independently checkable here, the same shape as keymgr.c's
- * SS6.1 keymgr_got_init gate.
- */
+/* SS6.2 retrofit: mailbox_selected verifies independently, in this process's own state, that MBOX_SELECT succeeded before FETCH/STORE/EXPUNGE/SEARCH/COPY/MOVE/IDLE_REFRESH, rather than trusting listener.c's gating -- a compromised listener could send these out of order. */
static int
require_mailbox_selected(const char *what)
{
struct seq_range *ranges;
int ok;
- /*
- * Same header-plus-variable-body shape as
- * STORE/FETCH/SEARCH, but like EXPUNGE, nranges may
- * legitimately be 0: a plain SELECT/EXAMINE (no
- * QRESYNC), or QRESYNC without known-uids
- * (qresync_has_uids == 0, store.c defaults to the
- * full UID range), both carry no trailing sequence-
- * set at all.
- */
+ /* Same header-plus-variable-body shape as STORE/FETCH/SEARCH, but nranges may legitimately be 0 here: plain SELECT/EXAMINE or QRESYNC without known-uids carry no trailing sequence-set. */
if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
log_warnx("bad IMSG_MBOX_SELECT (header)");
break;
sizeof(struct seq_range), &ok);
if (!ok)
break;
- /*
- * RFC 9051 SS6.3.2: a failed SELECT leaves NO mailbox
- * selected -- which is what listener does on its own
- * side (store_ipc.c's session_handle_mbox_selected()
- * sets SESSION_AUTHENTICATED on any error). Clear the
- * SS6.2 gate here, before dispatching, so a SELECT
- * that fails after an earlier one succeeded does not
- * leave this process answering "selected" for a
- * session the protocol says has nothing selected.
- * handle_mbox_select() sets it again only on success.
- */
+ /* RFC 9051 SS6.3.2: a failed SELECT leaves no mailbox selected, matching listener's own SESSION_AUTHENTICATED fallback -- clear the SS6.2 gate before dispatching so a failed re-SELECT doesn't leave this process still answering "selected". */
mailbox_selected = 0;
/* composes its own reply stream, like every handle_mbox_*() below; the EV_WRITE arm comes from imsgev_on_compose() */
struct seq_range *ranges;
int ok;
- /*
- * Same header-plus-variable-body shape as
- * STORE/FETCH/SEARCH, but unlike those, nranges
- * may legitimately be 0 here: plain EXPUNGE and
- * CLOSE (also sent through this case, with
- * silent=1 -- see cmd_close()/cmd_expunge() in
- * store_cmd.c) take no sequence-set at all, only
- * UID EXPUNGE (by_uid=1) carries a real one.
- */
+ /* Same header-plus-variable-body shape as STORE/FETCH/SEARCH, but nranges may legitimately be 0: plain EXPUNGE and CLOSE (silent=1) carry no sequence-set, only UID EXPUNGE does. */
if (!require_mailbox_selected("IMSG_MBOX_EXPUNGE"))
break;
blob - 72282279d87c0f1a0aa54e4cc828ee746a225594
blob + 3d0a8220eedf3e17e5a205269608622ec3aefeab
--- src/store_cmd.c
+++ src/store_cmd.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * store_cmd.c, STORE/EXPUNGE/CLOSE/UNSELECT/IDLE/COPY/MOVE/UID:
- * command-select handlers plus their async completion paths.
- */
+/* store_cmd.c: STORE/EXPUNGE/CLOSE/UNSELECT/IDLE/COPY/MOVE/UID command-select handlers and their async completion paths. */
#include <sys/types.h>
#include <sys/queue.h>
#include "imapd.h"
#include "log.h"
#include "listener.h"
+#include "mboxname.h"
int
cmd_idle(struct session *s, const char *tag, char *args)
"mod-sequence value";
return (-1);
}
- /*
- * RFC 7162 SS7: store-modifier takes a
- * mod-sequence-valzer, so 0 is legal here (unlike
- * CHANGEDSINCE), but the value is still "1*DIGIT"
- * bounded by 9,223,372,036,854,775,807.
- *
- * This one matters most of the three: strtoull(3)
- * accepts a leading sign, so "UNCHANGEDSINCE -1"
- * arrived as ULLONG_MAX, every message compared below
- * it, and the conditional STORE silently became an
- * UNCONDITIONAL one -- with a tagged OK and no
- * MODIFIED list to say otherwise. RFC 7162 SS3.1.3
- * exists precisely to stop that write.
- */
+ /* RFC 7162 SS7: mod-sequence-valzer allows 0 here, but strtoull(3) accepts a leading sign, so "UNCHANGEDSINCE -1" became ULLONG_MAX and silently turned a conditional STORE unconditional -- must be rejected per RFC 7162 SS3.1.3. */
if (*valtok < '0' || *valtok > '9') {
*errmsg = "invalid UNCHANGEDSINCE mod-sequence";
return (-1);
if (req.has_unchangedsince)
session_condstore_enable(s);
+ /* defensive cleanup of a previous STORE's leftovers; shouldn't actually find anything here, same reasoning (and same four fields plus flag) as search_dispatch()'s own pre-command reset */
+ free(s->store_modified);
+ s->store_modified = NULL;
+ s->store_modified_n = 0;
+ s->store_modified_cap = 0;
+ s->store_modified_alloc_failed = 0;
+
s->cmd_by_uid = by_uid;
s->state = SESSION_STORING;
if (listener_reject_bad_utf8(s, tag, mailbox))
return (1);
- if (!listener_mailbox_name_is_inbox(mailbox) && !listener_mailbox_name_valid(mailbox)) {
+ if (!mailbox_name_is_inbox(mailbox) && !listener_mailbox_name_valid(mailbox)) {
session_reply(s, tag, "BAD", "invalid mailbox name");
return (1);
}
return session_request_expunge(s, tag, 0, 1, ranges, nranges);
}
-/* Appends one MODIFIED entry (RFC 7162 SS3.1.3) to s->store_modified, growing by doubling from 16. */
+/* Appends one MODIFIED entry (RFC 7162 SS3.1.3) to s->store_modified, growing by doubling from 16; a failed grow sets s->store_modified_alloc_failed and stops collecting, since SS3.1.3's set must list every message that failed UNCHANGEDSINCE and session_handle_mbox_result() refuses to send a short one (the early return below then keeps one failure from logging once per remaining message, as session_handle_mbox_search_match() does). */
void
session_handle_store_modified(struct session *s,
struct imsg_mbox_store_modified *m)
{
+ if (s->store_modified_alloc_failed)
+ return;
+
if (s->store_modified_n == s->store_modified_cap) {
uint32_t newcap = s->store_modified_cap ?
s->store_modified_cap * 2 : 16;
if (n == NULL) {
log_warn("session %u: realloc STORE MODIFIED array",
s->id);
+ s->store_modified_alloc_failed = 1;
return;
}
s->store_modified = n;
s->cmd_by_uid ? m->uid : m->seqno;
}
+/* Appends one "lo" or "lo:hi" token, with a separating comma unless it is the first, and refuses rather than truncates when it would not fit. format_seq_list() and format_range_list() differ only in where lo and hi come from; this is everything else they used to spell out twice, including the budget arithmetic that decides whether a list is complete -- the thing a caller must get right, and the reason both are fuzzed. Returns the new written length, or (size_t)-1 if the token did not fit, leaving buf as it was. *truncated is set only for a budget refusal, not for a snprintf(3) failure, which is what both callers did before. */
+static size_t
+append_range_token(char *buf, size_t bufsize, size_t written, int *first,
+ uint32_t lo, uint32_t hi, int *truncated)
+{
+ char tok[24];
+ int toklen;
+
+ if (lo == hi)
+ toklen = snprintf(tok, sizeof(tok), "%u", lo);
+ else
+ toklen = snprintf(tok, sizeof(tok), "%u:%u", lo, hi);
+ if (toklen < 0)
+ return ((size_t)-1);
+
+ if (written + (size_t)toklen + (*first ? 0 : 1) >= bufsize) {
+ *truncated = 1;
+ return ((size_t)-1);
+ }
+
+ if (!*first)
+ buf[written++] = ',';
+ memcpy(buf + written, tok, (size_t)toklen);
+ written += (size_t)toklen;
+ buf[written] = '\0';
+ *first = 0;
+ return (written);
+}
+
/* Formats nums as comma-separated bare/lo:hi ranges, RFC 9051 SS7.3.4 ESEARCH style; nums must be pre-sorted. */
size_t
format_seq_list(char *buf, size_t bufsize, const uint32_t *nums, uint32_t n,
uint32_t start = nums[i];
uint32_t end = start;
uint32_t j = i + 1;
- char tok[24];
- int toklen;
+ size_t w;
+ /* the compaction, which is this function's own: collapse an ascending run into one lo:hi token */
while (j < n && nums[j] == end + 1) {
end = nums[j];
j++;
}
- if (start == end)
- toklen = snprintf(tok, sizeof(tok), "%u", start);
- else
- toklen = snprintf(tok, sizeof(tok), "%u:%u", start,
- end);
- if (toklen < 0)
+ w = append_range_token(buf, bufsize, written, &first, start,
+ end, truncated);
+ if (w == (size_t)-1)
break;
-
- if (written + (size_t)toklen + (first ? 0 : 1) >= bufsize) {
- *truncated = 1;
- break;
- }
-
- if (!first)
- buf[written++] = ',';
- memcpy(buf + written, tok, (size_t)toklen);
- written += (size_t)toklen;
- buf[written] = '\0';
- first = 0;
+ written = w;
i = j;
}
*truncated = 0;
buf[0] = '\0';
+ /* no compaction here, unlike format_seq_list(): store.c hands these over already compacted */
for (i = 0; i < n; i++) {
- char tok[24];
- int toklen;
+ size_t w;
- if (ranges[i].lo == ranges[i].hi)
- toklen = snprintf(tok, sizeof(tok), "%u", ranges[i].lo);
- else
- toklen = snprintf(tok, sizeof(tok), "%u:%u",
- ranges[i].lo, ranges[i].hi);
- if (toklen < 0)
+ w = append_range_token(buf, bufsize, written, &first,
+ ranges[i].lo, ranges[i].hi, truncated);
+ if (w == (size_t)-1)
break;
-
- if (written + (size_t)toklen + (first ? 0 : 1) >= bufsize) {
- *truncated = 1;
- break;
- }
-
- if (!first)
- buf[written++] = ',';
- memcpy(buf + written, tok, (size_t)toklen);
- written += (size_t)toklen;
- buf[written] = '\0';
- first = 0;
+ written = w;
}
return (written);
}
-/*
- * Worst-case sizing for each of COPYUID's two UID sets, matching
- * store_ipc.c's MODIFIED_PER_ENTRY and search_cmd.c's
- * SEARCH_ALL_PER_MATCH: "4294967295" plus a separator. The wrapper covers
- * the tag (IMAP_TAG_MAX), "OK [COPYUID ", the uidvalidity, cmdname and
- * CRLF.
- */
+/* Worst-case sizing for COPYUID's two UID sets ("4294967295" plus separator, matching store_ipc.c/search_cmd.c), plus the tag/"OK [COPYUID "/uidvalidity/cmdname/CRLF wrapper. */
#define COPYUID_PER_ENTRY 11
#define COPYUID_WRAPPER_MAX 160
size_t listsize, textsize;
int truncated, n, sent = 0;
- /*
- * Heap-allocated and sized for the worst case, as
- * session_finish_search() does for the ESEARCH ALL list. The
- * two fixed buffers were the wrong shape twice over. They
- * truncated INDEPENDENTLY, so the source and destination
- * lists could stop at different entry counts and silently
- * misalign the positional mapping RFC 9051 SS7.1 defines --
- * a client resolving its own UIDs through a misaligned
- * COPYUID lands on the wrong messages. And neither was the
- * real bound: session_reply() and session_untagged() compose
- * into 512 bytes and force the last two back to CRLF on
- * overflow, amputating the closing "]" of the response code.
- * Composed here in full and handed to session_write().
- */
+ /* Heap-allocated and worst-case sized (like session_finish_search()'s ESEARCH ALL list): fixed buffers here previously truncated independently, misaligning RFC 9051 SS7.1's COPYUID UID mapping and risking overflow-truncated responses. */
listsize = (size_t)s->copy_n * COPYUID_PER_ENTRY + 1;
textsize = 2 * listsize + COPYUID_WRAPPER_MAX;
log_warnx("session %u: COPYUID dest list "
"truncated", s->id);
- /* MOVE's COPYUID is untagged (its tagged OK follows the
- * EXPUNGEs); COPY's rides on the tagged OK itself. */
+ /* MOVE's COPYUID is untagged (its tagged OK follows the EXPUNGEs); COPY's rides on its own tagged OK. */
if (s->cmd_is_move)
n = snprintf(text, textsize,
"* OK [COPYUID %u %s %s]\r\n",
session_reply(s, s->pending_tag, "OK", "Done");
} else if (!sent) {
- /* RFC 9051 SS6.4.7 makes COPYUID a SHOULD, so dropping
- * the response code is a legal degradation; emitting a
- * truncated or half-empty one is not. */
+ /* RFC 9051 SS6.4.7 makes COPYUID a SHOULD, so dropping it is a legal degradation; emitting a truncated or half-empty one is not. */
char fallback[64];
snprintf(fallback, sizeof(fallback), "%s completed",
blob - 10e04f6f4773e0e729225108f31543c6b930635a
blob + 390001532828ddd0cdc979a6eb893be1ff110253
--- src/store_internal.h
+++ src/store_internal.h
#include <stdint.h>
+#include "mboxname.h"
+
struct mbox_index {
uint32_t uidvalidity;
uint32_t uidnext;
void handle_mbox_rename(struct imsg_mbox_rename *, struct imsgev *);
void handle_mbox_list(struct imsgev *);
int mailbox_name_valid(const char *);
-int mailbox_name_is_inbox(const char *);
int select_mailbox_dir(const char *);
int save_current_mailbox_dir(char *, size_t);
int locate_message_file(const char *, off_t *, char *, size_t);
* UIDVALIDITY/UIDNEXT and the UID<->basename(<->keywords) map. The index
* lives directly in that mailbox's maildir root, a sibling of tmp/new/
* cur. Name is plain and `ls`-visible on purpose.
+ *
+ * The four reserved filenames themselves (STORE_INDEX_NAME,
+ * STORE_INDEX_TMP_NAME, STORE_INDEX_LOCK_NAME, STORE_UIDVALIDITY_NAME) are
+ * defined in mboxname.h, included above: a mailbox may not be NAMED after one,
+ * and the listener has to refuse such a name too without including this
+ * store-private header. What each file is FOR stays documented here, and each
+ * block below names the constant it belongs to.
*/
-#define STORE_INDEX_NAME "imapd.index"
-#define STORE_INDEX_TMP_NAME "imapd.index.tmp"
+
/*
- * The index's lock is taken on this file, not on the index itself.
+ * STORE_INDEX_LOCK_NAME: the index's lock is taken on this file, not on the
+ * index itself.
*
* index_save() commits by writing STORE_INDEX_TMP_NAME and rename(2)ing it
* over STORE_INDEX_NAME, which means the index's inode is REPLACED on every
* refuses it as a mailbox name, and remove_maildir_subtree() unlinks it
* when the mailbox is deleted.
*/
-#define STORE_INDEX_LOCK_NAME "imapd.index.lock"
+
#define STORE_INDEX_LINE_MAX 1024
/*
- * Per-user UIDVALIDITY floor: the highest UIDVALIDITY ever issued to this
- * user, as decimal digits. One file at the MAILDIR ROOT, beside INBOX's own
- * index, not one per mailbox.
+ * STORE_UIDVALIDITY_NAME: per-user UIDVALIDITY floor, the highest UIDVALIDITY
+ * ever issued to this user, as decimal digits. One file at the MAILDIR ROOT,
+ * beside INBOX's own index, not one per mailbox.
*
* It exists because index_load() used to seed a fresh index with
* time(NULL), following RFC 9051 SS2.3.1.1's own advice -- but that advice
* which is what keeps it clear of lock_copy_move_mailboxes()'s name-ordered
* two-mailbox acquisition in mbox_copy.c.
*/
-#define STORE_UIDVALIDITY_NAME "imapd.uidvalidity"
/*
* A held index lock: the flock(2)ed lock file, plus the index descriptor,
int, struct seq_range[SEQSET_MAX_RANGES]);
int seqset_contains(const struct seq_range *, uint32_t, uint32_t);
uint32_t seqset_max_hi(const struct seq_range *, uint32_t);
+
+/*
+ * Where one message sits relative to a resolved sequence-set, for the
+ * ascending single-pass scans in handle_mbox_fetch(), handle_mbox_store() and
+ * stage_copy_messages(). PAST_END means the scan can stop, not merely that
+ * this message is unmatched -- it is only sound because those loops walk
+ * idx->lines in ascending order.
+ */
+enum seqset_pos {
+ SEQSET_PAST_END,
+ SEQSET_SKIP,
+ SEQSET_MATCH
+};
+
+enum seqset_pos seqset_position(const struct seq_range *, uint32_t, uint32_t,
+ int, uint32_t, uint32_t);
int refresh_index(struct mbox_index *, int);
uint32_t uidvalidity_next(void);
void idle_probe_reset(void);
blob - b3ed28e9b65a886593592c66f2b861e9b94573e2
blob + 29c7870d537508b2b47163c6c726534b52ccd0a8
--- src/store_ipc.c
+++ src/store_ipc.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * store_ipc.c, listener-side dispatch of the store process's async imsg
- * protocol: session_handle_mbox_*()/session_finish_*() reply handlers.
- */
+/* store_ipc.c: listener-side dispatch of the store process's async imsg protocol (session_handle_mbox_*()/session_finish_*() reply handlers). */
#include <sys/types.h>
#include <sys/queue.h>
#include "log.h"
#include "listener.h"
-/*
- * Shared receive-side handling for the four IMSG_MBOX_FETCH_{HEADER,BODY,
- * ENVELOPE,BODYSTRUCTURE} messages.
- */
+/* Shared receive-side handling for the four IMSG_MBOX_FETCH_{HEADER,BODY,ENVELOPE,BODYSTRUCTURE} messages. */
static void
fetch_part_recv(struct session *s, struct imsg *imsg, const char *what,
int found, uint32_t declared_len, char **bufp, uint32_t *lenp,
log_warnx("bad IMSG_MBOX_FETCH_META");
break;
}
- /*
- * imsg_get_data() guarantees size, not NUL
- * termination. flags is formatted with "%s" into a
- * client-visible response by three senders --
- * session_send_fetch_response(),
- * session_send_store_fetch_response() (both
- * fetch_cmd.c) and session_send_qresync_fetch_
- * response() below -- so an unterminated field would
- * read on past meta into this stack frame and put
- * the result on the wire.
- *
- * The sender is the store child: it runs as the
- * authenticated user and it is the process that
- * parses attacker-supplied message content, so it is
- * exactly the peer whose framing must not be taken
- * on trust. Same rule as auth.c's inbound username/
- * password, parent.c's maildir, keymgr.c's hash and
- * store.c's own imsg_field_valid().
- */
+ /* imsg_get_data() doesn't guarantee NUL termination, and flags is formatted with "%s" into client-visible replies, so an unterminated field from the (untrusted) store child would leak stack memory onto the wire -- same trust boundary as auth.c, parent.c, keymgr.c, and store.c's imsg_field_valid(). */
meta.flags[sizeof(meta.flags) - 1] = '\0';
/* Shared reply type for FETCH/STORE/QRESYNC resync; SELECTING buffers per RFC 7162 SS3.2.6's VANISHED-before-FETCH order. */
if (s->state == SESSION_SELECTING)
log_warnx("bad IMSG_MBOX_LIST_ITEM");
break;
}
- /*
- * Same rule as IMSG_MBOX_FETCH_META's flags above.
- * mailbox is the whole of struct imsg_mbox_list_item,
- * so an unterminated one leaves the struct
- * immediately -- and it is both walked as a C string
- * by list_pattern_match() and formatted with "%s"
- * into the untagged LIST response.
- */
+ /* Same NUL-termination risk as IMSG_MBOX_FETCH_META's flags: mailbox is walked as a C string by list_pattern_match() and formatted with "%s" into the untagged LIST response. */
item.mailbox[sizeof(item.mailbox) - 1] = '\0';
session_handle_mbox_list_item(s, &item);
break;
char buf[128];
if (res->error != MBOX_OP_OK || s->qresync_alloc_failed) {
- /*
- * RFC 9051 SS6.3.2 failure -> authenticated (RFC 5530
- * NONEXISTENT text, any cause).
- *
- * A dropped QRESYNC resync entry lands here too and fails the
- * whole SELECT on purpose: the resync data is held back until
- * this terminal reply, so nothing has been emitted yet and a
- * NO is still available. Completing the SELECT would instead
- * advertise a fresh HIGHESTMODSEQ that the client adopts as
- * its sync anchor, permanently hiding the dropped updates.
- * RFC 5530 SS3 UNAVAILABLE is the transient-server-problem
- * code.
- */
+ /* RFC 9051 SS6.3.2 failure -> authenticated (RFC 5530 NONEXISTENT); a dropped QRESYNC resync also fails SELECT here on purpose, before any HIGHESTMODSEQ is advertised, using RFC 5530 SS3 UNAVAILABLE for the transient condition. */
s->state = SESSION_AUTHENTICATED;
session_reply(s, s->pending_tag, "NO",
s->qresync_alloc_failed ? "[UNAVAILABLE] SELECT failed" :
"[NONEXISTENT] no such mailbox");
- /*
- * An honest store child never streams resync data ahead of a
- * failing IMSG_MBOX_SELECTED -- every error path in
- * handle_mbox_select() reaches "send:" before
- * qresync_send_resync() runs -- but a compromised one can,
- * and anything left behind here would be replayed into the
- * next SELECT's response.
- */
+ /* An honest store child never streams resync data before a failing IMSG_MBOX_SELECTED, but a compromised one could, and leftover data here would be replayed into the next SELECT's response. */
free(s->vanished_ranges);
s->vanished_ranges = NULL;
s->vanished_nranges = 0;
"OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen "
"\\Draft \\*)] System flags and keywords allowed");
- /*
- * RFC 9051 SS6.3.2 LIST; "/" matches cmd_namespace()'s delimiter.
- * The name goes through quote_mailbox() rather than straight into
- * "\"%s\"": it may contain a space, and it may also contain one of
- * SS4.3's quoted-specials, which raw substitution would emit
- * unescaped and so break the response boundary. Written with
- * session_write() for the same reason VANISHED (EARLIER) is above
- * -- the escaped form can exceed session_untagged()'s 512 bytes.
- */
+ /* RFC 9051 SS6.3.2 LIST; name goes through quote_mailbox() (may contain a space or SS4.3 quoted-special) and is written with session_write() since the escaped form can exceed session_untagged()'s 512-byte limit, same as VANISHED (EARLIER). */
{
char qname[MBOX_QUOTED_MAX];
char listbuf[MBOX_QUOTED_MAX + 64];
const struct imsg_mbox_status_result *res)
{
char qname[MBOX_QUOTED_MAX];
- /*
- * F2 fix: buf[256] was too small for a near-max mailbox name +
- * STATUS attrs. Now sized for the SS4.3-escaped name as well, plus
- * the leading "* " and trailing CRLF, since this line is written
- * directly rather than through session_untagged()'s 512 bytes.
- */
+ /* F2 fix: buf[256] was too small for a near-max mailbox name plus STATUS attrs; now sized for the SS4.3-escaped name, leading "* " and trailing CRLF since this line bypasses session_untagged()'s 512-byte buffer. */
char buf[MBOX_QUOTED_MAX + 384];
size_t len;
int n, first = 1;
return;
}
- /*
- * Quoted, not bare: mailbox names can contain spaces. The
- * backslash-escaping gap this comment used to record is closed --
- * quote_mailbox() renders the SS4.3 quoted string, quoted-specials
- * and all. The matching gap on the INPUT side -- finding #1 of the
- * mailbox_cmd.c review -- is closed too: parse_mailbox_arg() decodes
- * the escapes, so a name this line emits can be sent back.
- */
+ /* Quoted, not bare, since mailbox names can contain spaces; quote_mailbox() now renders proper SS4.3 quoting and parse_mailbox_arg() decodes it on input, closing both the output and input escaping gaps from the mailbox_cmd.c review. */
if (quote_mailbox(qname, sizeof(qname), s->status_mailbox) == -1)
log_warnx("session %u: STATUS mailbox name truncated", s->id);
len = (size_t)snprintf(buf, sizeof(buf), "* STATUS %s (", qname);
if (s->status_attrs & STATUS_ATT_MESSAGES)
STATUS_APPEND("MESSAGES %u", res->messages);
if (s->status_attrs & STATUS_ATT_RECENT)
- /* IMAP4rev2 dropped \Recent (RFC 9051 SS2.3.2), this
- * server tracks no such state, so always answer 0; placed
- * here to mirror IMAP4rev1's conventional MESSAGES/RECENT
- * ordering, though RFC 9051 response order is unspecified
- * and clients parse by name, not position. */
+ /* IMAP4rev2 dropped \Recent (RFC 9051 SS2.3.2); this server tracks no such state so always answers 0, kept here only to mirror IMAP4rev1's MESSAGES/RECENT ordering. */
STATUS_APPEND("RECENT %u", 0U);
if (s->status_attrs & STATUS_ATT_UIDNEXT)
STATUS_APPEND("UIDNEXT %u", res->uidnext);
#undef STATUS_APPEND
- /*
- * Closing paren and CRLF written here rather than by
- * session_untagged(), whose 512-byte buffer the escaped name can
- * exceed. buf is sized so this always fits; the else is a guard,
- * not a reachable path, and it answers NO rather than sending a
- * tagged OK with no STATUS data behind it.
- */
+ /* Closing paren and CRLF are written here (not via session_untagged(), whose 512 bytes the escaped name can exceed); buf is sized to always fit, and the else is an unreachable guard answering NO. */
if (len + 3 < sizeof(buf)) {
buf[len++] = ')';
buf[len++] = '\r';
return;
}
- /*
- * The op succeeded and it named this session's own selected mailbox,
- * so the selection has to follow. s->state was restored to
- * mbox_op_prev_state above, and it gates the comparison because
- * s->selected_mailbox is only meaningful while SESSION_SELECTED (see
- * its declaration in listener.h: it's written optimistically when the
- * SELECT round trip starts, so a failed SELECT leaves a stale name).
- */
+ /* The op succeeded on this session's own selected mailbox, so selection must follow; s->state (restored to mbox_op_prev_state above) gates the check since s->selected_mailbox is meaningful only while SESSION_SELECTED. */
if (s->state == SESSION_SELECTED &&
strcmp(s->selected_mailbox, s->mbox_op_name) == 0) {
if (strcmp(cmdname, "RENAME") == 0) {
"truncated after RENAME, can't happen "
"(both same size)", s->id);
} else if (strcmp(cmdname, "DELETE") == 0) {
- /*
- * The selected mailbox is gone, so this session has
- * nothing selected: the same transition CLOSE makes
- * (session_handle_mbox_result() below), for a sharper
- * reason. RFC 9051 SS6.3.5 neither forbids deleting
- * the selected mailbox nor says what follows, but
- * staying SESSION_SELECTED would let the next FETCH/
- * STORE/SEARCH/COPY/MOVE/EXPUNGE through, and the
- * store child resolves every mailbox from its cwd --
- * which handle_mbox_delete() (mbox_manage.c) has
- * already returned to the maildir root, i.e. INBOX.
- * The client would silently operate on INBOX under a
- * name it believes it just deleted; STORE \Deleted +
- * EXPUNGE would destroy INBOX mail. The store child
- * clears its own SS6.2 gate independently, in
- * handle_mbox_delete().
- */
+ /* Selected mailbox is gone, so clear selection here too (like CLOSE does): staying SESSION_SELECTED would let further FETCH/STORE/SEARCH/COPY/MOVE/EXPUNGE silently hit INBOX (the store child's cwd after delete), risking INBOX data loss under a name the client thinks it deleted. */
s->state = SESSION_AUTHENTICATED;
s->selected_mailbox[0] = '\0';
session_reset_idle_baseline(s);
if (!list_pattern_match(s->list_pattern, item->mailbox, 0))
return;
- /*
- * Quoted, not bare, same reasoning as
- * session_handle_mbox_status_result(); "()" = no attributes
- * (SS7.3.1). This is the emission that made the escaping gap
- * client-visible: item->mailbox comes from readdir(2) in the store
- * child, so it carries whatever is actually on disk.
- */
+ /* Quoted, not bare, same reasoning as session_handle_mbox_status_result(); "()" means no attributes (SS7.3.1). item->mailbox comes straight from readdir(2) in the store child, so this is the emission that made the escaping gap client-visible. */
if (quote_mailbox(qname, sizeof(qname), item->mailbox) == -1)
log_warnx("session %u: LIST mailbox name truncated", s->id);
n = snprintf(buf, sizeof(buf), "* %s () \"/\" %s\r\n", kw, qname);
}
}
-/*
- * Discards this session's IDLE snapshot. Must be called whenever the mailbox
- * the snapshot describes stops being the selected one.
- *
- * The snapshot is per-SESSION state describing a per-MAILBOX fact, and nothing
- * used to reset it: a client that IDLEd on one mailbox, sent DONE, selected
- * another and IDLEd again had the second mailbox's UID list diffed against the
- * first's, so every UID present in the first and absent from the second was
- * reported to it as an untagged EXPUNGE for a message that was never expunged.
- * Two SELECTs and two IDLEs on one connection, no attacker and no concurrency
- * required. See the group-13 review's finding #1.
- */
+/* Discards this session's IDLE snapshot; must be called whenever the selected mailbox changes, since a stale snapshot diffed against a different mailbox's UID list would report untouched messages as spuriously EXPUNGEd (group-13 review finding #1). */
void
session_reset_idle_baseline(struct session *s)
{
s->idle_refresh_pending = 0;
- /*
- * The store child's cheap probe found nothing touched, so it sent no
- * IMSG_MBOX_IDLE_UID messages at all. Discarding the empty list and
- * keeping the baseline is not an optimisation here but a correctness
- * requirement: diffing an empty list against the baseline would emit
- * an untagged EXPUNGE for every message in the mailbox.
- */
+ /* Store child's cheap probe found nothing touched and sent no IMSG_MBOX_IDLE_UID messages; keeping the old baseline (not diffing against an empty list) avoids falsely reporting every message as EXPUNGEd. */
if (res->ok && res->unchanged) {
free(newlist);
goto maybe_again;
}
- /*
- * An incomplete list is discarded rather than diffed: every UID that
- * session_handle_idle_uid() had to drop would otherwise be reported
- * as an untagged EXPUNGE for a message that still exists, and the
- * short list would then become the new baseline, making the error
- * permanent. Keeping the last known state costs one stale view until
- * the next refresh; diffing costs the client's correctness.
- */
+ /* An incomplete UID list is discarded rather than diffed or adopted as the new baseline: diffing it would falsely EXPUNGE still-existing messages, and adopting it would make the gap permanent; keeping the old baseline just costs one stale view. */
if (!res->ok || incomplete) {
log_warnx("session %u: IDLE refresh %s, keeping last "
"known state", s->id,
s->id);
}
-/*
- * The IDLE poll (RFC 9051 SS6.3.13), which is what makes IDLE push anything
- * at all.
- *
- * It replaced session_notify_idle_peers(), which walked this process's
- * "sessions" list asking OTHER sessions to recheck after an
- * APPEND/EXPUNGE/COPY/MOVE. Under SS7 each listener process owns exactly one
- * session, so that loop always skipped its only element -- and even had it
- * worked, it would only ever have fired for changes made by another IMAP
- * session, never for mail an MTA delivered, which is the case IDLE exists
- * for. A poll covers both, and needs no notification path between processes.
- *
- * Most firings are cheap: the store child answers an untouched mailbox with
- * two stat(2) calls, no lock and no UID stream (index.c's
- * idle_probe_unchanged()).
- */
+/* The IDLE poll (RFC 9051 SS6.3.13) that drives IDLE pushes; replaces the old per-session notification walk (broken under SS7's one-session-per-process model and blind to MTA deliveries) with a poll that's cheap when nothing changed (index.c's idle_probe_unchanged(), two stat(2) calls). */
static void
session_idle_poll(int fd, short event, void *arg)
{
(void)fd;
(void)event;
- /*
- * Both transitions out of IDLE disarm this timer, so neither test
- * should be able to fail. They are here because the cost of being
- * wrong is a refresh whose reply arrives with the session no longer
- * idling, and session_handle_idle_refreshed() would then adopt a
- * different mailbox's UID list as the baseline.
- */
+ /* Both IDLE exits disarm this timer so these checks should never fail; they guard against a refresh reply arriving after the session already left IDLE, which would corrupt session_handle_idle_refreshed()'s baseline. */
if (!s->idling || s->state != SESSION_SELECTED) {
- /*
- * Should be unreachable. Logged rather than returned
- * silently: if it ever does fire, the silent version leaves
- * no evidence, and the consequence (a reply landing after
- * the session left IDLE) is a wrong baseline rather than a
- * crash.
- */
+ /* Should be unreachable; logged rather than silently ignored so a wrong-baseline bug (reply arriving after the session left IDLE) leaves evidence instead of just corrupting state invisibly. */
log_debug("session %u: idle poll fired while not idling "
"(idling=%d state=%d), ignored", s->id, s->idling,
(int)s->state);
session_untagged(s, buf);
}
-/*
- * Worst-case sizing for the RFC 7162 SS3.1.3 MODIFIED list, mirroring
- * search_cmd.c's SEARCH_ALL_PER_MATCH/SEARCH_RESP_PREFIX_MAX pair: one
- * entry is at most "4294967295" plus a separator, and the wrapper is the
- * tag (IMAP_TAG_MAX), the fixed words, cmdname and CRLF.
- */
+/* Worst-case sizing for RFC 7162 SS3.1.3's MODIFIED list, mirroring search_cmd.c's SEARCH_ALL_PER_MATCH/SEARCH_RESP_PREFIX_MAX pair: each entry is at most "4294967295" plus separator, plus tag, fixed words, cmdname and CRLF. */
#define MODIFIED_PER_ENTRY 11
#define MODIFIED_WRAPPER_MAX 160
} else
cmdname = s->cmd_by_uid ? "UID FETCH" : "FETCH";
- /*
- * res->error is enum mbox_op_error, where MBOX_OP_OK is 1 (MBOX_ERR_
- * UNSET occupies 0), so printing it raw under an "error=" label made
- * every successful FETCH/STORE/EXPUNGE/CLOSE log "error=1". Same
- * two-way label session_finish_search() already prints.
- */
+ /* res->error is enum mbox_op_error where MBOX_OP_OK==1 (0 is MBOX_ERR_UNSET), so logging it raw made every success read "error=1"; same two-way label session_finish_search() already uses. */
log_debug("session %u: %s done, status=%s, %u response(s) sent",
s->id, cmdname, res->error == MBOX_OP_OK ? "OK" : "ERROR",
res->count);
s->store_modified = NULL;
s->store_modified_n = 0;
s->store_modified_cap = 0;
+ s->store_modified_alloc_failed = 0;
snprintf(text, sizeof(text), "%s failed", cmdname);
session_reply(s, s->pending_tag, "NO", text);
return;
}
- /* RFC 7162 SS3.1.3: a failed-conditional STORE gets MODIFIED on its tagged OK */
- if (was_storing && s->store_modified_n > 0) {
+ /* RFC 7162 SS3.1.3: a failed-conditional STORE gets MODIFIED on its tagged OK. The alloc_failed arm of this test matters: a grow that failed on the very first entry leaves store_modified_n at 0, which would otherwise fall through to the unqualified "STORE completed" below and tell the client every message passed UNCHANGEDSINCE. */
+ if (was_storing && (s->store_modified_n > 0 ||
+ s->store_modified_alloc_failed)) {
char *rbuf = NULL, *text = NULL;
size_t rbufsize, textsize;
- int truncated, n, sent = 0;
+ int truncated, n, incomplete;
- /*
- * Heap-allocated and sized for the worst case, for the same
- * reason session_finish_search() does it for the ESEARCH ALL
- * list. The old fixed 2048-byte buffers were never the real
- * bound anyway: session_reply() composes into 512 bytes and,
- * on overflow, forces the last two back to CRLF -- which
- * amputates the closing "]" and ships a malformed
- * resp-text-code (RFC 9051 SS7.1). Composed here in full and
- * handed to session_write(), the same bypass ESEARCH and
- * VANISHED (EARLIER) already use.
- */
+ /* Every way the set can come up short lands here: a failed grow in session_handle_store_modified(), a formatter truncation, a response that doesn't fit, or buffers that don't allocate. */
+ incomplete = s->store_modified_alloc_failed;
+
+ /* Heap-allocated and worst-case-sized like session_finish_search()'s ESEARCH ALL list, since the old fixed 2048-byte buffers weren't the real bound: session_reply()'s 512-byte overflow handling amputates the closing "]" into a malformed resp-text-code; composed in full and sent via session_write(), same as ESEARCH and VANISHED (EARLIER). */
rbufsize = (size_t)s->store_modified_n * MODIFIED_PER_ENTRY + 1;
textsize = rbufsize + MODIFIED_WRAPPER_MAX;
- if ((rbuf = malloc(rbufsize)) != NULL &&
- (text = malloc(textsize)) != NULL) {
- format_seq_list(rbuf, rbufsize, s->store_modified,
- s->store_modified_n, &truncated);
- if (truncated)
- log_warnx("session %u: MODIFIED list "
- "truncated", s->id);
-
- n = snprintf(text, textsize, "%s OK [MODIFIED %s] "
- "Conditional %s failed for some messages\r\n",
- s->pending_tag, rbuf, cmdname);
- if (n < 0 || (size_t)n >= textsize)
- log_warnx("session %u: MODIFIED response text "
- "truncated", s->id);
- else {
- session_write(s, text, (size_t)n);
- sent = 1;
+ if (!incomplete) {
+ if ((rbuf = malloc(rbufsize)) != NULL &&
+ (text = malloc(textsize)) != NULL) {
+ format_seq_list(rbuf, rbufsize,
+ s->store_modified, s->store_modified_n,
+ &truncated);
+ /* Can't fire today (MODIFIED_PER_ENTRY sizes rbuf for the worst case, as SEARCH_ALL_PER_MATCH does for ESEARCH), but a short list is indistinguishable from a complete one to the client, so it is refused rather than sent. */
+ if (truncated) {
+ log_warnx("session %u: MODIFIED list "
+ "truncated", s->id);
+ incomplete = 1;
+ } else {
+ n = snprintf(text, textsize,
+ "%s OK [MODIFIED %s] Conditional "
+ "%s failed for some messages\r\n",
+ s->pending_tag, rbuf, cmdname);
+ if (n < 0 || (size_t)n >= textsize) {
+ log_warnx("session %u: MODIFIED"
+ " response text truncated",
+ s->id);
+ incomplete = 1;
+ } else
+ session_write(s, text,
+ (size_t)n);
+ }
+ } else {
+ log_warn("session %u: malloc MODIFIED response",
+ s->id);
+ incomplete = 1;
}
- } else
- log_warn("session %u: malloc MODIFIED response", s->id);
+ }
free(rbuf);
free(text);
- /*
- * Fallback drops the response code rather than emit an empty
- * or truncated one: RFC 7162 SS3.1.3's MODIFIED takes a
- * non-empty sequence set, and a client is better served by a
- * plain tagged OK it can parse than by a malformed code.
- */
- if (!sent) {
- char fallback[64];
+ /* SS3.1.3's set MUST list every message that failed UNCHANGEDSINCE, and per SS3.1.3's client guidance a message absent from it is one the client believes was stored and will never retry -- so a set known to be short is never sent, and dropping the code entirely would say the same thing (no MODIFIED means nothing failed). Refusing the command is what every other variable-length list here does on a failed grow; RFC 5530 SS3 UNAVAILABLE marks it transient, the same code session_handle_mbox_selected() uses for a dropped QRESYNC range, so a client retries rather than treating the STORE as rejected. */
+ if (incomplete) {
+ char fail[64];
- snprintf(fallback, sizeof(fallback),
- "%s failed for some messages", cmdname);
- session_reply(s, s->pending_tag, "OK", fallback);
+ snprintf(fail, sizeof(fail), "[UNAVAILABLE] %s failed",
+ cmdname);
+ session_reply(s, s->pending_tag, "NO", fail);
}
free(s->store_modified);
s->store_modified = NULL;
s->store_modified_n = 0;
s->store_modified_cap = 0;
+ s->store_modified_alloc_failed = 0;
return;
}
free(s->store_modified);
s->store_modified = NULL;
s->store_modified_n = 0;
s->store_modified_cap = 0;
+ s->store_modified_alloc_failed = 0;
/* RFC 7162 SS3.2.7: real EXPUNGE (not CLOSE, SS3.2.8 forbids it) with count>0 gets HIGHESTMODSEQ, once CONDSTORE-aware. */
blob - 506ba984b2f719ebdb67c95201f3a938af8b72ed
blob + f163764dbe576e9f6320459fb57d8d8d868fb1e5
--- src/utf8.c
+++ src/utf8.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * utf8.c, the mailbox-name UTF-8 predicate.
- *
- * RFC 9051 SS5.1: "Server implementations MUST prohibit the creation of
- * 8-bit mailbox names that do not comply with Net-Unicode." Net-Unicode is
- * RFC 5198 SS2, which is six requirements, not one. This file implements
- * three of them and is explicit about the three it does not:
- *
- * item 1 UTF-8 per RFC 3629 ENFORCED (utf8_mailbox_ok)
- * item 2 CRLF line endings N/A -- CR and LF are
- * already refused by both
- * validators' c < 0x20 test
- * item 3 C1 controls U+0080-U+009F MUST NOT ENFORCED
- * (C0 and DEL are refused by the same
- * c < 0x20 || c == 0x7f test, which
- * RFC 9051 SS5.1 point 2 sanctions)
- * item 4 NFC normalization (a SHOULD) NOT ENFORCED
- * item 5 no leading BOM ENFORCED, and stricter:
- * U+FEFF is refused anywhere
- * item 6 no unassigned code points NOT ENFORCED
- *
- * Items 4 and 6 are left out deliberately, and imapd.8 says so rather than
- * letting the omission pass as compliance. Both need a Unicode character
- * database: item 6 needs the assigned-code-point set, item 4 needs canonical
- * decomposition mappings, combining classes and composition exclusions. Each
- * is tens of kilobytes of generated tables plus a standing commitment to
- * regenerate them for every Unicode release, in a daemon whose case is its
- * smallness. The visible cost of skipping item 4 is that two canonically
- * equivalent spellings of the same name (U+00E9, versus U+0065 U+0301) are
- * two different mailboxes here.
- *
- * There is no decoding to a code point and no table lookup below. The legal
- * range of the SECOND byte depends on the first, and that dependency is
- * exactly what excludes overlong encodings, the UTF-16 surrogates and
- * everything above U+10FFFF -- so writing the ranges out is both the whole
- * check and the clearest statement of RFC 3629 SS4's well-formed byte
- * sequence table.
- */
+/* utf8_mailbox_ok() enforces RFC 5198 SS2's UTF-8/C1-control/no-BOM requirements (3 of its 6 Net-Unicode items; NFC normalization and unassigned-code-point checks are deliberately unenforced, per imapd.8) via raw byte-range checks rather than decoding, since the well-formed-sequence ranges alone are what RFC 3629 SS4 requires. */
#include "utf8.h"
-/*
- * 1 if `name` is a NUL-terminated string this server will accept as a
- * mailbox name's encoding, 0 otherwise. Says nothing about whether the name
- * is otherwise acceptable -- "/", ".", "..", the reserved imapd.* names and
- * the length bound are each validator's own business, and both apply them
- * alongside this.
- */
+/* Returns 1 if `name` is a NUL-terminated string whose encoding this server accepts as a mailbox name, 0 otherwise; other rules ("/", ".", "..", reserved imapd.* names, length) are each caller's own responsibility. */
int
utf8_mailbox_ok(const char *name)
{
return (0);
}
- /*
- * Reading p[1] cannot run past the terminator: if p[0] is the
- * last byte then p[1] is the NUL, which is below every `lo`
- * and fails here. p[2] and p[3] are reached only after their
- * predecessor tested inside 0x80-0xbf, so that predecessor
- * was not the NUL either. The walk stops at the first bad
- * byte in every case, terminator included.
- */
+ /* Reading p[1..3] can't run past the terminator: each byte is only reached after its predecessor tested inside 0x80-0xbf, so the walk always stops at the first bad byte, terminator included. */
if (p[1] < lo || p[1] > hi)
return (0);
for (i = 2; i <= need; i++)
if (p[0] == 0xc2 && p[1] <= 0x9f)
return (0);
- /*
- * RFC 5198 SS2 item 5 forbids a BOM at the beginning.
- * U+FEFF is refused wherever it appears: a zero-width
- * no-break space in the middle of a mailbox name is a name
- * no user can tell apart from its neighbour, which is worth
- * more than matching the RFC's exact scope. imapd.8 records
- * that this is deliberately stricter.
- */
+ /* RFC 5198 SS2 item 5 bans a leading BOM; U+FEFF is refused anywhere in the name (stricter than the RFC, and imapd.8 says so) since a zero-width no-break space mid-name would make two mailboxes indistinguishable. */
if (p[0] == 0xef && p[1] == 0xbb && p[2] == 0xbf)
return (0);