commit - d796c4b1f495b1727d8f9698d9e42374051e54de
commit + a96b2723a3021df065f3860ba0115f2bfea97764
blob - 5d16137ef931b81fc220b56307c58534bb8579f3
blob + d52e35c2e1f4e621ef4078d51583e7add8cb3d06
--- README.md
+++ README.md
A from-scratch IMAP4rev2 ([RFC 9051](https://www.rfc-editor.org/rfc/rfc9051)) server for OpenBSD, written in traditional C in the privilege-separated tradition of `smtpd(8)`, `httpd(8)`, and `ntpd(8)`. No third-party IMAP library, no borrowed protocol engine.
-**Status:** pre-release, version 0.1. Actively developed. Not yet a port — see [Getting the source](#getting-the-source) below for the repository.
+**Status:** pre-release, version 0.1.1. Actively developed. Not yet a port — see [Getting the source](#getting-the-source) below for the repository.
## What it is
blob - e03331f785bb81925fc872c7ba73aeb8050f1a40 (mode 755)
blob + bfef11cc5d838831f3115309fc760b76d835c501 (mode 644)
--- contrib/imapd-teardown
+++ contrib/imapd-teardown
#
# $OpenIMAPD$
#
+# 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.
+#
# imapd-teardown -- completely remove an installed imapd, to test
# repeated from-scratch installs.
#
# installed by src/Makefile's afterinstall: target -- there's no real
# operational reason a production box would ever want to "uninstall
# itself," so it stays contrib/-only, run straight out of the source
-# tree). It exists because the fresh-install pass documented in
-# README.skeleton ("Fresh imapd install on premio (task #228)") found
+# tree). It exists because a fresh-install pass (task #228) found
# five real gaps that only showed up by actually attempting an install
# on a genuinely clean system -- this script is what makes "genuinely
# clean system" repeatable without needing an actual fresh box every
#
# - smtpd.conf's shd_userbase table (a different daemon's config) is
# never touched. If you also want smtpd to stop trying to deliver
-# into a wiped spool, that's a separate, deliberate edit -- see
-# the smtpd.conf gap in README.skeleton's fresh-install writeup
-# for what that table looks like.
+# into a wiped spool, that's a separate, deliberate edit to that
+# table.
#
# Usage:
# doas ./imapd-teardown [-y] [-M] [-c credentials-dir] [-f config-file]
else
userdel "$_acct" 2>/dev/null || true
# useradd's own default (no -g given) creates a same-
- # named group alongside the account -- see README.
- # skeleton's account-provisioning writeup, confirmed
- # there against the pre-rename _openimap/_openimapd
+ # named group alongside the account, confirmed
+ # against the pre-rename _openimap/_openimapd
# accounts. userdel(8) itself makes no mention of
# touching groups, so that group is cleaned up
# separately here, guarded against already being gone.
blob - fb06375a9eb329bd597bd0e995475eb338ebe2b7 (mode 755)
blob + 4a3837a3ad12eadd36f78dce37c7223f721d5187 (mode 644)
--- contrib/imapduser
+++ contrib/imapduser
#
# $OpenIMAPD$
#
+# 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.
+#
# imapduser -- add or delete an imapd mailbox account.
#
# Renamed and given a real -a/-d mode split from its previous identity
# (github.com/openbsd/ports, security/cyrus-sasl2/pkg/PLIST, read
# directly rather than assumed): it installs "@bin sbin/saslpasswd2"
# and "@man man/man8/saslpasswd2.8" for precisely this reason --
-# managing sasldb2, SASL's own bespoke secrets store. See
-# README.skeleton for the fuller writeup of this design decision.
+# managing sasldb2, SASL's own bespoke secrets store.
#
# imapd's credentials file is deliberately self-contained the same
# way: auth.c's cred_lookup() reads "username:passwordhash:uid:gid:
# maildir" lines straight out of it and never calls getpwnam(3) for an
-# IMAP end user (see auth.c's own header comment, citing
-# openimap-privsep-design.md -- that decision is specifically about
-# end users, not about auth's own _imapauth service identity).
+# IMAP end user (see auth.c's own header comment -- that decision is
+# specifically about end users, not about auth's own _imapauth service
+# identity).
# store.c's per-session store child then drops privileges directly to
# that uid/gid (IMSG_STORE_INIT handling) before chroot(2)-ing into
# spool_root and chdir(2)-ing into "/maildir" -- chdir(2) is NOT
# pointed at. It does NOT create or remove a real system account: no
# useradd(8)/userdel(8)/adduser(8), nothing written to /etc/passwd or
# /etc/group. That's the whole point of the design this script is
-# automating -- see the project's own README.skeleton for the fuller
-# writeup.
+# automating.
#
# Usage:
# imapduser -a [-c credentials-file] [-s spool-root] [-u uid] [-g gid] username
blob - 1e7591ee676c84c03570f9cffa7293aba421de20
blob + ac69fe628610f731e8f0c4d4be75f349932e41d4
--- contrib/imapduser.8
+++ contrib/imapduser.8
.Nm
was written for the OpenIMAPD project.
It replaces an earlier, add-only, contrib/-only script named
-.Nm newimapuser ;
-see the project's own
-.Pa README.skeleton
-for the rationale behind the rename and the
-.Fl a Ns / Ns Fl d
-split, including the real precedent this follows: OpenBSD's own
+.Nm newimapuser ,
+renamed and split into
+.Fl a Ns / Ns Fl d .
+.Nm
+is now installed rather than left as a dev-tree-only script, following
+the same real precedent as OpenBSD's own
.Sy cyrus-sasl2
-port installs
+port, which installs
.Xr saslpasswd2 8
to
.Pa ${PREFIX}/sbin
-for the same reason
-.Nm
-is now installed rather than left as a dev-tree-only script --
-managing a daemon's own bespoke, non-system credentials store.
+for the same reason: managing a daemon's own bespoke, non-system
+credentials store.
blob - 2e079fcefdb9b26541b8babb639326c61e0cb4fa
blob + a2453cb647b3f6b3ce336c5fa0ada94d5d63bfd8
--- src/Makefile
+++ src/Makefile
# project fits the single-daemon shape, so the project itself was
# renamed OpenIMAP -> OpenIMAPD to match.
PROG= imapd
-SRCS= main.c parent.c listener.c auth.c store.c log.c imsgev.c \
- parse.y
+# listener.c and store.c were each a single file through the end of
+# 0.1 (10,619 and 7,768 lines respectively) -- far larger than any file
+# in smtpd (24 files, largest 3,104 lines) or httpd (largest 2,029
+# lines), the two base-system daemons this project otherwise follows
+# most closely. Split by responsibility, matching that precedent: the
+# listener process's own source is now listener.c (core: accept loop,
+# session I/O, line dispatch) + auth_cmd.c + mailbox_cmd.c + append_cmd.c
+# + fetch_cmd.c + search_cmd.c + store_cmd.c + store_ipc.c, sharing
+# struct session and cross-file prototypes via listener.h (not installed,
+# not part of the wire protocol -- see that file). The store process's
+# own source is now store.c (core: imsg dispatch loop) + index.c + mime.c
+# + envelope.c + mbox_fetch.c + mbox_search.c + mbox_store.c +
+# mbox_manage.c + mbox_copy.c, sharing struct mbox_index and cross-file
+# prototypes via store_internal.h the same way. Pure move, no behavior
+# change -- see each new file's own header comment for exactly which
+# commands/responsibility it holds.
+SRCS= main.c parent.c log.c imsgev.c parse.y \
+ 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 \
+ store.c index.c mime.c envelope.c mbox_fetch.c mbox_search.c \
+ mbox_store.c mbox_manage.c mbox_copy.c
+
# imapd isn't in OpenBSD base and has no ports-framework Makefile of its
# own (no bsd.port.mk, no PREFIX), so BINDIR/MANDIR must be set explicitly:
# neither bsd.own.mk nor bsd.prog.mk defaults BINDIR (confirmed by grepping
# literal text -- the later redirect wins for the same fd, so "> /dev/null"
# silently drops and install.sh ends up with a bare "./${PROG} -V" instead
# of the intended output-suppressed form. Confirmed by reading the actual
-# generated line in a real "make install" transcript on premio: "-V"'s
+# generated line in a real "make install" transcript on test hardware: "-V"'s
# stdout leaked into rc.d/imapd's rc_pre() relink log instead of being
# discarded. Harmless (doesn't affect correctness -- install.sh's
# "set -o errexit" still aborts on real failures either way), but not the
MAN= imapd.8
-WARNS= 6
-CFLAGS+= -Wall -Wstrict-prototypes -Wmissing-prototypes
+CFLAGS+= -Wall -Wextra -Wstrict-prototypes -Wmissing-prototypes
CFLAGS+= -Wmissing-declarations -Wshadow -Wpointer-arith
-CFLAGS+= -Wsign-compare
+CFLAGS+= -Wsign-compare -Wcast-qual -Wcast-align
# Explicit -g: bsd.prog.mk's DEBUG?=-g default apparently isn't reaching
# the actual compile line in this tree (the crash-diagnosis gdb session
-# on premio showed "no debugging symbols found" against a plain `make`
+# on the test machine showed "no debugging symbols found" against a plain `make`
# build), so force it directly rather than relying on that default.
DEBUG= -g
blob - ea5d4f17b82c25482bae5d578d4e83b4a32d760e
blob + 03591a478c2cae7027e77e94fad9b6e7472667f3
--- src/auth.c
+++ src/auth.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * auth.c -- credential verification process. Implements the "auth"
- * section of openimap-privsep-design.md: verifies AUTHENTICATE PLAIN
- * credentials against the self-contained flat credential file
- * ("username:passwordhash:uid:gid:maildir", bcrypt hashes), using
- * crypt_checkpass(3) -- sourced against the local crypt_checkpass(3) man
- * page, including its documented timing-mitigation behavior for unknown
- * usernames (see auth_verify() below).
- *
- * API NAMES: checked against the real src/imsg.h this session -- see
- * the header comment in parent.c for the full verification note.
- *
- * getpwnam("_imapauth") below is a DIFFERENT thing from the
- * credential-file design decision in openimap-privsep-design.md ("the
- * credential file is self-contained... auth never calls getpwnam()").
- * That decision was about IMAP end users (mailbox owners) not needing
- * real system accounts. _imapauth is auth's own fixed daemon-user
- * identity -- an ordinary OpenBSD system daemon user, expected to exist
- * in /etc/passwd like _smtpd/_syslogd/etc. Resolving *that* via
- * getpwnam() is unrelated to, and does not reopen, the earlier decision.
- * (Renamed from _openimapd along with the rest of the daemon's own
- * on-disk/system identity -- see imapd.h's header comment for the
- * rename-scoping policy. listener.c's counterpart daemon user is
- * _imapd, not _imapauth -- the two roles need distinct names since
- * "imapd" alone is already taken by the primary/listener role.)
- */
+/* auth.c -- credential verification process: AUTHENTICATE PLAIN against the flat cred file. */
#include <sys/types.h>
};
static struct imsgev iev_listener;
+static struct imsgev iev_parent; /* fd 3, alive for the process's lifetime -- task #321 */
static char cred_file_basename[256];
static int cred_lookup(const char *, const char *username,
static void auth_verify(struct imsg_auth_request *,
struct imsg_auth_result *);
static void auth_dispatch(int, short, void *);
+static void auth_dispatch_parent(int, short, void *);
__dead void
auth_main(void)
if (imsgbuf_init(&ibuf3, 3) == -1)
fatal("imsgbuf_init");
- imsgbuf_allow_fdpass(&ibuf3); /* receives the fd-passed
- * IMSG_SETUP_PEER peer fd below -- see
- * imsgev.c's imsgev_init() comment. */
+ imsgbuf_allow_fdpass(&ibuf3); /* for the fd-passed IMSG_SETUP_PEER peer fd below */
- /*
- * IMSG_AUTH_INIT must be the first message read -- same reasoning
- * as store.c's IMSG_STORE_INIT: we need cred_file before we can
- * even compute a chroot() target, let alone chroot into it. Closes
- * the gap flagged in an earlier pass, where this process took a
- * struct openimap_config * that main.c never actually populated
- * for a re-exec'd child -- conf->cred_file was always an empty
- * string. See imapd.h's imsg_auth_init comment.
- */
- /*
- * imsg_get() before imsgbuf_read() -- not just tidiness. See
- * imsgev.c's setup_recv_one_peer() header comment for the real
- * deadlock this ordering caused elsewhere (parent's IMSG_SETUP_PEER
- * + IMSG_SETUP_DONE coalescing into one recvmsg() on a SOCK_STREAM
- * socketpair). The same risk applies here in principle -- parent
- * sends IMSG_AUTH_INIT then, later, this channel's IMSG_SETUP_PEER,
- * with no synchronization forcing them into separate reads -- so
- * this loop checks for an already-buffered message before ever
- * issuing a real blocking read.
- */
+ /* IMSG_AUTH_INIT must be read first: cred_file is needed before chroot() can be computed */
for (;;) {
if ((n = imsg_get(&ibuf3, &imsg)) == -1)
fatal("imsg_get");
fatalx("auth: bad IMSG_AUTH_INIT payload");
imsg_free(&imsg);
+ /* auth's own daemon-user identity; distinct from listener's _imapd. */
if ((pw = getpwnam("_imapauth")) == NULL)
fatalx("getpwnam _imapauth: no such user "
"(expected, not yet provisioned by an install script)");
- /*
- * chroot into the directory *containing* the credential file, per
- * the design doc -- not the file itself. cred_file_basename is
- * kept for the unveil() call below (relative to the new root).
- */
- (void)strlcpy(chrootdir, init.cred_file, sizeof(chrootdir));
+ /* chroot into the dir containing the cred file, not the file itself; basename kept for unveil() */
+ if (strlcpy(chrootdir, init.cred_file, sizeof(chrootdir)) >=
+ sizeof(chrootdir))
+ fatalx("cred_file too long: %s", init.cred_file);
if ((slash = strrchr(chrootdir, '/')) == NULL)
fatalx("cred_file must be an absolute path: %s",
init.cred_file);
- (void)strlcpy(cred_file_basename, slash + 1,
- sizeof(cred_file_basename));
+ if (strlcpy(cred_file_basename, slash + 1,
+ sizeof(cred_file_basename)) >= sizeof(cred_file_basename))
+ fatalx("cred_file basename too long: %s", init.cred_file);
*slash = '\0';
if (chroot(chrootdir) == -1)
setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1)
fatal("cannot drop privileges to _imapauth");
- /* boot-time handshake: one peer (listener), then SETUP_DONE+ack --
- * see imsgev.c's setup_recv_*() header comments. */
+ /* boot-time handshake: one peer (listener), then SETUP_DONE+ack */
peer_fd = setup_recv_one_peer(&ibuf3);
setup_recv_done_and_ack(&ibuf3);
event_init();
imsgev_init(&iev_listener, peer_fd, auth_dispatch, NULL);
- /*
- * unveil() path is relative to the chroot above -- "/" +
- * cred_file_basename, per the design doc's "unveil() restricted
- * to the single credential-file path, read-only."
- */
+ /* task #321: reuses fd 3's populated ibuf3 -- a fresh imsgbuf_init()
+ * would drop buffered bytes. Kept alive post-boot (unlike before)
+ * so successful logins can IMSG_AUTH_CRED parent directly; mirrors
+ * listener.c's own boot -> iev_parent conversion. */
+ imsgev_init_from_ibuf(&iev_parent, &ibuf3, auth_dispatch_parent, NULL);
+
+ /* unveil() path is relative to the chroot above: "/" + basename. */
{
char unveil_path[512];
+ /* cred_file_basename[256] is already strlcpy(3)-truncation-
+ * checked above; "/" + up to 255 bytes can never approach
+ * this 512-byte buffer, so snprintf(3) here can't truncate --
+ * the return value is explicitly discarded, not overlooked. */
(void)snprintf(unveil_path, sizeof(unveil_path), "/%s",
cred_file_basename);
if (unveil(unveil_path, "r") == -1)
fatalx("auth: exited event loop");
}
-/*
- * Real bug caught on the first real-hardware run (OpenBSD, not this
- * sandbox): imsg_compose() only queues a message in this process's own
- * userspace buffer -- it performs no I/O itself. Confirmed directly
- * against imsg_init(3)'s own EXAMPLES section: "When the socket is
- * ready for writing, queued messages are transmitted with
- * imsgbuf_write()." imsgev_add() (imsgev.c) correctly arms EV_WRITE
- * whenever imsgbuf_queuelen() is nonzero, but until this fix nothing
- * ever handled that event -- every dispatch function in this codebase
- * only ever checked "event & EV_READ". The observed symptom: a listener
- * process pegged at 25+ minutes of CPU time while otherwise idle (caught
- * via `ps`), because listener_dispatch_auth()'s own unconditional
- * imsgev_add() at the end of every call kept re-arming EV_WRITE for a
- * write that never happened, on a socket that's *always* writable --
- * the textbook busy-loop shape. Confirmed on auth's side via ktrace(1):
- * an IMSG_AUTH_REQUEST send from listener produced zero syscalls here,
- * because it never actually left listener's own queue.
- */
+/* EV_WRITE must be handled: imsg_compose() only queues, imsgbuf_write() puts it on the wire */
static void
auth_dispatch(int fd, short event, void *arg)
{
log_warnx("bad IMSG_AUTH_REQUEST");
break;
}
- /* F8 fix: imsg_get_data() guarantees payload size but
- * not NUL termination; force it before these fields are
- * used as C strings by auth_verify(). */
+ /* imsg_get_data() guarantees size, not NUL termination -- force it */
req.username[sizeof(req.username) - 1] = '\0';
req.password[sizeof(req.password) - 1] = '\0';
memset(&res, 0, sizeof(res));
res.session_id = req.session_id;
auth_verify(&req, &res);
- /* explicit_bzero() the plaintext password out of our
- * own stack copy as soon as we're done with it --
- * not itself sourced from any uploaded file this
- * session, just good hygiene given the credential
- * material involved. */
+ /* scrub the plaintext password from our stack copy */
explicit_bzero(req.password, sizeof(req.password));
if (imsg_compose(&iev->ibuf, IMSG_AUTH_RESULT, 0, 0,
-1, &res, sizeof(res)) == -1)
log_warn("imsg_compose IMSG_AUTH_RESULT");
imsgev_add(iev);
+
+ /* task #321: parent, not listener, is who actually
+ * spawns the store child -- send it uid/gid/maildir
+ * directly rather than trusting listener to relay
+ * what we just told it. */
+ if (res.ok) {
+ struct imsg_auth_cred cred;
+
+ memset(&cred, 0, sizeof(cred));
+ 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. */
+ (void)strlcpy(cred.maildir, res.maildir,
+ sizeof(cred.maildir));
+ if (imsg_compose(&iev_parent.ibuf,
+ IMSG_AUTH_CRED, 0, 0, -1, &cred,
+ sizeof(cred)) == -1)
+ log_warn("imsg_compose IMSG_AUTH_CRED");
+ imsgev_add(&iev_parent);
+ }
break;
}
default:
}
imsg_free(&imsg);
}
- /*
- * Real bug caught on first real-hardware run, right after fixing
- * the missing-EV_WRITE gap above: the imsgev_add(iev) inside the
- * IMSG_AUTH_REQUEST case only re-arms when this call actually
- * processed a message. Once EV_WRITE handling was added, this
- * function could now be invoked for a pure EV_WRITE firing with
- * nothing new to read -- the for loop above finds nothing, no case
- * runs, and without this unconditional call the event lapses for
- * good (imsgev_init() is plain EV_READ, not EV_PERSIST -- see
- * imsgev.c). auth has exactly one registered event, so losing it
- * empties event_dispatch()'s whole watch set, which returns and
- * hits this file's own "auth: exited event loop" fatalx() --
- * exactly what happened live: IMSG_AUTH_RESULT successfully sent
- * (the EV_WRITE fix working as intended), immediately followed by
- * auth exiting because nothing re-armed its read side afterward.
- * Matches the same unconditional-re-arm shape already used by
- * listener_dispatch_auth()/listener_dispatch_parent()/
- * session_store_dispatch() (listener.c) and store_child_dispatch()
- * (parent.c).
- */
+ /* unconditional re-arm: imsgev_init() is EV_READ not EV_PERSIST, so a pure EV_WRITE call would let it lapse */
imsgev_add(iev);
(void)fd;
}
-/*
- * Verifies req->password against the stored hash for req->username, and
- * fills *res. Always calls crypt_checkpass() -- with hash == NULL on an
- * unknown username -- rather than short-circuiting on a failed
- * cred_lookup(), per crypt_checkpass(3)'s documented behavior: "If the
- * hash is NULL, authentication will always fail, but a default amount of
- * work is performed to simulate the hashing operation." Short-circuiting
- * here would let login timing leak whether a username exists in the
- * credential file -- exactly what that NULL-hash behavior exists to
- * prevent.
- */
+/* task #321: parent never sends auth anything post-boot, so this exists to flush queued IMSG_AUTH_CRED writes and notice if parent's end closes */
static void
+auth_dispatch_parent(int fd, short event, void *arg)
+{
+ struct imsgev *iev = arg;
+ struct imsg imsg;
+ ssize_t n;
+
+ if (event & EV_WRITE) {
+ if (imsgbuf_write(&iev->ibuf) == -1)
+ fatal("imsgbuf_write");
+ }
+
+ if (event & EV_READ) {
+ if ((n = imsgbuf_read(&iev->ibuf)) == -1)
+ fatal("imsgbuf_read");
+ if (n == 0) {
+ log_warnx("parent closed channel");
+ event_del(&iev->ev);
+ return;
+ }
+ }
+
+ for (;;) {
+ if ((n = imsg_get(&iev->ibuf, &imsg)) == -1)
+ fatal("imsg_get");
+ if (n == 0)
+ break;
+
+ log_debug("auth_dispatch_parent: unhandled %d",
+ imsg_get_type(&imsg));
+ imsg_free(&imsg);
+ }
+ imsgev_add(iev);
+ (void)fd;
+}
+
+/* always calls crypt_checkpass() with hash == NULL on unknown username, to avoid timing leaks */
+static void
auth_verify(struct imsg_auth_request *req, struct imsg_auth_result *res)
{
struct cred_entry ce;
if (found)
hash = ce.passwordhash;
- if (crypt_checkpass(req->password, hash) == 0 && found) {
+ if (crypt_checkpass(req->password, hash) == 0 && found &&
+ strlcpy(res->maildir, ce.maildir, sizeof(res->maildir)) <
+ sizeof(res->maildir)) {
res->ok = 1;
res->uid = ce.uid;
res->gid = ce.gid;
- (void)strlcpy(res->maildir, ce.maildir,
- sizeof(res->maildir));
} else {
res->ok = 0;
}
explicit_bzero(&ce, sizeof(ce));
}
-/*
- * Scans the credential file (relative to our chroot, so just its
- * basename -- see auth_main()) line by line for "username", per the
- * "username:passwordhash:uid:gid:maildir" format resolved in
- * openimap-privsep-design.md. Linear scan -- fine for v1's expected
- * credential-file size (personal-use scope, a handful of users); revisit
- * only if that stops being true.
- */
+/* linear scan of "username:passwordhash:uid:gid:maildir" lines -- fine for v1's small cred files */
static int
cred_lookup(const char *path, const char *username, struct cred_entry *out)
{
if (strcmp(fields[0], username) != 0)
continue;
- (void)strlcpy(out->username, fields[0],
- sizeof(out->username));
- (void)strlcpy(out->passwordhash, fields[1],
- sizeof(out->passwordhash));
+ /* skip rather than silently truncate a field, same as every other malformed-line case */
+ if (strlcpy(out->username, fields[0], sizeof(out->username))
+ >= sizeof(out->username) ||
+ strlcpy(out->passwordhash, fields[1],
+ sizeof(out->passwordhash)) >= sizeof(out->passwordhash))
+ continue;
errno = 0;
out->uid = (uid_t)strtoul(fields[2], &ep, 10);
if (*ep != '\0' || errno != 0)
out->gid = (gid_t)strtoul(fields[3], &ep, 10);
if (*ep != '\0' || errno != 0)
continue;
- (void)strlcpy(out->maildir, fields[4], sizeof(out->maildir));
+ if (strlcpy(out->maildir, fields[4], sizeof(out->maildir)) >=
+ sizeof(out->maildir))
+ continue;
found = 1;
break;
}
blob - /dev/null
blob + 0af51913734a344c0d44415b736ff248d1c5fd40 (mode 644)
--- /dev/null
+++ src/append_cmd.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.
+ */
+
+/*
+ * append_cmd.c -- APPEND: literal-driven message upload, and its
+ * asynchronous IMSG_MBOX_APPENDED completion handling.
+ */
+
+#include <sys/types.h>
+#include <sys/queue.h>
+#include <sys/socket.h>
+
+#include <netinet/in.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <event.h>
+#include <imsg.h>
+#include <resolv.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <tls.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "listener.h"
+
+/* RFC 9051 SS9 date-time via sscanf(3); calendar validity (e.g. Feb 31) unchecked, timegm(3) normalizes it. */
+int
+parse_date_time(const char *s, int64_t *out)
+{
+ struct tm tm;
+ char mon[4];
+ int day, year, hh, mm, ss, zh, zm, i;
+ char zsign;
+ time_t t;
+ int64_t zoff;
+
+ memset(&tm, 0, sizeof(tm));
+
+ if (sscanf(s, "%2d-%3s-%4d %2d:%2d:%2d %c%2d%2d", &day, mon, &year,
+ &hh, &mm, &ss, &zsign, &zh, &zm) != 9)
+ return (-1);
+
+ if (day < 1 || day > 31 || hh < 0 || hh > 23 || mm < 0 || mm > 59 ||
+ ss < 0 || ss > 60 || year < 1970 ||
+ (zsign != '+' && zsign != '-'))
+ return (-1);
+
+ for (i = 0; i < 12; i++) {
+ if (strcasecmp(mon, fetch_month_names[i]) == 0)
+ break;
+ }
+ if (i == 12)
+ return (-1);
+
+ tm.tm_mday = day;
+ tm.tm_mon = i;
+ tm.tm_year = year - 1900;
+ tm.tm_hour = hh;
+ tm.tm_min = mm;
+ tm.tm_sec = ss;
+
+ if ((t = timegm(&tm)) == (time_t)-1)
+ return (-1);
+
+ zoff = (int64_t)zh * 3600 + (int64_t)zm * 60;
+ if (zsign == '-')
+ zoff = -zoff;
+
+ *out = (int64_t)t - zoff;
+ return (0);
+}
+
+/* Result struct for parse_append_args() -- avoids an unwieldy number of out-parameters. */
+struct append_parsed {
+ char mailbox[MBOX_NAME_MAX];
+ uint32_t sysflags;
+ char keywords[MBOX_FLAGS_MAX];
+ int has_date;
+ int64_t date;
+ uint64_t litlen;
+ int litnonsync;
+};
+
+/* RFC 9051 SS6.3.12 append grammar; flag-list parsing reused from parse_store_flags(). */
+int
+parse_append_args(char *args, struct append_parsed *out, const char **errmsg)
+{
+ char *p = args;
+
+ memset(out, 0, sizeof(*out));
+ *errmsg = NULL;
+
+ if (p == NULL || *p == '\0') {
+ *errmsg = "APPEND requires a mailbox name";
+ return (-1);
+ }
+
+ while (*p == ' ')
+ p++;
+ if (*p == '"') {
+ const char *start = p + 1;
+ char *end = strchr(start, '"');
+ size_t len;
+
+ if (end == NULL) {
+ *errmsg = "unterminated quoted mailbox name";
+ return (-1);
+ }
+ len = (size_t)(end - start);
+ if (len == 0) {
+ *errmsg = "empty mailbox name";
+ return (-1);
+ }
+ if (len >= sizeof(out->mailbox)) {
+ *errmsg = "mailbox name too long";
+ return (-1);
+ }
+ memcpy(out->mailbox, start, len);
+ out->mailbox[len] = '\0';
+ p = end + 1;
+ } else {
+ const char *start = p;
+ size_t len;
+
+ while (*p != '\0' && *p != ' ')
+ p++;
+ len = (size_t)(p - start);
+ if (len == 0) {
+ *errmsg = "empty mailbox name";
+ return (-1);
+ }
+ if (len >= sizeof(out->mailbox)) {
+ *errmsg = "mailbox name too long";
+ return (-1);
+ }
+ memcpy(out->mailbox, start, len);
+ out->mailbox[len] = '\0';
+ }
+
+ while (*p == ' ')
+ p++;
+ if (*p == '(') {
+ char *start = p;
+ char *end = strchr(p, ')');
+ char saved;
+ int rc;
+ const char *sub_err;
+
+ if (end == NULL) {
+ *errmsg = "unterminated flag list";
+ return (-1);
+ }
+ end++; /* include the ')' itself in the substring below */
+ saved = *end;
+ *end = '\0';
+ rc = parse_store_flags(start, &out->sysflags, out->keywords,
+ sizeof(out->keywords), &sub_err);
+ *end = saved;
+ if (rc != 0) {
+ *errmsg = sub_err;
+ return (rc);
+ }
+ p = end;
+ while (*p == ' ')
+ p++;
+ }
+
+ if (*p == '"') {
+ const char *start = p + 1;
+ char *end = strchr(start, '"');
+ size_t dlen;
+ char datebuf[64];
+
+ if (end == NULL) {
+ *errmsg = "unterminated date-time string";
+ return (-1);
+ }
+ dlen = (size_t)(end - start);
+ if (dlen >= sizeof(datebuf)) {
+ *errmsg = "date-time string too long";
+ return (-1);
+ }
+ memcpy(datebuf, start, dlen);
+ datebuf[dlen] = '\0';
+ if (parse_date_time(datebuf, &out->date) == -1) {
+ *errmsg = "malformed date-time string";
+ return (-1);
+ }
+ out->has_date = 1;
+ p = end + 1;
+ while (*p == ' ')
+ p++;
+ }
+
+ if (*p != '{') {
+ *errmsg = "expected a message literal";
+ return (-1);
+ }
+ {
+ const char *start = p + 1;
+ char *end = strchr(start, '}');
+ char *digits_end;
+ const char *digits_stop;
+ char digitsbuf[24];
+ size_t digits_len;
+ unsigned long long litlen;
+
+ if (end == NULL) {
+ *errmsg = "malformed literal announcement";
+ return (-1);
+ }
+
+ out->litnonsync = (end > start && end[-1] == '+');
+ digits_stop = out->litnonsync ? end - 1 : end;
+ digits_len = (size_t)(digits_stop - start);
+
+ if (digits_len == 0 || digits_len >= sizeof(digitsbuf)) {
+ *errmsg = "malformed literal octet count";
+ return (-1);
+ }
+ memcpy(digitsbuf, start, digits_len);
+ digitsbuf[digits_len] = '\0';
+
+ errno = 0;
+ litlen = strtoull(digitsbuf, &digits_end, 10);
+ if (*digits_end != '\0' || errno == ERANGE) {
+ *errmsg = "malformed literal octet count";
+ return (-1);
+ }
+ out->litlen = (uint64_t)litlen;
+
+ if (out->litnonsync && out->litlen > 4096) {
+ /* RFC 9051 SS4.3: non-sync literals capped at 4096 octets -- BAD, not cmd_append()'s NO size cap. */
+ *errmsg = "non-synchronizing literal exceeds RFC "
+ "9051 SS4.3's 4096-octet limit -- use a "
+ "synchronizing literal instead";
+ return (-1);
+ }
+
+ if (end[1] != '\0') {
+ *errmsg = "literal must be the final argument";
+ return (-1);
+ }
+ }
+
+ return (0);
+}
+
+/* Parses the literal announcement, allocates s->literal_buf, and enters literal-read mode (s->literal_pending). */
+int
+cmd_append(struct session *s, const char *tag, char *args)
+{
+ struct append_parsed parsed;
+ int rc;
+ const char *errmsg;
+
+ rc = parse_append_args(args, &parsed, &errmsg);
+ if (rc == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+ if (rc == -2) {
+ session_reply(s, tag, "NO", errmsg);
+ return (1);
+ }
+
+ if (parsed.litlen > APPEND_LITERAL_MAX) {
+ /* RFC 5530 LIMIT code -- matches APPEND_LITERAL_MAX's situation precisely. */
+ session_reply(s, tag, "NO",
+ "[LIMIT] message too large for this server (v1 size "
+ "limit -- see APPEND_LITERAL_MAX)");
+ return (1);
+ }
+
+ if (s->store_iev == NULL) {
+ /* Same store_iev invariant as cmd_select()/cmd_fetch() -- ST_AUTH requires it already wired. */
+ log_warnx("session %u: APPEND with no store channel wired",
+ s->id);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ if (parsed.litlen > 0) {
+ if ((s->literal_buf = malloc((size_t)parsed.litlen)) ==
+ NULL) {
+ log_warn("session %u: malloc APPEND literal buffer",
+ s->id);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ } else
+ s->literal_buf = NULL; /* zero-length literal; session_dispatch_client() handles it without special-casing */
+
+ if (strlcpy(s->append_mailbox, parsed.mailbox,
+ sizeof(s->append_mailbox)) >= sizeof(s->append_mailbox) ||
+ strlcpy(s->append_keywords, parsed.keywords,
+ sizeof(s->append_keywords)) >= sizeof(s->append_keywords)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->append_sysflags = parsed.sysflags;
+ s->append_has_date = parsed.has_date;
+ s->append_date = parsed.date;
+ s->append_prev_state = s->state;
+
+ /* tag is already IMAP_TAG_MAX-bounded by session_handle_line(); re-checked here defensively. */
+ if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->literal_len = parsed.litlen;
+ 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. */
+ if (!parsed.litnonsync)
+ session_write(s, "+ Ready for literal data\r\n", 27);
+
+ return (1);
+}
+
+/* Builds the combined header+message imsg, enters SESSION_APPENDING; s->literal_buf is freed either way. */
+int
+session_finish_append(struct session *s)
+{
+ struct imsg_mbox_append req;
+ char *combined;
+ size_t combined_len;
+
+ memset(&req, 0, sizeof(req));
+ if (strlcpy(req.mailbox, s->append_mailbox, sizeof(req.mailbox)) >=
+ sizeof(req.mailbox) ||
+ strlcpy(req.keywords, s->append_keywords, sizeof(req.keywords)) >=
+ sizeof(req.keywords)) {
+ log_warnx("session %u: APPEND mailbox/keywords truncated -- "
+ "can't happen (both already bounded when first stored)",
+ s->id);
+ session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error");
+ free(s->literal_buf);
+ s->literal_buf = NULL;
+ s->state = s->append_prev_state;
+ return (1);
+ }
+ req.sysflags = s->append_sysflags;
+ req.has_date = s->append_has_date;
+ req.date = s->append_date;
+ req.msglen = (uint32_t)s->literal_len;
+
+ if (s->store_iev == NULL) {
+ log_warnx("session %u: APPEND with no store channel wired "
+ "(literal already read)", s->id);
+ session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error");
+ free(s->literal_buf);
+ s->literal_buf = NULL;
+ s->state = s->append_prev_state;
+ return (1);
+ }
+
+ combined_len = sizeof(req) + (size_t)s->literal_len;
+ if ((combined = malloc(combined_len)) == NULL) {
+ log_warn("session %u: malloc APPEND imsg buffer", s->id);
+ session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error");
+ free(s->literal_buf);
+ s->literal_buf = NULL;
+ s->state = s->append_prev_state;
+ return (1);
+ }
+ memcpy(combined, &req, sizeof(req));
+ if (s->literal_len > 0)
+ memcpy(combined + sizeof(req), s->literal_buf,
+ (size_t)s->literal_len);
+
+ free(s->literal_buf);
+ s->literal_buf = NULL;
+
+ s->state = SESSION_APPENDING;
+
+ 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);
+ free(combined);
+ imsgev_add(s->store_iev);
+
+ return (1);
+}
+
+/* Terminal APPEND reply; restores s->state to s->append_prev_state (AUTHENTICATED or SELECTED). */
+void
+session_handle_mbox_appended(struct session *s,
+ const struct imsg_mbox_appended *res)
+{
+ int appended_to_selected;
+
+ s->state = s->append_prev_state;
+
+ if (res->error != MBOX_OP_OK) {
+ if (res->error == MBOX_OP_ERR_NO_SUCH_MAILBOX)
+ session_reply(s, s->pending_tag, "NO",
+ "[TRYCREATE] no such mailbox"); /* SS6.3.12: reports why, not a promise CREATE would help (v1 has none) */
+ else
+ session_reply(s, s->pending_tag, "NO",
+ "APPEND failed");
+ return;
+ }
+
+ /* 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))
+ appended_to_selected = 1;
+ else
+ appended_to_selected =
+ (strcmp(s->append_mailbox, s->selected_mailbox) == 0);
+
+ /* RFC 9051 SS6.3.13: APPEND always adds one message -- unlike EXPUNGE/CLOSE, no res->count gate needed. */
+ session_notify_idle_peers(s);
+
+ if (s->append_prev_state == SESSION_SELECTED && appended_to_selected) {
+ char buf[32];
+
+ snprintf(buf, sizeof(buf), "%u EXISTS", res->exists);
+ session_untagged(s, buf);
+ }
+
+ {
+ char buf[96];
+
+ snprintf(buf, sizeof(buf),
+ "[APPENDUID %u %u] APPEND completed", res->uidvalidity,
+ res->uid);
+ session_reply(s, s->pending_tag, "OK", buf);
+ }
+}
blob - 6b83b5da1105daeb3c5176264e6566c42ff04ba3
blob + 2ce9b6536055d078f1333f243a394898dd6243aa
--- src/imapd.8
+++ src/imapd.8
and a syntactically invalid destination name is refused outright
.Pq Li BAD
with no attempt to look it up.
+.Li DELETE
+of a mailbox that does not exist, and
+.Li RENAME
+with a source that does not exist, are refused with
+.Pq Li NONEXISTENT ;
+.Li CREATE
+of a mailbox that already exists, and
+.Li RENAME
+to a destination that already exists, are refused with
+.Pq Li ALREADYEXISTS
+(RFC 5530).
If a mailbox is renamed or deleted while a
.Em different
session
blob - /dev/null
blob + 4a48404e352ed638dd3f5cf8012c9b7d2326dd79 (mode 644)
--- /dev/null
+++ src/auth_cmd.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.
+ */
+
+/*
+ * 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.
+ */
+
+#include <sys/types.h>
+#include <sys/queue.h>
+#include <sys/socket.h>
+
+#include <netinet/in.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <event.h>
+#include <imsg.h>
+#include <resolv.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <tls.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "listener.h"
+
+/*
+ * RFC 9051 SS6.1.1 capability strings, selected by session->tls_active;
+ * CONDSTORE/QRESYNC (RFC 7162) advertised regardless of TLS. LOGINDISABLED
+ * is advertised in both strings -- cmd_login() below refuses LOGIN
+ * unconditionally, pre- and post-TLS, so both capability strings should
+ * say so rather than implying LOGIN might work once TLS is up.
+ */
+#define CAPABILITY_PRE_TLS "IMAP4rev2 STARTTLS LOGINDISABLED ID CONDSTORE QRESYNC"
+#define CAPABILITY_POST_TLS "IMAP4rev2 AUTH=PLAIN LOGINDISABLED ID CONDSTORE QRESYNC"
+
+
+int
+cmd_capability(struct session *s, const char *tag, char *args)
+{
+ (void)args; /* RFC 9051: "Arguments: none" -- extra args ignored, not rejected */
+
+ session_untagged(s, s->tls_active ?
+ "CAPABILITY " CAPABILITY_POST_TLS : "CAPABILITY " CAPABILITY_PRE_TLS);
+ session_reply(s, tag, "OK", "CAPABILITY completed");
+ return (1);
+}
+
+
+int
+cmd_noop(struct session *s, const char *tag, char *args)
+{
+ (void)args;
+ session_reply(s, tag, "OK", "NOOP completed");
+ return (1);
+}
+
+
+int
+cmd_logout(struct session *s, const char *tag, char *args)
+{
+ (void)args;
+
+ /* Exact example text from RFC 9051 SS6.1.3. */
+ session_untagged(s, "BYE IMAP4rev2 Server logging out");
+ session_reply(s, tag, "OK", "LOGOUT completed");
+ session_teardown(s);
+ return (0);
+}
+
+
+int
+cmd_id(struct session *s, const char *tag, char *args)
+{
+ /* RFC 2971 SS3.1: field/value list not parsed, just logged and discarded; always replies NIL per SS3.2 */
+ log_debug("session %u: ID params: %s", s->id,
+ args != NULL ? args : "(none)");
+ session_untagged(s, "ID NIL");
+ session_reply(s, tag, "OK", "ID completed");
+ return (1);
+}
+
+
+/*
+ * LOGIN is permanently disabled, matching LOGINDISABLED in both
+ * CAPABILITY strings above. AUTHENTICATE PLAIN (SASL) is the only
+ * supported credential path -- one fewer parser/credential-handling
+ * code path than supporting both, per this project's smaller-feature-
+ * set-is-smaller-attack-surface design principle. (A full LOGIN
+ * implementation was written and real-hardware-verified during Canary
+ * Mail interop testing, since Canary sends LOGIN, never AUTHENTICATE
+ * PLAIN; every other client tested -- Apple Mail, Airmail -- uses
+ * AUTHENTICATE PLAIN without issue, so LOGIN was re-disabled rather
+ * than kept enabled solely for one client's benefit.)
+ */
+int
+cmd_login(struct session *s, const char *tag, char *args)
+{
+ (void)args;
+
+ session_reply(s, tag, "NO", "LOGIN not supported, use AUTHENTICATE PLAIN");
+ return (1);
+}
+
+
+int
+cmd_starttls(struct session *s, const char *tag, char *args)
+{
+ if (args != NULL) {
+ /* RFC 9051 SS6.2.1 Result: "BAD - ... arguments invalid". */
+ session_reply(s, tag, "BAD", "STARTTLS takes no arguments");
+ return (1);
+ }
+ if (s->tls_active) {
+ /* RFC 9051 SS6.2.1: BAD if STARTTLS received after negotiation */
+ session_reply(s, tag, "BAD", "TLS already active");
+ return (1);
+ }
+ if (listener_tls_ctx == NULL) {
+ /* RFC 9051 SS6.2.1 NO + RFC 5530 UNAVAILABLE: cert/key loading failed at boot */
+ session_reply(s, tag, "NO",
+ "[UNAVAILABLE] TLS negotiation unavailable");
+ return (1);
+ }
+
+ session_reply(s, tag, "OK", "Begin TLS negotiation now"); /* must precede TLS start, so goes out in cleartext */
+
+ s->inbuflen = 0; /* command-injection mitigation: discard plaintext already buffered past this line */
+
+ session_tls_start(s);
+ return (1);
+}
+
+/* RFC 4616 SS2: authzid/authcid/passwd each up to 255 octets + 2 NUL delimiters = 767, rounded up */
+#define SASL_PLAIN_MAX 768
+
+/* decodes+verifies one SASL PLAIN message (RFC 4616 SS2), sends IMSG_AUTH_REQUEST; never tears down the session, always returns 1 */
+int
+sasl_plain_finish(struct session *s, const char *tag, const char *b64,
+ int allow_empty_equals)
+{
+ unsigned char raw[SASL_PLAIN_MAX];
+ unsigned char *authcid, *nul;
+ const unsigned char *passwd;
+ int rawlen;
+ size_t off, authcidlen, passwdlen;
+ struct imsg_auth_request req;
+
+ if (allow_empty_equals && strcmp(b64, "=") == 0) {
+ rawlen = 0;
+ } else {
+ rawlen = b64_pton(b64, raw, sizeof(raw));
+ if (rawlen < 0) {
+ session_reply(s, tag, "BAD", "invalid base64");
+ return (1);
+ }
+ }
+
+ /*
+ * rawlen==0 (the allow_empty_equals "=" case just above) would end
+ * up here anyway -- memchr(3) can't find a NUL in a zero-length
+ * buffer -- but reading raw's still-uninitialized stack contents
+ * through memchr(3) to get there is needless even though every
+ * real memchr(3) treats n==0 as a no-op that never dereferences
+ * the pointer. Short-circuit it explicitly instead of relying on
+ * that (cppcheck flagged this as a genuine uninitialized-variable
+ * read, which is technically correct about raw's contents even
+ * though the call itself is harmless).
+ */
+ if (rawlen == 0) {
+ session_reply(s, tag, "BAD", "malformed SASL PLAIN message");
+ explicit_bzero(raw, sizeof(raw));
+ return (1);
+ }
+
+ nul = memchr(raw, '\0', (size_t)rawlen);
+ if (nul == NULL) {
+ session_reply(s, tag, "BAD", "malformed SASL PLAIN message");
+ explicit_bzero(raw, sizeof(raw));
+ return (1);
+ }
+ off = (size_t)(nul - raw) + 1;
+ authcid = raw + off;
+ nul = memchr(authcid, '\0', (size_t)rawlen - off);
+ if (nul == NULL) {
+ session_reply(s, tag, "BAD", "malformed SASL PLAIN message");
+ explicit_bzero(raw, sizeof(raw));
+ return (1);
+ }
+ authcidlen = (size_t)(nul - authcid);
+ passwd = nul + 1;
+ passwdlen = (size_t)rawlen - off - authcidlen - 1;
+
+ /* RFC 4616 SS2: empty prep result SHALL fail verification -- NO not BAD, framing is fine */
+ if (authcidlen == 0 || passwdlen == 0) {
+ session_reply(s, tag, "NO", "[AUTHENTICATIONFAILED] authentication failed");
+ explicit_bzero(raw, sizeof(raw));
+ return (1);
+ }
+ /* too big for imsg_auth_request's fixed fields; same generic NO, avoids a distinct error leaking an oracle */
+ if (authcidlen >= AUTH_USERNAME_MAX || passwdlen >= AUTH_PASSWORD_MAX) {
+ session_reply(s, tag, "NO", "[AUTHENTICATIONFAILED] authentication failed");
+ explicit_bzero(raw, sizeof(raw));
+ return (1);
+ }
+
+ memset(&req, 0, sizeof(req));
+ req.session_id = s->id;
+ memcpy(req.username, authcid, authcidlen);
+ memcpy(req.password, passwd, passwdlen);
+ explicit_bzero(raw, sizeof(raw));
+
+ if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->state = SESSION_AUTHENTICATING;
+
+ if (imsg_compose(&iev_auth.ibuf, IMSG_AUTH_REQUEST, 0, 0, -1,
+ &req, sizeof(req)) == -1)
+ log_warn("session %u: imsg_compose IMSG_AUTH_REQUEST", s->id);
+ imsgev_add(&iev_auth);
+ /* imsg_compose() already copied req, safe to scrub our stack copy */
+ explicit_bzero(&req, sizeof(req));
+
+ return (1);
+}
+
+/* client's response to our "+ " continuation after bare "AUTHENTICATE PLAIN" (see cmd_authenticate()) */
+int
+session_handle_auth_continuation(struct session *s, const char *line)
+{
+ s->auth_cont = 0; /* next line is back to an ordinary tagged command regardless of outcome */
+
+ if (strcmp(line, "*") == 0) { /* RFC 9051 SS6.2.2: lone "*" cancels the exchange */
+ session_reply(s, s->pending_tag, "BAD",
+ "AUTHENTICATE cancelled");
+ return (1);
+ }
+
+ return sasl_plain_finish(s, s->pending_tag, line, 0);
+}
+
+/* client's response to our "+ idling" continuation (RFC 9051 SS6.3.13; see cmd_idle()); only "DONE" terminates IDLE */
+int
+session_handle_idle_continuation(struct session *s, const char *line)
+{
+ s->idling = 0;
+
+ if (strcasecmp(line, "DONE") != 0) {
+ session_reply(s, s->pending_tag, "BAD",
+ "expected DONE");
+ return (1);
+ }
+
+ session_reply(s, s->pending_tag, "OK", "IDLE terminated");
+ return (1);
+}
+
+
+int
+cmd_authenticate(struct session *s, const char *tag, char *args)
+{
+ char *mech, *p;
+ const char *initial;
+
+ if (args == NULL) {
+ /* RFC 9051 SS6.2.2 Result: "BAD - ... arguments invalid". */
+ session_reply(s, tag, "BAD", "Missing SASL mechanism name");
+ return (1);
+ }
+ mech = args;
+ for (p = mech; *p != '\0' && *p != ' '; p++)
+ continue;
+ if (*p == '\0') {
+ initial = NULL;
+ } else {
+ *p++ = '\0';
+ while (*p == ' ')
+ p++;
+ initial = (*p != '\0') ? p : NULL;
+ }
+
+ /* RFC 9051 SS6.2.2: MUST NOT permit plaintext mechanisms pre-TLS */
+ if (!s->tls_active) {
+ /* RFC 5530 PRIVACYREQUIRED: retry after STARTTLS */
+ session_reply(s, tag, "NO",
+ "[PRIVACYREQUIRED] plaintext authentication requires TLS");
+ return (1);
+ }
+
+ /* v1 only implements PLAIN, matching CAPABILITY_POST_TLS */
+ if (strcasecmp(mech, "PLAIN") != 0) {
+ session_reply(s, tag, "NO",
+ "authentication mechanism not available");
+ return (1);
+ }
+
+ if (initial != NULL) { /* RFC 9051 SS6.2.2 initial-resp: finishes in one round trip */
+ return sasl_plain_finish(s, tag, initial, 1);
+ }
+
+ /* no initial response: send "+", auth_cont routes the reply line to session_handle_auth_continuation() */
+ if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->auth_cont = 1;
+ session_write(s, "+ \r\n", 4);
+ return (1);
+}
+
+/* shared reply for a recognized command store.c can't run yet (no wire payload designed); NO not BAD, syntax is fine */
+int
+stub_not_implemented(struct session *s, const char *tag, const char *cmdname)
+{
+ log_debug("session %u: %s not implemented (store.c's IMSG_MBOX_* "
+ "wire protocol isn't designed yet)", s->id, cmdname);
+ session_reply(s, tag, "NO", "not implemented");
+ return (1);
+}
+
+/* RFC 9051 SS6.3.1 ENABLE: unknown extensions ignored; ENABLED lists only what THIS command newly enabled, even if empty */
+int
+cmd_enable(struct session *s, const char *tag, char *args)
+{
+ char buf[64];
+ char *tok, *save;
+ int newly_condstore = 0, newly_qresync = 0;
+
+ if (args == NULL) {
+ session_reply(s, tag, "BAD",
+ "ENABLE requires at least one capability argument");
+ return (1);
+ }
+
+ for (tok = strtok_r(args, " ", &save); tok != NULL;
+ tok = strtok_r(NULL, " ", &save)) {
+ if (strcasecmp(tok, "QRESYNC") == 0) {
+ if (!s->qresync_enabled) {
+ s->qresync_enabled = 1;
+ newly_qresync = 1;
+ if (!s->condstore_enabled)
+ newly_condstore = 1;
+ }
+ } else if (strcasecmp(tok, "CONDSTORE") == 0) {
+ if (!s->condstore_enabled)
+ newly_condstore = 1;
+ }
+ /* anything else: unadvertised extension, SS6.3.1 says ignore */
+ }
+
+ 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") -- but the return
+ * value is still explicitly discarded rather than silently ignored,
+ * per this project's check-every-return-value standard.
+ */
+ buf[0] = '\0';
+ if (newly_qresync)
+ (void)strlcat(buf, "QRESYNC", sizeof(buf));
+ if (newly_condstore) {
+ if (buf[0] != '\0')
+ (void)strlcat(buf, " ", sizeof(buf));
+ (void)strlcat(buf, "CONDSTORE", sizeof(buf));
+ }
+
+ if (buf[0] != '\0') {
+ char untagged[80];
+
+ snprintf(untagged, sizeof(untagged), "ENABLED %s", buf);
+ session_untagged(s, untagged);
+ } else
+ session_untagged(s, "ENABLED");
+
+ session_reply(s, tag, "OK", "ENABLE completed");
+ return (1);
+}
blob - ac68f76cf15a2f085d826daffe7acd99af15e8b1
blob + 88870413145be405657cc25906d29343f8a98aea
--- src/imapd.h
+++ src/imapd.h
/*
* Shared definitions for all four imapd(8) process roles: parent,
- * listener, auth, store. See ../openimap-privsep-design.md for the design
- * this header implements -- process split, imsg message catalog, pledge
- * strings, and the fork-per-session store mechanism are all decided there,
- * not here. This header should not drift from that document; if it does,
- * one of the two is wrong.
+ * listener, auth, store -- the process split, imsg message catalog,
+ * pledge strings, and the fork-per-session store mechanism are all
+ * defined here.
*
* struct/enum names below still say "openimap" in places (struct
* openimap_config, enum openimap_proc_type) even after the imapd(8)
* OpenSSH is the one exception, and only because it ships a whole
* toolkit (ssh/scp/sftp/ssh-keygen/...), not a single daemon. This
* project is shaped like the single-daemon case, so "OpenIMAP" was
- * the wrong analogy; see docs/openimap.md and README.skeleton. Only
+ * the wrong analogy. Only
* the installed daemon's own identity (PROG, man page, rc.d script,
* default file paths), this header's own filename, and its include
* guard/version macro (below) changed as part of that *earlier*
* command has stable, greppable output if that's ever wanted. Bump by hand
* until something better (git describe, etc.) is worth wiring in.
*/
-#define IMAPD_VERSION "0.1"
+#define IMAPD_VERSION "0.1.1"
/*
* Process roles, selected at exec time via "-x <role>". See main.c.
};
/*
- * imsg message catalog. Mirrors the table in openimap-privsep-design.md
- * ("imsg message catalog (draft)") -- keep in sync with that document.
- * IMSG_SETUP_PEER / IMSG_SETUP_DONE are the boot-time handshake (also
- * reused, per that document, for the per-session store peer-wiring
- * handshake after IMSG_STORE_INIT).
+ * imsg message catalog. IMSG_SETUP_PEER / IMSG_SETUP_DONE are the
+ * boot-time handshake, also reused for the per-session store peer-wiring
+ * handshake after IMSG_STORE_INIT.
*/
enum imsg_type {
IMSG_NONE,
IMSG_AUTH_REQUEST,
IMSG_AUTH_RESULT,
- /* per-session store spawn (listener -> parent -> new store child) */
+ /* auth -> parent, per successful login (task #321: parent's
+ * privilege decision is sourced directly from auth, keyed by
+ * session_id, instead of being relayed through the network-facing
+ * listener process) */
+ IMSG_AUTH_CRED,
+
+ /* per-session store spawn: auth's IMSG_AUTH_CRED triggers the spawn
+ * in parent; IMSG_STORE_FORK is now parent -> listener only, used
+ * solely as a failure notification when the spawn couldn't proceed */
IMSG_STORE_FORK,
IMSG_STORE_INIT,
IMSG_STORE_PEER,
/*
* RFC 9051 SS6.3.4-SS6.3.6 (CREATE/DELETE/RENAME) and SS6.3.9 (LIST),
- * this pass -- flat (non-nested) multi-mailbox support, per the
- * design resolved in docs/openimap-storage-backend.md's "Open items"
- * #10. IMSG_MBOX_CREATE/IMSG_MBOX_DELETE/IMSG_MBOX_RENAME and
+ * this pass -- flat (non-nested) multi-mailbox support, mailboxes as
+ * sibling subdirectories of the session's own per-user maildir root.
+ * IMSG_MBOX_CREATE/IMSG_MBOX_DELETE/IMSG_MBOX_RENAME and
* IMSG_MBOX_LIST itself were already reserved in this enum from an
* earlier skeleton pass (store_dispatch()'s "TODO: none of these
* payload shapes are designed yet" case) -- only their payload
};
/*
- * Standard privsep imsg-over-event(3) wrapper. Not itself quoted from any
- * uploaded source file this session -- this is a widely-used pattern in
- * OpenBSD privsep daemons (smtpd.c's own use of event_dispatch(3)/
- * evtimer_set(3)/signal_add(3), observed directly this session, is what
- * grounds using libevent here at all; the imsgev wrapper struct itself is
- * this project's own plumbing on top of that, not copied from a specific
- * quoted definition).
+ * Standard privsep imsg-over-event(3) wrapper. This struct's fields and
+ * imsgev.c's functions are this project's own implementation, not copied
+ * from a specific quoted definition -- but the name "imsgev"/"struct
+ * imsgev" for an imsgbuf+event(3) wrapper matches Eric Faurot's
+ * usr.sbin/ldapd/imsgev.c in OpenBSD base closely enough (not a generic
+ * name) that his copyright is carried in imsgev.c's own header as
+ * attribution for that naming/conceptual lineage, even though the actual
+ * function signatures and dispatch design here differ from his.
*/
struct imsgev {
struct imsgbuf ibuf;
uint16_t port_cleartext; /* 143, STARTTLS */
uint16_t port_implicit_tls; /* 993, RFC 8314 */
char spool_root[1024]; /* mail spool root, store's chroot */
- char cred_file[1024]; /* auth's credential file, see
- * openimap-privsep-design.md's
- * "auth" section for the
+ char cred_file[1024]; /* auth's credential file -- one
+ * line per user, format
* username:passwordhash:uid:gid:
- * maildir format */
+ * maildir */
char tls_cert_file[1024];
char tls_key_file[1024];
uint32_t bodystructure_read_max; /* "attachment max" directive --
/*
* Boot-time config-delivery payloads, closing the gap flagged in an
* earlier pass: listener/auth don't read imapd.conf themselves (kept
- * off their rpath/unveil surface deliberately -- see each role's pledge
- * discussion in openimap-privsep-design.md), but nothing ever specified
- * how they'd get their slice of it otherwise. Modeled directly on
+ * off their rpath/unveil surface deliberately, per each role's own
+ * pledge(2) string below), but nothing ever specified how they'd get
+ * their slice of it otherwise. Modeled directly on
* IMSG_STORE_INIT's existing precedent: parent, which alone reads the
* real config, hands over only the fields that role actually needs, not
* the whole struct openimap_config.
};
/*
- * openimap-privsep-design.md's credential-file field (line ~501): "maildir:
- * path relative to the spool root store is chroot'd into, so store never
- * needs an absolute-path credential field to escape its chroot." The design
- * doc's imsg catalog already specified IMSG_STORE_FORK should carry "session
- * id, resolved uid/gid, mailbox identifier" -- this field was the "mailbox
- * identifier" from day one, it just never actually got added to either
- * struct below, so auth.c's imsg_auth_result.maildir (correctly resolved
- * per-user from the credential file) was being silently dropped on the
- * floor by listener.c's session_request_store() before this pass.
+ * task #321: sent auth -> parent directly, over auth's own existing
+ * channel (the one IMSG_AUTH_INIT already arrives on), immediately after
+ * a successful login. This is the sole source parent uses to spawn a
+ * session's store child -- see parent_handle_store_fork() in parent.c.
+ * Previously this data (uid/gid/maildir, resolved by auth.c from the
+ * credential file) was relayed to parent by listener.c instead, meaning
+ * parent's privilege decision ultimately trusted a value repeated by the
+ * network-facing process rather than the process that actually verified
+ * the login. See docs/SECURITY-PATCHES.md's F1 writeup for the original
+ * finding and the recommended complete fix this implements.
*/
+struct imsg_auth_cred {
+ uint32_t session_id;
+ uid_t uid;
+ gid_t gid;
+ char maildir[AUTH_MAILDIR_MAX];
+};
+
+/*
+ * task #321: parent -> listener only now, a failure notification when
+ * parent couldn't spawn (or lost) a session's store child. No longer
+ * carries uid/gid/maildir -- those arrive on IMSG_AUTH_CRED above -- so
+ * a failure reply is just session_id, always zeroed for the rest.
+ */
#define STORE_MAILDIR_MAX AUTH_MAILDIR_MAX
struct imsg_store_fork {
uint32_t session_id;
- uid_t uid;
- gid_t gid;
- char maildir[STORE_MAILDIR_MAX];
};
struct imsg_store_init {
* listener): the first IMSG_MBOX_* pair to actually get a wire payload
* shape -- the rest of the family (FETCH/STORE/APPEND/...) is still exactly
* as undesigned as store.c's own header comment says. v1 is single-mailbox
- * (INBOX only, per openimap-v1-dispatch.md's SELECT row and the still-
- * unresolved hierarchy-separator question flagged in listener.c's
- * cmd_namespace()) -- readonly distinguishes EXAMINE (RFC 9051 SS6.3.3)
+ * (INBOX only, and the still-unresolved hierarchy-separator question is
+ * flagged in listener.c's cmd_namespace()) -- readonly distinguishes
+ * EXAMINE (RFC 9051 SS6.3.3)
* from SELECT, wired up in listener.c's select_or_examine(). store.c still
* does nothing different for readonly than for a normal SELECT: v1's single
* mailbox returns the identical EXISTS/UIDVALIDITY/UIDNEXT/highestmodseq
#define MBOX_NAME_MAX 256
/*
+ * Shared specific-error type for the store-process operation-result imsg
+ * structs (imsg_mbox_selected, imsg_mbox_status_result, imsg_mbox_result,
+ * imsg_mbox_appended) -- replaces the old "int ok" plus a bolted-on "int
+ * no_such_mailbox" that some of these structs used to maintain
+ * independently for the exact same concept. MBOX_ERR_UNSET is deliberately
+ * the zero value, not MBOX_OP_OK: every producer memset()s its result
+ * struct to 0 before filling it in, so a codepath that forgets to set this
+ * field ends up reporting failure, not silent success.
+ *
+ * MBOX_OP_ERR_NO_SUCH_MAILBOX and MBOX_OP_ERR_ALREADY_EXISTS are
+ * client-visible via RFC 5530 SS3's NONEXISTENT and ALREADYEXISTS codes
+ * respectively (confirmed directly against the RFC text, not assumed):
+ * NONEXISTENT's own worked example is a RENAME failing because the
+ * source doesn't exist, and ALREADYEXISTS's is a CREATE/RENAME target
+ * that already exists -- both apply directly to CREATE/DELETE/RENAME's
+ * not-found and already-exists failure modes (added 2026-08-27, task
+ * #317), correcting an earlier, unverified claim in this comment that no
+ * RFC 5530 code fit those cases. NO_SUCH_MAILBOX is also COPY/MOVE/
+ * APPEND's TRYCREATE trigger (RFC 9051 SS6.4.7/SS6.4.8/SS6.3.12). Every
+ * other failure cause (mkdir/stat/flock/index races, truncated internal
+ * buffers, etc.) still collapses to a plain "<CMD> failed" NO via
+ * MBOX_OP_ERR_GENERIC -- those genuinely don't fit any RFC 5530 code.
+ *
+ * Defined here, ahead of imsg_mbox_select(ed)/imsg_mbox_status_result
+ * below, rather than down by imsg_mbox_result where it was first added --
+ * a straight compile (caught by the 2026-08-27 -Wcast-qual/-Wcast-align
+ * addition finally forcing a real, non-sandboxed syntax check of this
+ * header) showed the enum being referenced by struct fields hundreds of
+ * lines before its old definition, which every C compiler rejects as an
+ * incomplete type. This project's enum-error-type pass (task #307-309)
+ * had never actually been compile-checked end to end before now --
+ * task #310 ("compile-check + real-hardware verify") was still open
+ * when this was found.
+ */
+enum mbox_op_error {
+ MBOX_ERR_UNSET = 0,
+ MBOX_OP_OK,
+ MBOX_OP_ERR_GENERIC,
+ MBOX_OP_ERR_NO_SUCH_MAILBOX,
+ MBOX_OP_ERR_ALREADY_EXISTS,
+};
+
+/*
* QRESYNC select-param additions (RFC 7162 SS3.2.5): `"QRESYNC" SP "("
* uidvalidity SP mod-sequence-value [SP known-uids [SP seq-match-data]]
* ")"`. v1 scope, sourced against this project's own established pattern
};
struct imsg_mbox_selected {
- int ok; /* 0 -- e.g. mailbox isn't INBOX, v1's only
- * mailbox -- see openimap-v1-dispatch.md */
+ enum mbox_op_error error; /* MBOX_OP_ERR_GENERIC covers both "no
+ * such mailbox" and an index I/O failure --
+ * session_handle_mbox_selected() replies
+ * "[NONEXISTENT] no such mailbox" (RFC 5530)
+ * unconditionally on any failure here, so
+ * there's no NO_SUCH_MAILBOX/other distinction
+ * for this struct to carry, unlike COPY/MOVE/
+ * APPEND's TRYCREATE case */
uint32_t exists; /* RFC 9051 SS7.4.1 EXISTS */
uint32_t uidvalidity; /* RFC 9051 SS2.3.1.1 */
uint32_t uidnext; /* RFC 9051 SS2.3.1.1 */
#define STATUS_ATT_DELETED (1U << 4)
#define STATUS_ATT_SIZE (1U << 5)
#define STATUS_ATT_HIGHESTMODSEQ (1U << 6)
+#define STATUS_ATT_RECENT (1U << 7) /* IMAP4rev2 (RFC 9051
+ * SS2.3.2) formally
+ * dropped \Recent and
+ * RECENT from STATUS's
+ * status-att grammar,
+ * but real clients
+ * (Canary Mail seen
+ * requesting it
+ * directly against
+ * this server) still
+ * ask for it out of
+ * IMAP4rev1 habit --
+ * always answered "0"
+ * (no \Recent tracking
+ * exists in this
+ * server) rather than
+ * failing the whole
+ * STATUS command with
+ * BAD over one legacy
+ * attribute name. */
/*
* IMSG_MBOX_STATUS (listener -> store) / IMSG_MBOX_STATUS_RESULT (store ->
* shape here.
*
* mailbox field added for RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 (flat multi-
- * mailbox support -- see docs/openimap-storage-backend.md item 10): this
- * comment previously argued a mailbox-name field was unnecessary, "v1 has
+ * mailbox support): this comment previously argued a mailbox-name field
+ * was unnecessary, "v1 has
* exactly one mailbox (INBOX) and no CREATE" -- no longer true. SS6.3.11
* itself requires STATUS to target *any* named mailbox independent of
* whatever this session currently has selected ("asks the server to
};
struct imsg_mbox_status_result {
- int ok; /* 0 if the open/flock/index_load sequence
- * itself failed -- can't happen due to a bad
- * mailbox name (listener.c's gate above
- * already ruled that out), but store.c can
- * still fail for the same reasons handle_mbox_
- * select() can */
+ enum mbox_op_error error; /* MBOX_OP_ERR_GENERIC -- bad mailbox
+ * name or the open/flock/index_load sequence
+ * itself failed, same causes handle_mbox_
+ * select() can hit; session_handle_mbox_
+ * status_result() replies "[NONEXISTENT] no
+ * such mailbox" unconditionally on any
+ * failure, so no further distinction needed */
uint32_t messages; /* STATUS_ATT_MESSAGES */
uint32_t uidnext; /* STATUS_ATT_UIDNEXT */
uint32_t uidvalidity; /* STATUS_ATT_UIDVALIDITY */
* addressing into a MULTIPART container or a MESSAGE/RFC822|GLOBAL part's
* own nested numbering (matching BODYSTRUCTURE's own established message/
* rfc822 scope cut) -- remains deliberately out of this pass. See
- * listener.c's cmd_fetch() comment and README.skeleton's entries for each
- * item above for the full scoping reasoning. Also v1-scoped: exactly one
+ * listener.c's cmd_fetch() comment for the full scoping reasoning.
+ * Also v1-scoped: exactly one
* sequence-set range per request (a single number, "a:b", or "*" at either
* end) -- listener.c rejects a comma-separated sequence-set before ever
* sending this message, rather than silently fetching only the first
* the same flag-rename + modseq-bump
* machinery STORE already has, plus
* reflecting the change back in this
- * FETCH's own response -- scoped out,
- * see README.skeleton). listener.c's
+ * FETCH's own response -- scoped
+ * out). listener.c's
* parse_fetch_atts() only recognizes the
* exact token "BODY.PEEK[HEADER]"; plain
* BODY[HEADER] still degrades like every
* same imsg, capped at BODYSTRUCTURE_MAX */
};
+/* enum mbox_op_error is defined earlier in this file, above
+ * imsg_mbox_select -- moved there because every struct using it,
+ * including this one, needs the complete type in scope, and
+ * imsg_mbox_selected/imsg_mbox_status_result appear before this point.
+ * See that definition's own comment for the full rationale. */
+
/* Generic per-operation completion signal -- FETCH is the first user,
* but the name (and the enum's own placement of IMSG_MBOX_RESULT after
* every other IMSG_MBOX_* type) suggests it's meant to close out
* STORE/APPEND/etc. too once those exist. */
struct imsg_mbox_result {
- int ok;
+ enum mbox_op_error error;
uint32_t count; /* number of IMSG_MBOX_FETCH_META (or
* equivalent, for a future op) messages that
* preceded this one */
* highestmodseq above.
*/
uint32_t uidvalidity;
-
- /*
- * COPY/MOVE only (0 for every other operation that shares this
- * struct, same "populated for the operations that need it" split as
- * highestmodseq/uidvalidity above): 1 distinguishes "destination
- * mailbox doesn't exist" from any other failure -- listener.c must
- * send the tagged NO with a "[TRYCREATE]" prefix per SS6.4.7/SS6.4.8
- * for this case specifically, same distinction imsg_mbox_appended's
- * own no_such_mailbox field already makes for APPEND.
- */
- int no_such_mailbox;
};
/*
* doesn't define, so listener.c rejects both before ever building this
* struct). Keywords (arbitrary non-"\" atoms, SS2.3.2's `flag-keyword`)
* are carried separately as a comma-separated list -- matching the
- * index's own on-disk keyword delimiter (openimap-storage-backend.md) so
- * store.c can merge them directly without a format conversion; listener.c
+ * index's own on-disk keyword delimiter so store.c can merge them
+ * directly without a format conversion; listener.c
* rejects any client-supplied keyword containing ':' or ',' (both valid
* in IMAP's `atom` grammar, but the index format has no escaping
* mechanism for its own field/record delimiters -- see cmd_store_cmd()'s
* copy_move_dispatch() exactly the way cmd_rename()'s oldname/newname
* already are -- mailbox_name_is_inbox()/mailbox_name_valid() first, with
* zero store round trip for a syntactically invalid name. Before flat
- * multi-mailbox support (RFC 9051 SS6.3.4-SS6.3.6, docs/openimap-storage-
- * backend.md item 10) this struct had no destination field at all: v1 had
+ * multi-mailbox support (RFC 9051 SS6.3.4-SS6.3.6) this struct had no
+ * destination field at all: v1 had
* no CREATE, so the destination was *always* the same mailbox as the
* source (the currently selected mailbox, itself always INBOX) --
* explicitly RFC-sanctioned even then (SS6.4.8: "moving a message to the
};
struct imsg_mbox_appended {
- int ok;
- int no_such_mailbox; /* 1 distinguishes "not INBOX" --
+ enum mbox_op_error error; /* MBOX_OP_ERR_NO_SUCH_MAILBOX
+ * distinguishes "not INBOX" --
* listener.c must send the tagged NO
* with a "[TRYCREATE]" prefix per
* SS6.3.12 -- from any other failure
- * (plain NO, no response code) */
+ * (MBOX_OP_ERR_GENERIC: plain NO, no
+ * response code) */
uint32_t uidvalidity;
uint32_t uid; /* the appended message's own UID --
* together with uidvalidity, this is
* and refuse cleanly" choice APPEND makes for an oversized message.
*
* v1 scope, matching this project's smaller-feature-set design
- * philosophy (openimap-privsep-design.md): search keys that need actual
- * message content or headers -- BCC/BODY/CC/FROM/HEADER/SENTBEFORE/
+ * philosophy: search keys that need actual message content or headers
+ * -- BCC/BODY/CC/FROM/HEADER/SENTBEFORE/
* SENTON/SENTSINCE/SUBJECT/TEXT/TO -- are rejected by listener.c's
* parser with a specific NO before this imsg is ever built, the same
* "recognized, can't do it right now" category FETCH's BODY[] rejection
/*
* RFC 9051 SS6.3.4/SS6.3.5 (CREATE/DELETE) and SS6.3.9 (LIST), this pass --
- * see docs/openimap-storage-backend.md's "Open items" #10 for the full
- * design (flat, non-nested mailboxes as sibling subdirectories of the
+ * flat, non-nested mailboxes as sibling subdirectories of the
* session's own per-user maildir root; CREATE/DELETE/RENAME all reply with
* the existing struct imsg_mbox_result, only "ok" meaningful). listener.c
* has already validated the name syntactically (non-empty, not "INBOX",
*/
void imsgev_init(struct imsgev *, int,
void (*)(int, short, void *), void *);
-void imsgev_init_from_ibuf(struct imsgev *, struct imsgbuf *,
+void imsgev_init_from_ibuf(struct imsgev *, const struct imsgbuf *,
void (*)(int, short, void *), void *);
void imsgev_add(struct imsgev *);
/*
* Boot-time setup-loop helpers, sourced against smtpd.c's setup_proc()
- * shape (see openimap-privsep-design.md's "Peer-wiring handshake"
- * section): a freshly exec'd child blocks reading fd 3 for zero or more
+ * shape: a freshly exec'd child blocks reading fd 3 for zero or more
* IMSG_SETUP_PEER messages, then an IMSG_SETUP_DONE, and acks. v1's
* boot-time wiring is 1:1 (listener gets exactly one peer, auth gets
* exactly one) so setup_recv_one_peer() covers both; store's later,
blob - /dev/null
blob + 8c03107f9c2ffdf3e003d2b7297d2dbb48146bb3 (mode 644)
--- /dev/null
+++ src/envelope.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.
+ */
+
+/* envelope.c -- the ENVELOPE and BODYSTRUCTURE FETCH response builders. */
+
+#include <sys/types.h>
+#include <sys/file.h>
+#include <sys/stat.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <event.h>
+#include <fcntl.h>
+#include <imsg.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "store_internal.h"
+
+int
+envbuf_append(char *buf, size_t bufsize, size_t *outlen, const char *data,
+ size_t datalen)
+{
+ if (*outlen + datalen > bufsize)
+ return (-1);
+ memcpy(buf + *outlen, data, datalen);
+ *outlen += datalen;
+ return (0);
+}
+
+
+int
+envbuf_append_str(char *buf, size_t bufsize, size_t *outlen, const char *s)
+{
+ return (envbuf_append(buf, bufsize, outlen, s, strlen(s)));
+}
+
+/* Appends one RFC 9051 nstring: NIL if val is NULL, else quoted+escaped; not RFC 2047 decoded (verbatim). */
+int
+envbuf_append_nstring(char *buf, size_t bufsize, size_t *outlen,
+ const char *val, size_t vallen)
+{
+ size_t i;
+
+ if (val == NULL)
+ return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
+
+ if (envbuf_append(buf, bufsize, outlen, "\"", 1) == -1)
+ return (-1);
+ for (i = 0; i < vallen; i++) {
+ if ((val[i] == '"' || val[i] == '\\') &&
+ envbuf_append(buf, bufsize, outlen, "\\", 1) == -1)
+ return (-1);
+ if (envbuf_append(buf, bufsize, outlen, &val[i], 1) == -1)
+ return (-1);
+ }
+ return (envbuf_append(buf, bufsize, outlen, "\"", 1));
+}
+
+/* Formats one RFC 5322 mailbox as an IMAP address tuple (RFC 9051 SS9); no group syntax, addr-adl always NIL. */
+int
+envbuf_append_one_address(char *buf, size_t bufsize, size_t *outlen,
+ const char *tok, size_t toklen)
+{
+ char addrbuf[1024];
+ size_t addrlen = 0;
+ const char *name = NULL;
+ size_t namelen = 0;
+ const char *spec;
+ size_t speclen;
+ const char *mailbox, *host;
+ size_t mailboxlen, hostlen;
+ size_t at;
+ int found_at;
+ size_t lt;
+
+ while (toklen > 0 && (tok[0] == ' ' || tok[0] == '\t')) {
+ tok++;
+ toklen--;
+ }
+ while (toklen > 0 && (tok[toklen - 1] == ' ' || tok[toklen - 1] == '\t'))
+ toklen--;
+ if (toklen == 0)
+ return (-1);
+
+ /* unquoted '<' splits display-name (before) from addr-spec (up to matching unquoted '>') */
+ lt = toklen;
+ {
+ size_t i;
+ int q = 0;
+
+ for (i = 0; i < toklen; i++) {
+ if (tok[i] == '"')
+ q = !q;
+ else if (!q && tok[i] == '<') {
+ lt = i;
+ break;
+ }
+ }
+ }
+
+ if (lt < toklen) {
+ size_t gt = toklen, i;
+ int q = 0;
+
+ for (i = lt + 1; i < toklen; i++) {
+ if (tok[i] == '"')
+ q = !q;
+ else if (!q && tok[i] == '>') {
+ gt = i;
+ break;
+ }
+ }
+ if (gt >= toklen)
+ return (-1); /* unmatched '<' -- malformed, skip */
+
+ {
+ const char *disp = tok;
+ size_t displen = lt;
+
+ while (displen > 0 && (disp[0] == ' ' || disp[0] == '\t')) {
+ disp++;
+ displen--;
+ }
+ while (displen > 0 &&
+ (disp[displen - 1] == ' ' || disp[displen - 1] == '\t'))
+ displen--;
+
+ if (displen >= 2 && disp[0] == '"' &&
+ disp[displen - 1] == '"') {
+ /* emission loop below re-escapes for the wire; no separate unescape pass needed */
+ disp++;
+ displen -= 2;
+ }
+ if (displen > 0) {
+ name = disp;
+ namelen = displen;
+ }
+ }
+
+ spec = tok + lt + 1;
+ speclen = gt - (lt + 1);
+ } else {
+ spec = tok;
+ speclen = toklen;
+ }
+
+ while (speclen > 0 && (spec[0] == ' ' || spec[0] == '\t')) {
+ spec++;
+ speclen--;
+ }
+ while (speclen > 0 && (spec[speclen - 1] == ' ' || spec[speclen - 1] == '\t'))
+ speclen--;
+
+ found_at = 0;
+ at = 0;
+ {
+ size_t i;
+ int q = 0;
+
+ for (i = 0; i < speclen; i++) {
+ if (spec[i] == '"')
+ q = !q;
+ else if (!q && spec[i] == '@') {
+ at = i;
+ found_at = 1;
+ }
+ }
+ }
+ if (!found_at || at == 0 || at + 1 >= speclen)
+ return (-1); /* no usable local-part@domain split */
+
+ mailbox = spec;
+ mailboxlen = at;
+ host = spec + at + 1;
+ hostlen = speclen - at - 1;
+
+ /* strip surrounding quotes from a quoted local-part (unescaped "@" inside not handled) */
+ if (mailboxlen >= 2 && mailbox[0] == '"' && mailbox[mailboxlen - 1] == '"') {
+ mailbox++;
+ mailboxlen -= 2;
+ }
+
+ if (name != NULL) {
+ size_t j;
+
+ if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "\"", 1) == -1)
+ return (-1);
+ for (j = 0; j < namelen; j++) {
+ char c = name[j];
+
+ if (c == '\\' && j + 1 < namelen) {
+ j++;
+ c = name[j];
+ }
+ if ((c == '"' || c == '\\') &&
+ envbuf_append(addrbuf, sizeof(addrbuf), &addrlen,
+ "\\", 1) == -1)
+ return (-1);
+ if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen,
+ &c, 1) == -1)
+ return (-1);
+ }
+ if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "\"", 1) == -1)
+ return (-1);
+ } else {
+ if (envbuf_append_str(addrbuf, sizeof(addrbuf), &addrlen, "NIL") == -1)
+ return (-1);
+ }
+
+ if (envbuf_append_str(addrbuf, sizeof(addrbuf), &addrlen, " NIL ") == -1)
+ return (-1);
+ if (envbuf_append_nstring(addrbuf, sizeof(addrbuf), &addrlen, mailbox,
+ mailboxlen) == -1)
+ return (-1);
+ if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, " ", 1) == -1)
+ return (-1);
+ if (envbuf_append_nstring(addrbuf, sizeof(addrbuf), &addrlen, host,
+ hostlen) == -1)
+ return (-1);
+
+ if (envbuf_append(buf, bufsize, outlen, "(", 1) == -1)
+ return (-1);
+ if (envbuf_append(buf, bufsize, outlen, addrbuf, addrlen) == -1)
+ return (-1);
+ return (envbuf_append(buf, bufsize, outlen, ")", 1));
+}
+
+/* Formats an RFC 5322 address-list as "(" 1*address ")", or NIL if none parse (RFC 9051 SS7.5.2); splits on top-level commas only. */
+int
+envbuf_append_address_list(char *buf, size_t bufsize, size_t *outlen,
+ const char *val, size_t vallen)
+{
+ size_t save = *outlen;
+ size_t i = 0;
+ int any = 0;
+
+ while (vallen > 0 && (val[0] == ' ' || val[0] == '\t')) {
+ val++;
+ vallen--;
+ }
+ while (vallen > 0 && (val[vallen - 1] == ' ' || val[vallen - 1] == '\t'))
+ vallen--;
+
+ if (vallen == 0)
+ return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
+
+ if (envbuf_append(buf, bufsize, outlen, "(", 1) == -1)
+ return (-1);
+
+ while (i < vallen) {
+ size_t tok_start;
+ size_t tok_len;
+ int in_quotes = 0, in_angle = 0;
+
+ while (i < vallen && (val[i] == ' ' || val[i] == '\t' ||
+ val[i] == ','))
+ i++;
+ tok_start = i;
+ while (i < vallen) {
+ char c = val[i];
+
+ if (c == '"')
+ in_quotes = !in_quotes;
+ else if (!in_quotes && c == '<')
+ in_angle = 1;
+ else if (!in_quotes && c == '>')
+ in_angle = 0;
+ else if (!in_quotes && !in_angle && c == ',')
+ break;
+ i++;
+ }
+ tok_len = i - tok_start;
+ while (tok_len > 0 && (val[tok_start + tok_len - 1] == ' ' ||
+ val[tok_start + tok_len - 1] == '\t'))
+ tok_len--;
+
+ if (tok_len > 0) {
+ if (envbuf_append_one_address(buf, bufsize, outlen,
+ val + tok_start, tok_len) == 0)
+ any = 1;
+ else if (*outlen > bufsize) {
+ return (-1); /* can't happen; envbuf_append() never overruns bufsize */
+ }
+ /* malformed address: buf/outlen untouched on failure (addrbuf only flushed atomically) */
+ }
+ }
+
+ if (!any) {
+ *outlen = save;
+ return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
+ }
+ return (envbuf_append(buf, bufsize, outlen, ")", 1));
+}
+
+/* Looks up header field `name`, appends its nstring form (NIL if absent); shared by ENVELOPE's plain-string members. */
+int
+append_field_nstring(char *out, size_t outsize, size_t *outlen,
+ const char *hdrbuf, uint32_t hdrlen, const char *name)
+{
+ char *val;
+ size_t vallen;
+ int rc;
+
+ if (extract_header_field(hdrbuf, hdrlen, name, &val, &vallen) == 0) {
+ rc = envbuf_append_nstring(out, outsize, outlen, val, vallen);
+ free(val);
+ } else {
+ rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0);
+ }
+ return (rc);
+}
+
+/* Builds RFC 9051 SS7.5.2 ENVELOPE list; Sender/Reply-To default to From if absent/empty; -1 if unreadable or over ENVELOPE_MAX. */
+int
+build_envelope(const char *basename, char **buf_out, uint32_t *len_out)
+{
+ char *hdrbuf = NULL;
+ uint32_t hdrlen = 0;
+ char out[ENVELOPE_MAX];
+ size_t outlen = 0;
+ char from_formatted[ENVELOPE_MAX];
+ size_t from_len = 0;
+
+ *buf_out = NULL;
+ *len_out = 0;
+
+ if (read_message_header(basename, &hdrbuf, &hdrlen) == -1)
+ return (-1);
+
+ if (envbuf_append(out, sizeof(out), &outlen, "(", 1) == -1)
+ goto fail;
+
+ if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
+ "Date") == -1)
+ goto fail;
+ if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+ goto fail;
+ if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
+ "Subject") == -1)
+ goto fail;
+ if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+ goto fail;
+
+ /* from */
+ {
+ char *val;
+ size_t vallen;
+
+ if (extract_header_field(hdrbuf, hdrlen, "From", &val,
+ &vallen) == 0) {
+ int rc = envbuf_append_address_list(from_formatted,
+ sizeof(from_formatted), &from_len, val, vallen);
+ free(val);
+ if (rc == -1)
+ goto fail;
+ } else {
+ if (envbuf_append_str(from_formatted,
+ sizeof(from_formatted), &from_len, "NIL") == -1)
+ goto fail;
+ }
+ }
+ if (envbuf_append(out, sizeof(out), &outlen, from_formatted,
+ from_len) == -1)
+ goto fail;
+ if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+ goto fail;
+
+ /* sender, reply-to: default to from_formatted per SS7.5.2 */
+ {
+ static const char *const fallback_fields[] =
+ { "Sender", "Reply-To" };
+ size_t fi;
+
+ for (fi = 0; fi < 2; fi++) {
+ char *val;
+ size_t vallen;
+ int used_value = 0;
+
+ if (extract_header_field(hdrbuf, hdrlen,
+ fallback_fields[fi], &val, &vallen) == 0) {
+ if (vallen > 0) {
+ int rc = envbuf_append_address_list(
+ out, sizeof(out), &outlen, val,
+ vallen);
+ used_value = 1;
+ free(val);
+ if (rc == -1)
+ goto fail;
+ } else
+ free(val);
+ }
+ if (!used_value &&
+ envbuf_append(out, sizeof(out), &outlen,
+ from_formatted, from_len) == -1)
+ goto fail;
+ if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+ goto fail;
+ }
+ }
+
+ /* to, cc, bcc */
+ {
+ static const char *const addr_fields[] = { "To", "Cc", "Bcc" };
+ size_t fi;
+
+ for (fi = 0; fi < 3; fi++) {
+ char *val;
+ size_t vallen;
+
+ if (extract_header_field(hdrbuf, hdrlen,
+ addr_fields[fi], &val, &vallen) == 0) {
+ int rc = envbuf_append_address_list(out,
+ sizeof(out), &outlen, val, vallen);
+ free(val);
+ if (rc == -1)
+ goto fail;
+ } else {
+ if (envbuf_append_str(out, sizeof(out),
+ &outlen, "NIL") == -1)
+ goto fail;
+ }
+ if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+ goto fail;
+ }
+ }
+
+ if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
+ "In-Reply-To") == -1)
+ goto fail;
+ if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+ goto fail;
+ if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
+ "Message-Id") == -1)
+ goto fail;
+
+ if (envbuf_append(out, sizeof(out), &outlen, ")", 1) == -1)
+ goto fail;
+
+ free(hdrbuf);
+
+ if ((*buf_out = malloc(outlen)) == NULL) {
+ log_warn("session %u: malloc ENVELOPE buffer (%s)",
+ session_id, basename);
+ return (-1);
+ }
+ memcpy(*buf_out, out, outlen);
+ *len_out = (uint32_t)outlen;
+ return (0);
+
+fail:
+ log_warnx("session %u: message %s: formatted ENVELOPE exceeds "
+ "ENVELOPE_MAX -- ENVELOPE skipped", session_id, basename);
+ free(hdrbuf);
+ return (-1);
+}
+
+/* BODYSTRUCTURE (RFC 9051 SS7.5.2): recursive RFC 2045/2046 MIME parse, bounded by MIME_MAX_DEPTH/MIME_MAX_PARTS; no extension data, message/rfc822, or RFC 2231 continuations. */
+int
+build_body_structure(int depth, int *nparts_used, const char *hdr,
+ size_t hdrlen, const char *body, size_t bodylen, char *out,
+ size_t outsize, size_t *outlen)
+{
+ char type[64], subtype[64];
+ char params_fmt[600];
+ char boundary[70 + 1]; /* RFC 2046 SS5.1.1 caps boundary at 70 chars, +1 NUL */
+ int has_boundary;
+
+ if (depth > MIME_MAX_DEPTH)
+ return (-1);
+ if (++*nparts_used > MIME_MAX_PARTS)
+ return (-1);
+
+ params_fmt[0] = '\0';
+ if (parse_content_type(hdr, hdrlen, type, sizeof(type), subtype,
+ sizeof(subtype), params_fmt, sizeof(params_fmt), boundary,
+ sizeof(boundary), &has_boundary) == -1)
+ return (-1);
+
+ if (strcasecmp(type, "MULTIPART") == 0) {
+ size_t part_starts[MIME_MAX_PARTS], part_ends[MIME_MAX_PARTS];
+ int n, i;
+
+ if (!has_boundary)
+ return (-1);
+ if (split_multipart(body, bodylen, boundary, part_starts,
+ part_ends, &n, MIME_MAX_PARTS) == -1)
+ return (-1);
+
+ if (envbuf_append(out, outsize, outlen, "(", 1) == -1)
+ return (-1);
+ for (i = 0; i < n; i++) {
+ const char *pbuf = body + part_starts[i];
+ size_t plen = part_ends[i] - part_starts[i];
+ size_t phdrend;
+
+ /* zero-length body-part is spec-legal (RFC 2046 SS5.1.1); treat as hdrlen==0/bodylen==0 directly */
+ if (plen == 0)
+ phdrend = 0;
+ else if (find_header_body_split(pbuf, plen,
+ &phdrend) == -1)
+ return (-1);
+ if (build_body_structure(depth + 1, nparts_used, pbuf,
+ phdrend, pbuf + phdrend, plen - phdrend, out,
+ outsize, outlen) == -1)
+ return (-1);
+ }
+ if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+ return (-1);
+ if (envbuf_append_nstring(out, outsize, outlen, subtype,
+ strlen(subtype)) == -1)
+ return (-1);
+ return (envbuf_append(out, outsize, outlen, ")", 1));
+ }
+
+ if (strcasecmp(type, "MESSAGE") == 0 &&
+ (strcasecmp(subtype, "RFC822") == 0 ||
+ strcasecmp(subtype, "GLOBAL") == 0))
+ return (-1); /* scoped out, see BODYSTRUCTURE comment above */
+
+ {
+ char *idval = NULL, *descval = NULL, *encval = NULL;
+ size_t idlen = 0, desclen = 0, enclen = 0;
+ char encstr[40];
+ int is_text = (strcasecmp(type, "TEXT") == 0);
+ int rc = 0;
+
+ if (envbuf_append(out, outsize, outlen, "(", 1) == -1)
+ return (-1);
+ if (envbuf_append_nstring(out, outsize, outlen, type,
+ strlen(type)) == -1)
+ return (-1);
+ if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+ return (-1);
+ if (envbuf_append_nstring(out, outsize, outlen, subtype,
+ strlen(subtype)) == -1)
+ return (-1);
+ if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+ return (-1);
+ if (envbuf_append(out, outsize, outlen, params_fmt,
+ strlen(params_fmt)) == -1)
+ return (-1);
+ if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+ return (-1);
+
+ if (extract_header_field(hdr, hdrlen, "Content-Id", &idval,
+ &idlen) == 0)
+ rc = envbuf_append_nstring(out, outsize, outlen, idval,
+ idlen);
+ else
+ rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0);
+ free(idval);
+ if (rc == -1)
+ return (-1);
+ if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+ return (-1);
+
+ if (extract_header_field(hdr, hdrlen, "Content-Description",
+ &descval, &desclen) == 0)
+ rc = envbuf_append_nstring(out, outsize, outlen,
+ descval, desclen);
+ else
+ rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0);
+ free(descval);
+ if (rc == -1)
+ return (-1);
+ if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+ return (-1);
+
+ if (extract_header_field(hdr, hdrlen,
+ "Content-Transfer-Encoding", &encval, &enclen) == 0 &&
+ enclen > 0 && enclen < sizeof(encstr)) {
+ memcpy(encstr, encval, enclen);
+ encstr[enclen] = '\0';
+ } else if (strlcpy(encstr, "7BIT", sizeof(encstr)) >=
+ sizeof(encstr)) {
+ return (-1);
+ }
+ free(encval);
+ {
+ static const char *const known[] = { "7BIT", "8BIT",
+ "BINARY", "BASE64", "QUOTED-PRINTABLE" };
+ size_t ki;
+
+ for (ki = 0; ki < 5; ki++) {
+ if (strcasecmp(encstr, known[ki]) == 0) {
+ if (strlcpy(encstr, known[ki],
+ sizeof(encstr)) >= sizeof(encstr))
+ return (-1);
+ break;
+ }
+ }
+ }
+ if (envbuf_append_nstring(out, outsize, outlen, encstr,
+ strlen(encstr)) == -1)
+ return (-1);
+ if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+ return (-1);
+
+ {
+ char numbuf[32];
+
+ snprintf(numbuf, sizeof(numbuf), "%zu", bodylen);
+ if (envbuf_append_str(out, outsize, outlen, numbuf) == -1)
+ return (-1);
+ }
+
+ if (is_text) {
+ size_t lines = 0, li;
+ char numbuf2[32];
+
+ for (li = 0; li < bodylen; li++) {
+ if (body[li] == '\n')
+ lines++;
+ }
+ snprintf(numbuf2, sizeof(numbuf2), " %zu", lines);
+ if (envbuf_append_str(out, outsize, outlen, numbuf2) == -1)
+ return (-1);
+ }
+
+ return (envbuf_append(out, outsize, outlen, ")", 1));
+ }
+}
+
+/* Top-level entry: reads message once (capped at bodystructure_read_max), finds header/body split, walks from depth 0; -1 on any failure. */
+int
+build_bodystructure(const char *basename, char **buf_out, uint32_t *len_out)
+{
+ char *wholebuf = NULL;
+ uint32_t wholelen = 0;
+ size_t hdrend;
+ char out[BODYSTRUCTURE_MAX];
+ size_t outlen = 0;
+ int nparts_used = 0;
+
+ *buf_out = NULL;
+ *len_out = 0;
+
+ if (read_message_body(basename, 0, bodystructure_read_max,
+ "BODYSTRUCTURE", &wholebuf, &wholelen) == -1)
+ return (-1);
+ if (wholelen == 0 ||
+ find_header_body_split(wholebuf, wholelen, &hdrend) == -1) {
+ free(wholebuf);
+ return (-1);
+ }
+
+ if (build_body_structure(0, &nparts_used, wholebuf, hdrend,
+ wholebuf + hdrend, wholelen - hdrend, out, sizeof(out),
+ &outlen) == -1) {
+ free(wholebuf);
+ return (-1);
+ }
+ free(wholebuf);
+
+ if ((*buf_out = malloc(outlen)) == NULL) {
+ log_warn("session %u: malloc BODYSTRUCTURE buffer (%s)",
+ session_id, basename);
+ return (-1);
+ }
+ memcpy(*buf_out, out, outlen);
+ *len_out = (uint32_t)outlen;
+ return (0);
+}
+
+/* Parses RFC 9051 SS6.4.5.1 section-part (e.g. "3.1") into 1-based part numbers; re-validates untrusted input. -1 on bad syntax or > maxpath. */
blob - 2afc427465067cbf551fb84c000c437ffdacda0a
blob + 55cbf3e19a7d339b4fb4f479c379a596688464fb
--- src/imsgev.c
+++ src/imsgev.c
/*
* Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ * Copyright (c) 2009 Eric Faurot <eric@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
+ * (usr.sbin/ldapd/imsgev.c) -- not a generic/obvious name, so his
+ * copyright is carried forward here even though this file's actual
+ * function signatures and dispatch design (a caller-supplied raw
+ * libevent handler, vs. ldapd's callback+needfd model) were written
+ * independently and differ from his implementation.
+ *
* 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.
*/
/*
- * Small shared wrapper around imsgbuf + event(3), used identically by
- * parent.c, listener.c, auth.c, and store.c. Not itself sourced from any
- * uploaded file this session -- see the comment on struct imsgev in
- * openimap.h.
- *
- * API NAMES: checked against the real src/imsg.h this session (see
- * parent.c's header comment) -- imsgbuf_set_maxsize() and
- * imsgbuf_queuelen() both match the real header exactly.
- * MAX_IMSGSIZE itself *is* sourced (imsg_init(3), cited throughout
- * openimap-privsep-design.md).
+ * imsgev.c -- shared wrapper around imsgbuf + event(3), used by
+ * parent.c, listener.c, auth.c, and store.c.
*/
#include <sys/types.h>
fatal("imsgbuf_init");
imsgbuf_set_maxsize(&iev->ibuf, MAX_IMSGSIZE);
- /*
- * Every channel wrapped by struct imsgev either sends or receives
- * fd-passed messages somewhere in its lifetime (listening sockets,
- * SETUP_PEER peer fds, later store-peer fds) -- imsgbuf_allow_fdpass()
- * is real (confirmed against src/imsg.h) and was missing from this
- * skeleton entirely until this pass. Unconditional here rather than
- * per-caller since there's no channel in this design that *never*
- * passes an fd.
- */
+ /* every channel fd-passes something at some point; allow it always */
imsgbuf_allow_fdpass(&iev->ibuf);
iev->handler = handler;
event_add(&iev->ev, NULL);
}
-/*
- * Like imsgev_init(), but for a channel whose struct imsgbuf has already
- * been imsgbuf_init()'d and read from -- e.g. a fd-3 boot channel that a
- * child process drained synchronously (before event_init() was even
- * callable) and now wants to hand off to the event loop for the rest of
- * its life. Takes ownership of *ibuf by copying it into iev->ibuf, NOT by
- * calling imsgbuf_init() again.
- *
- * This distinction matters: unlike struct imsgev (which embeds a struct
- * event -- see the struct store_child comment in parent.c for why THAT
- * must never be copied once registered with libevent), struct imsgbuf
- * itself holds no libevent registration and its only heap state (`w`) is
- * a pointer, so copying it by value is safe and standard -- but calling
- * imsgbuf_init() a *second* time on the same fd would silently discard
- * any bytes imsgbuf_read() had already buffered beyond the messages the
- * caller happened to have consumed so far (parent doesn't wait for acks
- * between sends, so more than one message can already be sitting in the
- * kernel socket buffer -- and possibly already pulled into ibuf's
- * internal state -- by the time a child gets around to reading it).
- * imsgev_init() alone would have that bug; this function exists so
- * listener.c's fd-3 channel (parent) doesn't hit it. See listener.c's
- * listener_main() for the caller.
- */
+/* like imsgev_init(), but copies an already-init'd *ibuf instead of re-init'ing (would discard buffered bytes) */
void
-imsgev_init_from_ibuf(struct imsgev *iev, struct imsgbuf *ibuf,
+imsgev_init_from_ibuf(struct imsgev *iev, const struct imsgbuf *ibuf,
void (*handler)(int, short, void *), void *data)
{
iev->ibuf = *ibuf;
event_add(&iev->ev, NULL);
}
-/*
- * Re-arm after composing an outgoing message: watch EV_WRITE too if the
- * imsgbuf has queued, unflushed output. Callers' dispatch handlers should
- * call this at the end of any code path that calls imsg_compose().
- */
+/* re-arm after imsg_compose(); adds EV_WRITE if output is queued. Call at the end of any compose path. */
void
imsgev_add(struct imsgev *iev)
{
event_add(&iev->ev, NULL);
}
-/*
- * Blocks (no event loop running yet) for exactly one IMSG_SETUP_PEER on
- * ibuf3 (already imsgbuf_init()'d on fd 3 by the caller) and returns the
- * fd-passed peer fd. fatalx()s on anything else -- matches smtpd's
- * setup_proc() treating an unexpected message during setup as fatal
- * ("bad imsg %d").
- *
- * Real deadlock caught on first real-hardware run (OpenBSD, not this
- * sandbox): parent.c sends IMSG_SETUP_PEER and IMSG_SETUP_DONE back to
- * back on the same socketpair fd (setup_peer_send() then
- * setup_done_send(), both flushed immediately, no wait in between). On a
- * SOCK_STREAM socketpair the kernel is free to coalesce both sends into
- * one readable chunk -- confirmed via ktrace(1) on the live hang: a
- * single recvmsg() here returned 32 bytes (both 16-byte imsg headers)
- * instead of 16. imsg_get() correctly peels off just the first message
- * and leaves the second one fully buffered in ibuf3's own userspace
- * state -- but the *caller* (setup_recv_done_and_ack(), immediately
- * next) used to call imsgbuf_read() unconditionally before ever checking
- * whether a complete message was already sitting there, so it issued a
- * second blocking recvmsg() for bytes that were never coming, while
- * parent sat blocked reading this process's now-overdue SETUP_DONE ack.
- * Fixed by checking imsg_get() FIRST in both this loop and
- * setup_recv_done_and_ack()'s below -- imsgbuf_read() (an actual
- * blocking syscall) only happens when nothing is already buffered.
- */
+/* blocks for one IMSG_SETUP_PEER, returns its fd-passed fd; imsg_get() checked before imsgbuf_read() to avoid coalesced-message stalls */
int
setup_recv_one_peer(struct imsgbuf *ibuf3)
{
return (fd);
}
-/*
- * Blocks for IMSG_SETUP_DONE on ibuf3, then sends one back as an ack.
- * Sourced against smtpd.c's setup_proc() loop (IMSG_SETUP_DONE case sets
- * a "done" flag and exits the loop; the ack-back send is the same
- * imsg_compose(ibuf, IMSG_SETUP_DONE, 0, 0, -1, NULL, 0) shape quoted in
- * the design doc from setup_proc()'s tail).
- *
- * imsg_get()-before-imsgbuf_read() ordering: see setup_recv_one_peer()'s
- * header comment above -- this is the specific call site where the real
- * deadlock happened (IMSG_SETUP_DONE arrives already-buffered, coalesced
- * with the preceding IMSG_SETUP_PEER read by the caller).
- */
+/* blocks for IMSG_SETUP_DONE, then sends one back as an ack (see setup_recv_one_peer() re: imsg_get() ordering) */
void
setup_recv_done_and_ack(struct imsgbuf *ibuf3)
{
blob - /dev/null
blob + 075fcd37dae4bde752279995e76c977001f0e82a (mode 644)
--- /dev/null
+++ src/fetch_cmd.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.
+ */
+
+/*
+ * fetch_cmd.c -- FETCH: attribute/section-spec parsing and
+ * response building.
+ */
+
+#include <sys/types.h>
+#include <sys/queue.h>
+#include <sys/socket.h>
+
+#include <netinet/in.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <event.h>
+#include <imsg.h>
+#include <resolv.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <tls.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "listener.h"
+
+int
+parse_nz_number(const char *str, uint32_t *out)
+{
+ unsigned long v;
+ char *end;
+
+ if (str == NULL || *str == '\0' || *str == '0')
+ return (-1);
+
+ errno = 0;
+ v = strtoul(str, &end, 10);
+ if (*end != '\0' || errno == ERANGE || v == 0 || v > UINT32_MAX)
+ return (-1);
+
+ *out = (uint32_t)v;
+ return (0);
+}
+
+/* RFC 9051 SS9 seq-range; "*" unresolved here, carried via lo_star/hi_star to store.c; backwards literal range swapped */
+int
+parse_seq_range(const char *tok, uint32_t *lo, uint32_t *hi, int *lo_star,
+ int *hi_star)
+{
+ char buf[32];
+ char *colon;
+ const char *loside, *hiside;
+
+ if (tok == NULL || *tok == '\0' || strlen(tok) >= sizeof(buf))
+ return (-1);
+ if (strlcpy(buf, tok, sizeof(buf)) >= sizeof(buf))
+ return (-1);
+
+ *lo_star = *hi_star = 0;
+ *lo = *hi = 0;
+
+ if ((colon = strchr(buf, ':')) != NULL) {
+ *colon = '\0';
+ loside = buf;
+ hiside = colon + 1;
+ } else {
+ loside = buf;
+ hiside = buf;
+ }
+
+ if (strcmp(loside, "*") == 0)
+ *lo_star = 1;
+ else if (parse_nz_number(loside, lo) == -1)
+ return (-1);
+
+ if (strcmp(hiside, "*") == 0)
+ *hi_star = 1;
+ else if (parse_nz_number(hiside, hi) == -1)
+ return (-1);
+
+ if (!*lo_star && !*hi_star && *lo > *hi) {
+ uint32_t tmp = *lo;
+
+ *lo = *hi;
+ *hi = tmp;
+ }
+
+ return (0);
+}
+
+/* like strtok_r(str, " ", &savep), but space isn't a delimiter inside an unclosed '[' or '(' (RFC 9051 SS9 header-list) */
+static char *
+fetch_att_tok(char *str, char **savep)
+{
+ char *p, *start;
+ int depth = 0;
+
+ p = (str != NULL) ? str : *savep;
+
+ while (*p == ' ')
+ p++;
+ if (*p == '\0') {
+ *savep = p;
+ return (NULL);
+ }
+
+ for (start = p; *p != '\0'; p++) {
+ if (*p == '[' || *p == '(')
+ depth++;
+ else if (*p == ']' || *p == ')') {
+ if (depth > 0)
+ depth--;
+ } else if (*p == ' ' && depth == 0)
+ break;
+ }
+
+ if (*p != '\0') {
+ *p = '\0';
+ p++;
+ }
+ *savep = p;
+ return (start);
+}
+
+/* parses "HEADER.FIELDS[.NOT] (name ...)" bracket body (RFC 9051 SS9); -1 on syntax error is a real client BAD, not a silent drop */
+int
+parse_header_fields_att(const char *inner, int *not_out, char *fields_out,
+ size_t fields_outsize)
+{
+ char listbuf[HEADER_FIELDS_LABEL_MAX];
+ char *p = listbuf, *listp, *end;
+ char *name, *save;
+ int first = 1;
+
+ *not_out = 0;
+ fields_out[0] = '\0';
+
+ if (strlcpy(listbuf, inner, sizeof(listbuf)) >= sizeof(listbuf))
+ return (-1);
+
+ if (strncasecmp(p, "HEADER.FIELDS", 13) != 0)
+ return (-1);
+ p += 13;
+
+ if (strncasecmp(p, ".NOT", 4) == 0) {
+ *not_out = 1;
+ p += 4;
+ }
+
+ if (*p != ' ')
+ return (-1);
+ p++;
+
+ listp = p;
+ if (*listp != '(')
+ return (-1);
+ listp++;
+
+ end = strchr(listp, ')');
+ if (end == NULL || end[1] != '\0')
+ return (-1);
+ *end = '\0';
+
+ if (*listp == '\0')
+ return (-1); /* header-list requires at least one name */
+
+ for (name = strtok_r(listp, " ", &save); name != NULL;
+ name = strtok_r(NULL, " ", &save)) {
+ if (strchr(name, '"') != NULL)
+ return (-1);
+ if (!first &&
+ strlcat(fields_out, " ", fields_outsize) >= fields_outsize)
+ return (-1);
+ if (strlcat(fields_out, name, fields_outsize) >= fields_outsize)
+ return (-1);
+ first = 0;
+ }
+
+ return (0);
+}
+
+/* RFC 9051 SS6.4.5.1 section-part grammar check; verbatim string still crosses to store.c's parse_section_part() */
+int
+section_part_valid(const char *s)
+{
+ int n = 0;
+
+ if (s == NULL || *s == '\0')
+ return (0);
+
+ while (*s != '\0') {
+ if (*s < '1' || *s > '9') /* nz-number: digit-nz first */
+ return (0);
+ if (n >= MIME_MAX_DEPTH)
+ return (0);
+ n++;
+ while (*s >= '0' && *s <= '9')
+ s++;
+ if (*s == '\0')
+ return (1);
+ if (*s != '.')
+ return (0);
+ s++;
+ if (*s == '\0')
+ return (0); /* trailing dot */
+ }
+ return (1);
+}
+
+/* RFC 9051 SS6.4.5 partial-range suffix "<start.count>"; count may be 0 (apply_partial_range() in store.c handles that) */
+int
+parse_partial_suffix(const char *s, int *has_partial_out,
+ uint32_t *start_out, uint32_t *count_out)
+{
+ const char *p;
+ char *end;
+ unsigned long start, count;
+
+ *has_partial_out = 0;
+ *start_out = 0;
+ *count_out = 0;
+
+ if (s == NULL || *s == '\0')
+ return (0);
+
+ if (s[0] != '<')
+ return (-1);
+ p = s + 1;
+
+ if (*p < '0' || *p > '9')
+ return (-1);
+ errno = 0;
+ start = strtoul(p, &end, 10);
+ if (errno != 0 || start > UINT32_MAX || *end != '.')
+ return (-1);
+ p = end + 1;
+
+ if (*p < '0' || *p > '9')
+ return (-1);
+ errno = 0;
+ count = strtoul(p, &end, 10);
+ if (errno != 0 || count > UINT32_MAX || *end != '>' || end[1] != '\0')
+ return (-1);
+
+ *has_partial_out = 1;
+ *start_out = (uint32_t)start;
+ *count_out = (uint32_t)count;
+ return (0);
+}
+
+/* RFC 9051 SS6.4.5 fetch-att + ALL/FULL/FAST macros; unsupported items silently skipped (*degraded_out=1) unless all are, then -2/NO */
+int
+parse_fetch_atts(char *spec, uint32_t *attrs_out, int *degraded_out,
+ int *header_fields_not_out, char *header_fields_out,
+ size_t header_fields_outsize, char *header_fields_label_out,
+ size_t header_fields_label_outsize, int *bodystructure_full_out,
+ char *section_part_out, size_t section_part_outsize,
+ int *has_partial_out, uint32_t *partial_start_out,
+ uint32_t *partial_count_out, const char **errmsg)
+{
+ char *p, *tok, *save;
+ size_t len;
+ uint32_t attrs = 0;
+ int degraded = 0;
+ int has_partial = 0;
+ uint32_t partial_start = 0, partial_count = 0;
+
+ *errmsg = NULL;
+ *attrs_out = 0;
+ *degraded_out = 0;
+ *header_fields_not_out = 0;
+ header_fields_out[0] = '\0';
+ header_fields_label_out[0] = '\0';
+ *bodystructure_full_out = 0;
+ section_part_out[0] = '\0';
+ *has_partial_out = 0;
+ *partial_start_out = 0;
+ *partial_count_out = 0;
+
+ if (spec == NULL || *spec == '\0') {
+ *errmsg = "missing message data item(s)";
+ return (-1);
+ }
+
+ p = spec;
+ len = strlen(p);
+ if (len >= 2 && p[0] == '(' && p[len - 1] == ')') {
+ p[len - 1] = '\0';
+ p++;
+ }
+ if (*p == '\0') {
+ *errmsg = "empty message data item list";
+ return (-1);
+ }
+
+ for (tok = fetch_att_tok(p, &save); tok != NULL;
+ tok = fetch_att_tok(NULL, &save)) {
+ if (strcasecmp(tok, "FAST") == 0) {
+ /* SS6.4.5 macro: FLAGS INTERNALDATE RFC822.SIZE. */
+ attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
+ MBOX_FETCH_RFC822_SIZE;
+ } else if (strcasecmp(tok, "ALL") == 0) {
+ /* SS6.4.5 macro: FAST + ENVELOPE. */
+ attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
+ MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_ENVELOPE;
+ } else if (strcasecmp(tok, "FULL") == 0) {
+ /* SS6.4.5 macro: ALL + bare BODY (bodystructure_full_out stays 0) */
+ attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
+ MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_ENVELOPE |
+ MBOX_FETCH_BODYSTRUCTURE;
+ } else if (strcasecmp(tok, "FLAGS") == 0) {
+ attrs |= MBOX_FETCH_FLAGS;
+ } else if (strcasecmp(tok, "UID") == 0) {
+ attrs |= MBOX_FETCH_UID;
+ } else if (strcasecmp(tok, "INTERNALDATE") == 0) {
+ attrs |= MBOX_FETCH_INTERNALDATE;
+ } else if (strcasecmp(tok, "RFC822.SIZE") == 0) {
+ attrs |= MBOX_FETCH_RFC822_SIZE;
+ } else if (strcasecmp(tok, "MODSEQ") == 0) {
+ attrs |= MBOX_FETCH_MODSEQ; /* RFC 7162 SS3.1.4.2 -- also CONDSTORE-enabling, cmd_fetch() checks this bit */
+ } else if (strcasecmp(tok, "BODY.PEEK[HEADER]") == 0) {
+ attrs |= MBOX_FETCH_BODY_HEADER; /* the one exact-match BODY[...]; plain BODY[HEADER] would need \Seen, unimplemented */
+ } else if (strncasecmp(tok, "BODY.PEEK[", strlen("BODY.PEEK[")) ==
+ 0 && strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS",
+ strlen("BODY.PEEK[HEADER.FIELDS")) != 0) {
+ /* every other BODY.PEEK[...] shape: [], [TEXT], or [<section-part>], optional <<start.count>> (SS6.4.5) */
+ const char *bracket_start = tok +
+ strlen("BODY.PEEK[");
+ char *close;
+ char inner[SECTION_PART_MAX];
+ const char *suffix;
+
+ close = strchr(bracket_start, ']');
+ if (close == NULL) {
+ degraded = 1; /* not well-bracketed -- same lenient skip as other unsupported forms */
+ continue;
+ }
+ if ((size_t)(close - bracket_start) >= sizeof(inner)) {
+ degraded = 1;
+ continue;
+ }
+ memcpy(inner, bracket_start, close - bracket_start);
+ inner[close - bracket_start] = '\0';
+ suffix = close + 1;
+
+ if (suffix[0] != '\0' &&
+ parse_partial_suffix(suffix, &has_partial,
+ &partial_start, &partial_count) == -1) {
+ *errmsg = "malformed <partial> range";
+ return (-1);
+ }
+
+ if (inner[0] == '\0') {
+ attrs |= MBOX_FETCH_BODY_WHOLE;
+ } else if (strcasecmp(inner, "TEXT") == 0) {
+ attrs |= MBOX_FETCH_BODY_TEXT;
+ } else if (section_part_valid(inner)) {
+ attrs |= MBOX_FETCH_BODY_PART;
+ if (strlcpy(section_part_out, inner,
+ section_part_outsize) >=
+ section_part_outsize) {
+ attrs &= ~MBOX_FETCH_BODY_PART;
+ degraded = 1;
+ continue;
+ }
+ } else {
+ degraded = 1; /* recognized shape, unsupported section (e.g. "2.1.TEXT") */
+ continue;
+ }
+ } else if (strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS",
+ strlen("BODY.PEEK[HEADER.FIELDS")) == 0) {
+ /* prefix-matched (field-name list varies); a second HEADER.FIELDS item is silently ignored */
+ size_t toklen = strlen(tok);
+ char inner[HEADER_FIELDS_LABEL_MAX];
+
+ if (toklen < strlen("BODY.PEEK[") + 1 ||
+ tok[toklen - 1] != ']') {
+ *errmsg = "malformed HEADER.FIELDS section";
+ return (-1);
+ }
+ if (attrs & MBOX_FETCH_HEADER_FIELDS)
+ continue; /* already captured one -- ignore any further duplicates */
+
+ if (toklen - strlen("BODY.PEEK[") - 1 >=
+ sizeof(inner)) {
+ *errmsg = "HEADER.FIELDS section too long";
+ return (-1);
+ }
+ memcpy(inner, tok + strlen("BODY.PEEK["),
+ toklen - strlen("BODY.PEEK[") - 1);
+ inner[toklen - strlen("BODY.PEEK[") - 1] = '\0';
+
+ if (parse_header_fields_att(inner,
+ header_fields_not_out, header_fields_out,
+ header_fields_outsize) == -1) {
+ *errmsg = "malformed HEADER.FIELDS section";
+ return (-1);
+ }
+ if (strlcpy(header_fields_label_out, inner,
+ header_fields_label_outsize) >=
+ header_fields_label_outsize) {
+ *errmsg = "HEADER.FIELDS section too long";
+ return (-1);
+ }
+ attrs |= MBOX_FETCH_HEADER_FIELDS;
+ } else if (strcasecmp(tok, "ENVELOPE") == 0) {
+ attrs |= MBOX_FETCH_ENVELOPE; /* RFC 9051 SS7.5.2; no .PEEK variant, no \Seen side effect */
+ } else if (strcasecmp(tok, "BODY") == 0 ||
+ strcasecmp(tok, "BODYSTRUCTURE") == 0) {
+ /* both produce identical output; exact-matched ahead of the "BODY" prefix catch-all below */
+ attrs |= MBOX_FETCH_BODYSTRUCTURE;
+ *bodystructure_full_out =
+ (strcasecmp(tok, "BODYSTRUCTURE") == 0);
+ } else if (strncasecmp(tok, "BODY", 4) == 0 ||
+ strcasecmp(tok, "RFC822") == 0 ||
+ strcasecmp(tok, "RFC822.HEADER") == 0 ||
+ strcasecmp(tok, "RFC822.TEXT") == 0) {
+ /* MIME part-addressed BODY[...] and RFC822(.HEADER/.TEXT) shorthands unimplemented; dropped */
+ degraded = 1;
+ } else {
+ *errmsg = "unknown message data item";
+ return (-1);
+ }
+ }
+
+ if (attrs == 0) {
+ /* every requested item was unsupported, e.g. BODY[<part>] or RFC822(.HEADER/.TEXT) alone */
+ *errmsg = "cannot fetch that message content yet -- "
+ "supported: FLAGS/UID/INTERNALDATE/RFC822.SIZE/MODSEQ/"
+ "ENVELOPE/(BODY|BODYSTRUCTURE)/BODY.PEEK[...]";
+ return (-2);
+ }
+
+ /* both HEADER and HEADER.FIELDS requested (legal, SS6.4.5): HEADER wins, only one pending_header_* slot exists */
+ if ((attrs & MBOX_FETCH_BODY_HEADER) &&
+ (attrs & MBOX_FETCH_HEADER_FIELDS))
+ attrs &= ~MBOX_FETCH_HEADER_FIELDS;
+
+ *attrs_out = attrs;
+ *degraded_out = degraded;
+ /* accumulated in locals like attrs, copied out here so the HEADER-wins resolution above stays the one adjustment point */
+ *has_partial_out = has_partial;
+ *partial_start_out = partial_start;
+ *partial_count_out = partial_count;
+ return (0);
+}
+
+const char *fetch_month_names[12] = {
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
+};
+
+/* RFC 9051 SS9 date-time; always formats in UTC "+0000" -- ts carries no tz info and a chroot'd store child has no tzdata */
+void
+format_internaldate(int64_t ts, char *out, size_t outsize)
+{
+ struct tm tm;
+ time_t t = (time_t)ts;
+
+ if (gmtime_r(&t, &tm) == NULL) {
+ if (strlcpy(out, "01-Jan-1970 00:00:00 +0000", outsize) >=
+ outsize)
+ log_warnx("format_internaldate: fallback string "
+ "truncated -- caller's buffer too small");
+ return;
+ }
+
+ snprintf(out, outsize, "%2d-%s-%04d %02d:%02d:%02d +0000",
+ tm.tm_mday, fetch_month_names[tm.tm_mon], tm.tm_year + 1900,
+ tm.tm_hour, tm.tm_min, tm.tm_sec);
+}
+
+/* snprintf-into-growing-buffer helper; clamps *len to bufsize so a prior truncation can't underflow the next call's remaining size */
+static void
+fetch_append(char *buf, size_t bufsize, size_t *len, const char *fmt, ...)
+{
+ va_list ap;
+ int n;
+
+ if (*len >= bufsize)
+ return;
+
+ va_start(ap, fmt);
+ n = vsnprintf(buf + *len, bufsize - *len, fmt, ap);
+ va_end(ap);
+
+ if (n < 0)
+ return;
+
+ *len += (size_t)n;
+ if (*len > bufsize)
+ *len = bufsize;
+}
+
+/* sends one untagged "* <seqno> FETCH (...)" (RFC 9051 SS7.5.2); literal-syntax items flush buf then write their payload raw */
+void
+session_send_fetch_response(struct session *s,
+ struct imsg_mbox_fetch_meta *meta)
+{
+ char buf[768];
+ char date[40];
+ size_t len = 0;
+ int need_sp = 0;
+ int have_header = (s->fetch_attrs & (MBOX_FETCH_BODY_HEADER |
+ MBOX_FETCH_HEADER_FIELDS)) && s->pending_header_found;
+ int have_body = (s->fetch_attrs &
+ (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT |
+ MBOX_FETCH_BODY_PART)) && s->pending_body_found;
+ int have_envelope = (s->fetch_attrs & MBOX_FETCH_ENVELOPE) &&
+ s->pending_envelope_found;
+ int have_bodystructure = (s->fetch_attrs & MBOX_FETCH_BODYSTRUCTURE) &&
+ s->pending_bodystructure_found;
+
+ fetch_append(buf, sizeof(buf), &len, "%u FETCH (", meta->seqno);
+
+ if (s->fetch_attrs & MBOX_FETCH_FLAGS) {
+ fetch_append(buf, sizeof(buf), &len, "FLAGS (%s)", meta->flags);
+ need_sp = 1;
+ }
+ if (s->fetch_attrs & MBOX_FETCH_UID) {
+ fetch_append(buf, sizeof(buf), &len, "%sUID %u",
+ need_sp ? " " : "", meta->uid);
+ need_sp = 1;
+ }
+ if (s->fetch_attrs & MBOX_FETCH_INTERNALDATE) {
+ format_internaldate(meta->internaldate, date, sizeof(date));
+ fetch_append(buf, sizeof(buf), &len, "%sINTERNALDATE \"%s\"",
+ need_sp ? " " : "", date);
+ need_sp = 1;
+ }
+ if (s->fetch_attrs & MBOX_FETCH_RFC822_SIZE) {
+ fetch_append(buf, sizeof(buf), &len, "%sRFC822.SIZE %llu",
+ need_sp ? " " : "", (unsigned long long)meta->size);
+ need_sp = 1;
+ }
+ if (s->fetch_attrs & MBOX_FETCH_MODSEQ) {
+ /* RFC 7162 SS3.1.4.2 fetch-mod-resp */
+ fetch_append(buf, sizeof(buf), &len, "%sMODSEQ (%llu)",
+ need_sp ? " " : "", (unsigned long long)meta->modseq);
+ need_sp = 1;
+ }
+
+ if (have_header || have_body || have_envelope || have_bodystructure) {
+ session_write(s, "* ", 2);
+ session_write(s, buf, len);
+
+ if (have_envelope) {
+ len = 0;
+ fetch_append(buf, sizeof(buf), &len, "%sENVELOPE ",
+ need_sp ? " " : "");
+ session_write(s, buf, len);
+ if (s->pending_envelope_len > 0)
+ session_write(s, s->pending_envelope_buf,
+ s->pending_envelope_len);
+ need_sp = 1;
+ }
+ if (have_bodystructure) {
+ len = 0;
+ fetch_append(buf, sizeof(buf), &len, "%s%s ",
+ need_sp ? " " : "", s->pending_bodystructure_label);
+ session_write(s, buf, len);
+ if (s->pending_bodystructure_len > 0)
+ session_write(s, s->pending_bodystructure_buf,
+ s->pending_bodystructure_len);
+ need_sp = 1;
+ }
+ if (have_header) {
+ len = 0;
+ fetch_append(buf, sizeof(buf), &len,
+ "%sBODY[%s] {%u}\r\n", need_sp ? " " : "",
+ s->pending_header_label, s->pending_header_len);
+ session_write(s, buf, len);
+ if (s->pending_header_len > 0)
+ session_write(s, s->pending_header_buf,
+ s->pending_header_len);
+ need_sp = 1;
+ }
+ if (have_body) {
+ /* RFC 9051 SS6.4.5: echo the origin octet only if the client sent one; never echo store.c's count */
+ len = 0;
+ if (s->pending_body_has_partial)
+ fetch_append(buf, sizeof(buf), &len,
+ "%sBODY[%s]<%u> {%u}\r\n",
+ need_sp ? " " : "", s->pending_body_label,
+ s->pending_body_partial_origin,
+ s->pending_body_len);
+ else
+ fetch_append(buf, sizeof(buf), &len,
+ "%sBODY[%s] {%u}\r\n", need_sp ? " " : "",
+ s->pending_body_label, s->pending_body_len);
+ session_write(s, buf, len);
+ if (s->pending_body_len > 0)
+ session_write(s, s->pending_body_buf,
+ s->pending_body_len);
+ }
+ session_write(s, ")\r\n", 3);
+ } else {
+ fetch_append(buf, sizeof(buf), &len, ")");
+
+ if (len >= sizeof(buf))
+ log_warnx("session %u: FETCH response for seq %u "
+ "truncated", s->id, meta->seqno);
+
+ session_untagged(s, buf);
+ }
+
+ /* reset pending_*_found even when have_* is false, so it doesn't leak into the next message's response */
+ if (have_header) {
+ free(s->pending_header_buf);
+ s->pending_header_buf = NULL;
+ s->pending_header_len = 0;
+ }
+ if (s->fetch_attrs & (MBOX_FETCH_BODY_HEADER | MBOX_FETCH_HEADER_FIELDS))
+ s->pending_header_found = 0;
+
+ if (have_body) {
+ free(s->pending_body_buf);
+ s->pending_body_buf = NULL;
+ s->pending_body_len = 0;
+ }
+ if (s->fetch_attrs & (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT |
+ MBOX_FETCH_BODY_PART))
+ s->pending_body_found = 0;
+
+ if (have_envelope) {
+ free(s->pending_envelope_buf);
+ s->pending_envelope_buf = NULL;
+ s->pending_envelope_len = 0;
+ }
+ if (s->fetch_attrs & MBOX_FETCH_ENVELOPE)
+ s->pending_envelope_found = 0;
+
+ if (have_bodystructure) {
+ free(s->pending_bodystructure_buf);
+ s->pending_bodystructure_buf = NULL;
+ s->pending_bodystructure_len = 0;
+ }
+ if (s->fetch_attrs & MBOX_FETCH_BODYSTRUCTURE)
+ s->pending_bodystructure_found = 0;
+}
+
+/* STORE's untagged FETCH response (RFC 9051 SS6.4.6) always shows FLAGS; MODSEQ shown whenever CONDSTORE-aware (SS3.1.3) */
+void
+session_send_store_fetch_response(struct session *s,
+ const struct imsg_mbox_fetch_meta *meta)
+{
+ char buf[MBOX_FLAGS_MAX + 96];
+ size_t len;
+
+ /* RFC 9051 SS6.4.9: a UID STORE's echo must include UID, right after FLAGS */
+ len = (size_t)snprintf(buf, sizeof(buf), "%u FETCH (FLAGS (%s)",
+ meta->seqno, meta->flags);
+ if (s->cmd_by_uid && len < sizeof(buf))
+ len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+ " UID %u", meta->uid);
+ if (s->condstore_enabled && len < sizeof(buf))
+ len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+ " MODSEQ (%llu)", (unsigned long long)meta->modseq);
+ if (len < sizeof(buf))
+ snprintf(buf + len, sizeof(buf) - len, ")");
+
+ session_untagged(s, buf);
+}
+
+/* splits a trailing RFC 4466 modifier list off spec (shared by cmd_fetch()/cmd_store_cmd()); NUL-terminates spec in place */
+char *
+split_trailing_modifiers(char *spec)
+{
+ char *p = spec;
+
+ if (*p == '(') {
+ int depth = 0;
+
+ for (;;) {
+ if (*p == '(')
+ depth++;
+ else if (*p == ')') {
+ depth--;
+ if (depth == 0) {
+ p++;
+ break;
+ }
+ } else if (*p == '\0')
+ return (NULL); /* unterminated; caller's own parser produces the BAD for this */
+ p++;
+ }
+ } else {
+ while (*p != '\0' && *p != ' ')
+ p++;
+ }
+
+ if (*p == '\0')
+ return (NULL);
+ *p++ = '\0';
+ while (*p == ' ')
+ p++;
+ if (*p == '\0')
+ return (NULL);
+ return (p);
+}
+
+/* FETCH's trailing fetch-modifier list (RFC 4466 + RFC 7162 SS3.1.4.1/SS3.2.6); *want_vanished lets fetch_dispatch() pair-check later */
+int
+parse_fetch_modifiers(char *modspec, struct imsg_mbox_fetch *req,
+ const struct session *s, int by_uid, int *want_vanished,
+ const char **errmsg)
+{
+ char *p, *tok, *save;
+ size_t len;
+
+ *errmsg = NULL;
+ *want_vanished = 0;
+ len = strlen(modspec);
+ if (len < 2 || modspec[0] != '(' || modspec[len - 1] != ')') {
+ *errmsg = "malformed fetch-modifier list";
+ return (-1);
+ }
+ modspec[len - 1] = '\0';
+ p = modspec + 1;
+
+ for (tok = strtok_r(p, " ", &save); tok != NULL;
+ tok = strtok_r(NULL, " ", &save)) {
+ if (strcasecmp(tok, "CHANGEDSINCE") == 0) {
+ const char *valtok = strtok_r(NULL, " ", &save);
+ char *ep;
+
+ if (valtok == NULL) {
+ *errmsg = "CHANGEDSINCE requires a "
+ "mod-sequence value";
+ return (-1);
+ }
+ errno = 0;
+ req->changedsince = strtoull(valtok, &ep, 10);
+ if (*ep != '\0' || errno != 0) {
+ *errmsg = "invalid CHANGEDSINCE mod-sequence";
+ return (-1);
+ }
+ req->has_changedsince = 1;
+ req->attrs |= MBOX_FETCH_MODSEQ;
+ } else if (strcasecmp(tok, "VANISHED") == 0) {
+ if (!by_uid) {
+ /* RFC 7162 SS3.2.6: VANISHED not allowed with plain FETCH, MUST return tagged BAD */
+ *errmsg = "VANISHED is only valid as a UID "
+ "FETCH modifier (RFC 7162 SS3.2.6)";
+ return (-1);
+ }
+ if (!s->qresync_enabled) {
+ *errmsg = "VANISHED requires ENABLE QRESYNC "
+ "first (RFC 7162 SS3.2.6)";
+ return (-1);
+ }
+ *want_vanished = 1;
+ } else {
+ *errmsg = "unrecognized fetch modifier";
+ return (-1);
+ }
+ }
+
+ return (0);
+}
+
+/* RFC 9051 SS6.4.5 fetch + RFC 4466/7162 modifier list; plain BODY[...] and BODY[<part>] are a deliberate v1 scope cut */
+int
+cmd_fetch(struct session *s, const char *tag, char *args)
+{
+ return fetch_dispatch(s, tag, args, 0);
+}
+
+/* shared body for cmd_fetch() (by_uid=0) and cmd_uid()'s FETCH branch (by_uid=1); RFC 9051 SS6.4.9 forces MBOX_FETCH_UID into attrs */
+int
+fetch_dispatch(struct session *s, const char *tag, char *args, int by_uid)
+{
+ struct imsg_mbox_fetch req;
+ const char *seqtok;
+ char *attspec, *modspec;
+ uint32_t lo, hi, attrs;
+ int lo_star, hi_star, rc, want_vanished = 0, degraded;
+ int header_fields_not = 0;
+ char header_fields[HEADER_FIELDS_MAX];
+ char header_fields_label[HEADER_FIELDS_LABEL_MAX];
+ int bodystructure_full = 0;
+ char section_part[SECTION_PART_MAX];
+ int has_partial = 0;
+ uint32_t partial_start = 0, partial_count = 0;
+ const char *errmsg;
+ const char *cmdname = by_uid ? "UID FETCH" : "FETCH";
+
+ if (args == NULL) {
+ session_reply(s, tag, "BAD",
+ "FETCH requires a sequence set and message data item(s)");
+ return (1);
+ }
+
+ seqtok = args;
+ while (*args != '\0' && *args != ' ')
+ args++;
+ if (*args == '\0') {
+ session_reply(s, tag, "BAD",
+ "FETCH requires message data item(s)");
+ return (1);
+ }
+ *args++ = '\0';
+ while (*args == ' ')
+ args++;
+ attspec = args;
+
+ if (strchr(seqtok, ',') != NULL) {
+ session_reply(s, tag, "BAD",
+ "comma-separated sequence sets not supported in v1 -- "
+ "issue separate FETCH commands");
+ return (1);
+ }
+
+ if (parse_seq_range(seqtok, &lo, &hi, &lo_star, &hi_star) == -1) {
+ session_reply(s, tag, "BAD", "invalid sequence set");
+ return (1);
+ }
+
+ modspec = split_trailing_modifiers(attspec);
+
+ rc = parse_fetch_atts(attspec, &attrs, °raded, &header_fields_not,
+ header_fields, sizeof(header_fields), header_fields_label,
+ sizeof(header_fields_label), &bodystructure_full, section_part,
+ sizeof(section_part), &has_partial, &partial_start,
+ &partial_count, &errmsg);
+ if (rc == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+ if (rc == -2) {
+ session_reply(s, tag, "NO", errmsg);
+ return (1);
+ }
+ if (degraded)
+ log_debug("session %u: %s: one or more unsupported message "
+ "data items silently dropped (plain BODY[...]/BODY[<part>]"
+ "/BODY.PEEK[<part>] with MESSAGE/RFC822|GLOBAL or MULTIPART"
+ " nested numbering/RFC822[.HEADER/.TEXT]) -- answering "
+ "with whatever was recognized", s->id, cmdname);
+
+ memset(&req, 0, sizeof(req));
+ req.attrs = attrs;
+ req.by_uid = by_uid;
+ req.header_fields_not = header_fields_not;
+
+ /* re-checked though already bounds-checked above; store.c applies has_partial/section_part to whichever of WHOLE/TEXT/PART wins */
+ if (strlcpy(req.header_fields, header_fields,
+ sizeof(req.header_fields)) >= sizeof(req.header_fields) ||
+ strlcpy(req.section_part, section_part, sizeof(req.section_part))
+ >= sizeof(req.section_part)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ req.has_partial = has_partial;
+ req.partial_start = partial_start;
+ req.partial_count = partial_count;
+
+ /* verbatim client-typed label never crosses to store.c -- stashed here for session_send_fetch_response() to echo */
+ if (attrs & MBOX_FETCH_BODY_HEADER) {
+ if (strlcpy(s->pending_header_label, "HEADER",
+ sizeof(s->pending_header_label)) >=
+ sizeof(s->pending_header_label)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ } else if (attrs & MBOX_FETCH_HEADER_FIELDS) {
+ if (strlcpy(s->pending_header_label, header_fields_label,
+ sizeof(s->pending_header_label)) >=
+ sizeof(s->pending_header_label)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ }
+
+ /* same idea, for BODY.PEEK[]/[TEXT]/[<section-part>]; WHOLE>TEXT>PART must match store.c's handle_mbox_fetch() */
+ if (attrs & MBOX_FETCH_BODY_WHOLE) {
+ s->pending_body_label[0] = '\0';
+ } else if (attrs & MBOX_FETCH_BODY_TEXT) {
+ if (strlcpy(s->pending_body_label, "TEXT",
+ sizeof(s->pending_body_label)) >=
+ sizeof(s->pending_body_label)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ } else if (attrs & MBOX_FETCH_BODY_PART) {
+ if (strlcpy(s->pending_body_label, section_part,
+ sizeof(s->pending_body_label)) >=
+ sizeof(s->pending_body_label)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ }
+ s->pending_body_has_partial = has_partial;
+ s->pending_body_partial_origin = partial_start;
+
+ /* same idea, for BODYSTRUCTURE: response label echoes whichever bare token ("BODY"/"BODYSTRUCTURE") the client used */
+ if (attrs & MBOX_FETCH_BODYSTRUCTURE) {
+ if (strlcpy(s->pending_bodystructure_label,
+ bodystructure_full ? "BODYSTRUCTURE" : "BODY",
+ sizeof(s->pending_bodystructure_label)) >=
+ sizeof(s->pending_bodystructure_label)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ }
+
+ if (modspec != NULL) {
+ if (parse_fetch_modifiers(modspec, &req, s, by_uid,
+ &want_vanished, &errmsg) == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+ }
+
+ if (want_vanished && !req.has_changedsince) {
+ /* RFC 7162 SS3.2.6: VANISHED MUST be paired with CHANGEDSINCE, else tagged BAD */
+ session_reply(s, tag, "BAD",
+ "VANISHED requires CHANGEDSINCE also be specified "
+ "(RFC 7162 SS3.2.6)");
+ return (1);
+ }
+ req.want_vanished = want_vanished;
+
+ if (by_uid)
+ req.attrs |= MBOX_FETCH_UID;
+
+ if (s->store_iev == NULL) {
+ /* same invariant check as cmd_select() -- ST_SELECTED requires store_iev already wired */
+ log_warnx("session %u: %s with no store channel wired",
+ s->id, cmdname);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ req.seq_lo = lo;
+ req.seq_hi = hi;
+ req.lo_is_star = lo_star;
+ req.hi_is_star = hi_star;
+
+ /* RFC 7162 SS3.1: MODSEQ fetch-att and CHANGEDSINCE modifier are both CONDSTORE-enabling */
+ if (req.attrs & MBOX_FETCH_MODSEQ)
+ session_condstore_enable(s);
+
+ if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->fetch_attrs = req.attrs;
+ s->cmd_by_uid = by_uid;
+ s->state = SESSION_FETCHING;
+
+ if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_FETCH, 0, 0, -1,
+ &req, sizeof(req)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_FETCH", s->id);
+ imsgev_add(s->store_iev);
+
+ return (1);
+}
blob - a42a003990cf0f34ffcf910cd350f587df7783e9
blob + b418cd4079c0ac53d846b137d7487da81c37c0d0
--- src/listener.c
+++ src/listener.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * listener.c -- protocol/network process. Implements the "listener"
- * section of openimap-privsep-design.md: owns client TCP sockets, runs
- * the IMAP command parser/dispatch table for the "any state" and "not
- * authenticated state" command sets, and now terminates TLS for real
- * (implicit-TLS port 993 per RFC 8314, and STARTTLS on the cleartext
- * port) via libtls -- tls_server()/tls_configure() built once at boot
- * from cert/key bytes parent sends (IMSG_TLS_CERT/IMSG_TLS_KEY), then
- * tls_accept_socket()+tls_handshake() per connection, sourced directly
- * against src/lib/libtls/tls.h and httpd's server_tls_init()/server_
- * tls_handshake() (openbsd_source/src/usr.sbin/httpd/server.c). AUTHENTICATE
- * PLAIN is now real too: cmd_authenticate() handles both the inline-
- * initial-response and continuation-request forms (RFC 9051 SS6.2.2),
- * decodes via b64_pton() (<resolv.h>, sourced against openbsd_source/src/
- * lib/libc/net/base64.c), splits per RFC 4616's authzid/authcid/passwd
- * framing (fetched directly from https://www.rfc-editor.org/rfc/rfc4616.txt
- * this session -- not present in research/ or openbsd_source/), and sends
- * IMSG_AUTH_REQUEST to auth over iev_auth; the tagged OK/NO back to the
- * client is sent from whichever of listener_dispatch_auth()'s IMSG_AUTH_
- * RESULT or listener_dispatch_parent()'s IMSG_SETUP_PEER/IMSG_STORE_FORK
- * cases actually resolves the async round trip. A session can now
- * genuinely reach SESSION_AUTHENTICATED. imap_cmds[] also now has a real,
- * correctly state-gated entry for every command-auth (RFC 9051 SS6.3) and
- * command-select (SS6.4) command -- ENABLE, SELECT, LIST, FETCH, STORE,
- * EXPUNGE, CLOSE, UNSELECT, APPEND, and SEARCH are fully implemented
- * (cmd_enable()/cmd_select()/cmd_list()/cmd_fetch()/cmd_store_cmd()/cmd_
- * expunge()/cmd_close()/cmd_unselect()/cmd_append()/cmd_search(), FETCH
- * message-metadata-only: FLAGS/UID/INTERNALDATE/RFC822.SIZE, not BODY[]
- * -- see cmd_fetch()'s own comment for why). STORE reuses FETCH's
- * IMSG_MBOX_FETCH_META/IMSG_MBOX_RESULT reply pair, since RFC 9051
- * SS6.4.6 says STORE's only response is itself an untagged FETCH; CLOSE
- * reuses EXPUNGE's IMSG_MBOX_EXPUNGE/IMSG_MBOX_EXPUNGED/IMSG_MBOX_RESULT
- * wholesale (silent=1, no untagged EXPUNGE responses, and a different
- * post-completion state -- see session_request_expunge()/session_handle_
- * mbox_result()); UNSELECT needs no store round trip at all, since
- * store.c holds no per-selection state to free. LIST (SS6.3.9, basic
- * syntax only -- extended selection/return options get a flagged NO)
- * needs no store round trip either: v1 has no CREATE, so INBOX's
- * existence is never in question, making LIST pure string/wildcard
- * matching against the fixed name "INBOX" (list_pattern_match()).
- * APPEND (SS6.3.12) required this file's first real IMAP literal
- * ({n}/{n+}) support -- a raw-byte read phase (s->literal_pending, in
- * session_dispatch_client()'s read loop, intercepted before the CRLF
- * line parser since literal bytes can contain embedded CRLFs) followed
- * by a store round trip in the new SESSION_APPENDING state; message
- * bytes travel to store.c as variable-length trailing data on a single
- * imsg (imsg_get_buf()/imsg_get_len(), verified against the real imsg-
- * buffer.c this session), capped at APPEND_LITERAL_MAX (12000 bytes) to
- * stay under imsg's MAX_IMSGSIZE -- larger messages need real fd-passing,
- * not implemented this pass, same deferral as BODY[] FETCH. SEARCH
- * (SS6.4.4, ESEARCH responses per SS7.3.4) compiles the flag/date/size/
- * sequence-number/UID-range/NOT/OR/parenthesized-list search-key grammar
- * into a flat postfix bytecode (parse_search_key()/parse_search_key_
- * list()) sent to store.c as variable-length trailing data on IMSG_MBOX_
- * SEARCH (the same imsg technique APPEND established), which streams
- * back matching sequence numbers via IMSG_MBOX_SEARCH_MATCH for session_
- * finish_search() to assemble into MIN/MAX/ALL/COUNT per RFC 9051's own
- * worked examples; content-and-header-based search keys (BCC/BODY/CC/
- * FROM/HEADER/SENTBEFORE/SENTON/SENTSINCE/SUBJECT/TEXT/TO), the SAVE/"$"
- * result variable, and the "UID SEARCH" command wrapper are all
- * deliberately out of scope this pass -- see imapd.h's imsg_mbox_
- * search comment. Everything else in those two command sets still
- * replies NO via stub_not_implemented() pending store.c's still-
- * undesigned IMSG_MBOX_* wire protocol for that operation. A session can
- * now genuinely reach SESSION_SELECTED, issue a LIST from either
- * Authenticated or Selected, and round-trip a FETCH, STORE, EXPUNGE,
- * CLOSE, UNSELECT, APPEND, or SEARCH.
- *
- * The boot-time setup handshake, receiving the listening socket fds +
- * TLS cert/key from parent, the per-session table, requesting + wiring
- * a per-session store child on successful auth (IMSG_STORE_FORK /
- * IMSG_SETUP_PEER / IMSG_SETUP_DONE from parent, demuxed by session_id),
- * its own privilege drop, and full session teardown (including
- * notifying a wired store child via IMSG_STORE_SHUTDOWN, and closing
- * a TLS session correctly -- see session_teardown()'s comment on why
- * tls_close() sometimes needs a manual close(2) fallback and sometimes
- * doesn't) are all implemented for real too.
- *
- * Two distinct persistent channels exist here, fixed this pass (see the
- * "channel-identity gap" note that used to be here as a flagged TODO):
- * - iev_auth: the boot-time SETUP_PEER-wired channel to the AUTH
- * process (peer_fd below). Carries IMSG_AUTH_REQUEST out /
- * IMSG_AUTH_RESULT in.
- * - iev_parent: this process's own fd-3 channel back to PARENT, kept
- * alive for the process's whole lifetime (parent never closes its
- * end -- "parent isn't on this path once wiring completes" only
- * applies to STORE, not to listener/auth themselves). Carries
- * IMSG_STORE_FORK out, and IMSG_SETUP_PEER (a new store child's
- * peer fd, demuxed by session_id via imsg_get_id()) / IMSG_STORE_FORK
- * (parent replying with a *failure*) in.
- *
- * An earlier draft of this file used ONE struct imsgev (also named
- * iev_parent) fed from peer_fd -- i.e. it was actually the AUTH channel
- * despite the name -- and never turned fd 3 itself into a persistent,
- * event-driven channel at all past the synchronous boot-time drain loop.
- * That meant IMSG_STORE_FORK was being sent to auth, not parent, and
- * nothing ever read fd 3 again after boot. Fixed below by keeping fd 3's
- * already-populated struct imsgbuf alive across the transition into the
- * event loop (imsgev_init_from_ibuf() in imsgev.c) instead of a second,
- * fresh imsgbuf_init() on the same fd, which would have silently dropped
- * any bytes already buffered from parent (parent doesn't wait for acks
- * between sends, so more than one message can already be in flight by
- * the time this process gets around to reading it).
- *
- * API NAMES: checked against the real src/imsg.h this session -- see
- * parent.c's header comment for the full verification note.
- */
+/* listener.c -- protocol/network process: client sockets, IMAP dispatch, TLS. */
#include <sys/types.h>
#include <sys/queue.h>
#include <netinet/in.h>
-/*
- * <resolv.h> (below, for b64_pton()) uses "struct sockaddr_in",
- * "struct in_addr", and "struct in6_addr" without defining them itself --
- * confirmed against openbsd_source/src/include/resolv.h, which only
- * includes <sys/types.h>, <sys/socket.h>, and <stdio.h>. <netinet/in.h>
- * must come first, matching the ordering smtpd's util.c and httpd's
- * server_http.c both use for the same pairing.
- */
+/* <netinet/in.h> must precede <resolv.h>, which needs struct sockaddr_in. */
#include <ctype.h>
#include <errno.h>
#include <event.h>
#include "imapd.h"
#include "log.h"
-
-enum session_state {
- SESSION_NOT_AUTH,
- SESSION_AUTHENTICATING, /* IMSG_AUTH_REQUEST sent, awaiting reply */
- SESSION_STORE_PENDING, /* IMSG_STORE_FORK sent, awaiting peer */
- SESSION_AUTHENTICATED,
- SESSION_SELECTING, /* IMSG_MBOX_SELECT sent, awaiting reply --
- * see cmd_select()/session_store_dispatch() */
- SESSION_SELECTED,
- SESSION_FETCHING, /* IMSG_MBOX_FETCH sent, awaiting the
- * IMSG_MBOX_FETCH_META stream + terminal
- * IMSG_MBOX_RESULT -- see cmd_fetch()/
- * session_handle_mbox_result() */
- SESSION_STORING, /* IMSG_MBOX_STORE sent, awaiting the same
- * IMSG_MBOX_FETCH_META stream + terminal
- * IMSG_MBOX_RESULT shape SESSION_FETCHING
- * uses -- see cmd_store_cmd()'s comment on
- * why STORE reuses FETCH's reply types */
- SESSION_EXPUNGING, /* IMSG_MBOX_EXPUNGE sent (by EXPUNGE itself,
- * or by CLOSE with silent=1), awaiting the
- * IMSG_MBOX_EXPUNGED stream + terminal
- * IMSG_MBOX_RESULT -- see cmd_expunge()/
- * cmd_close()/session_handle_mbox_result() */
- SESSION_APPENDING, /* IMSG_MBOX_APPEND sent, awaiting the single
- * terminal IMSG_MBOX_APPENDED reply -- see
- * cmd_append()/session_finish_append()/
- * session_handle_mbox_appended(). Distinct
- * from the *client-literal-read* phase that
- * precedes it (s->literal_pending -- see that
- * field's comment): this state only covers
- * the store round trip, once the full
- * message has already been read off the
- * wire. */
- SESSION_SEARCHING, /* IMSG_MBOX_SEARCH sent, awaiting the
- * IMSG_MBOX_SEARCH_MATCH stream + terminal
- * IMSG_MBOX_RESULT -- see cmd_search()/
- * session_finish_search(). Same per-match-
- * stream-then-terminal-result reply shape as
- * SESSION_FETCHING/STORING/EXPUNGING, so it's
- * handled by the same session_handle_mbox_
- * result() entry point, which branches to
- * session_finish_search() first. */
- SESSION_STATUSING, /* IMSG_MBOX_STATUS sent, awaiting the single
- * terminal IMSG_MBOX_STATUS_RESULT reply --
- * see cmd_status()/session_handle_mbox_
- * status_result(). Same single-request/single-
- * reply shape as SESSION_SELECTING, but
- * (unlike SELECT) never changes s->state's
- * SELECTED-ness -- STATUS "does not change the
- * currently selected mailbox" (RFC 9051
- * SS6.3.11) -- so s->status_prev_state records
- * whichever ST_AUTH state was current when
- * cmd_status() was called, for session_handle_
- * mbox_status_result() to restore. */
- SESSION_COPYING, /* IMSG_MBOX_COPY or IMSG_MBOX_MOVE sent
- * (s->cmd_is_move says which), awaiting the
- * IMSG_MBOX_COPY_MAPPING stream (plus, for a
- * MOVE, an interleaved IMSG_MBOX_EXPUNGED
- * stream -- see s->move_expunged's comment) +
- * terminal IMSG_MBOX_RESULT -- see cmd_copy()/
- * cmd_move()/session_finish_copy_or_move().
- * Same per-message-stream-then-terminal-result
- * shape as SESSION_FETCHING/STORING/EXPUNGING/
- * SEARCHING, so it's handled by the same
- * session_handle_mbox_result() entry point,
- * which branches to session_finish_copy_or_
- * move() first, the same way it already
- * branches to session_finish_search(). */
-
- /*
- * RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 additions (flat multi-mailbox
- * support, docs/openimap-storage-backend.md item 10). All four are
- * command-auth (valid in Authenticated or Selected state) and never
- * change s->state's SELECTED-ness -- same "record whichever ST_AUTH
- * state was current before, restore it after" pattern SESSION_
- * STATUSING's s->status_prev_state already established, reused here
- * as s->mbox_op_prev_state (one shared field: only one of these four
- * can ever be in flight for a given session at once, so there's no
- * need for four separate fields the way SESSION_APPENDING has its
- * own append_prev_state).
- */
- SESSION_CREATING, /* IMSG_MBOX_CREATE sent, awaiting the single
- * terminal IMSG_MBOX_RESULT reply -- see
- * cmd_create()/session_finish_mbox_op(). */
- SESSION_DELETING, /* IMSG_MBOX_DELETE sent, same single-terminal-
- * reply shape as SESSION_CREATING -- see
- * cmd_delete()/session_finish_mbox_op(). */
- SESSION_RENAMING, /* IMSG_MBOX_RENAME sent, same single-terminal-
- * reply shape as SESSION_CREATING -- see
- * cmd_rename()/session_finish_mbox_op(). */
- SESSION_LISTING /* IMSG_MBOX_LIST sent, awaiting the
- * IMSG_MBOX_LIST_ITEM stream + terminal
- * IMSG_MBOX_RESULT -- see list_dispatch()/
- * session_finish_list(). Same per-item-stream-
- * then-terminal-result shape as SESSION_
- * SEARCHING/COPYING, so it's handled by the
- * same session_handle_mbox_result() entry
- * point too, branching to session_finish_
- * list() first. */
-};
-
-/*
- * Generous line-length cap for the raw CRLF-delimited read buffer below.
- * RFC 9051 doesn't mandate a specific limit -- SS7.1.3's "* BAD Command
- * line too long" is example text, not a normative value -- but any real
- * server needs one to bound memory for a client that never sends CRLF.
- * Revisit once IMAP literals ({n}-prefixed octet counts, SS4.3) are
- * implemented: those need a different, larger mechanism than a flat line
- * buffer, since a literal's byte count is part of the command syntax
- * itself, not just "a longer line".
- */
-#define SESSION_INBUF_MAX 8192
-
-/*
- * Generous bound for a client-chosen tag we need to remember across an
- * async IMSG_AUTH_REQUEST/IMSG_AUTH_RESULT (and, on success, IMSG_STORE_
- * FORK/IMSG_SETUP_PEER) round trip -- RFC 9051 SS9 doesn't specify a tag
- * length limit (`tag = 1*<any ASTRING-CHAR except "+">`), so this is the
- * same kind of deliberate, flagged simplification as session_reply()'s
- * 512-byte response buffer: truncated via strlcpy() rather than rejected,
- * matching this file's existing truncate-rather-than-overflow style.
- */
-#define IMAP_TAG_MAX 64
-
-/*
- * Cap on the verbatim label text (e.g. "HEADER.FIELDS (DATE FROM)" or
- * "HEADER.FIELDS.NOT (X-SPAM-STATUS)") stashed on struct session's
- * pending_header_label and echoed back into the FETCH response's
- * "BODY[<label>]" -- see that field's own comment for the full
- * reasoning. Defined up here (rather than down near parse_fetch_atts(),
- * where it's actually used) because struct session, just below,
- * declares a fixed array of this size -- needs to be visible before that
- * point. Never crosses the imsg boundary (listener.c has this text
- * straight from the client's own command line), so this is a
- * listener.c-local constant, not a shared imapd.h one like HEADER_
- * FIELDS_MAX. Sized generously above HEADER_FIELDS_MAX (256, imapd.h)
- * to cover the "HEADER.FIELDS.NOT (...)" wrapper text around whatever
- * field-name list already fits under that cap.
- */
-#define HEADER_FIELDS_LABEL_MAX 288
-
-/* One RFC 7162 VANISHED (EARLIER) range -- see struct session's vanished_
- * ranges comment and format_range_list(). Named (not anonymous) so format_
- * range_list() can take it as a parameter type. */
-struct vanished_range {
- uint32_t lo;
- uint32_t hi;
-};
-
-struct session {
- uint32_t id;
- int client_fd;
- struct event client_ev;
- enum session_state state;
- int implicit_tls; /* accepted on port 993, per
- * RFC 8314 -- vs. port 143,
- * where TLS only starts after
- * STARTTLS */
- struct imsgev *store_iev; /* NULL until STORE_PENDING
- * resolves */
- int client_ev_added; /* client_ev is only safe to
- * event_del() once this is
- * set -- see listener_accept()'s
- * implicit-TLS early-teardown
- * path, which never reaches
- * the event_set()/event_add()
- * call below it. */
- int tls_active; /* 1 once a real TLS handshake
- * (implicit-TLS accept or
- * STARTTLS) has completed --
- * threaded through CAPABILITY's
- * state-appropriate response and
- * AUTHENTICATE's TLS gate. */
- struct tls *tls_ctx; /* NULL until tls_accept_socket()
- * -- non-NULL during handshake
- * too, not just once tls_active */
- int pending_greeting; /* implicit-TLS only: the
- * greeting can't be sent until
- * after the handshake completes
- * (RFC 8314 -- no protocol bytes
- * before TLS), so listener_accept()
- * defers it and session_tls_
- * handshake() sends it on success */
- char inbuf[SESSION_INBUF_MAX];
- size_t inbuflen; /* bytes of unparsed input
- * currently in inbuf */
- int auth_cont; /* 1 while the next raw client
- * line is a SASL continuation-
- * response (base64 or "*"), not
- * a fresh tagged command -- set
- * by cmd_authenticate() when the
- * client sends bare "AUTHENTICATE
- * PLAIN" with no inline initial
- * response, cleared by session_
- * handle_auth_continuation() as
- * soon as that line arrives. */
- int idling; /* 1 while the next raw client
- * line is IDLE's "DONE"
- * continuation, not a fresh
- * tagged command -- set by
- * cmd_idle(), cleared by
- * session_handle_idle_
- * continuation() (RFC 9051
- * SS6.3.13). Same shape as
- * auth_cont above, checked
- * second in session_dispatch_
- * client()'s read loop. */
- uid_t uid; /* set once, in session_request_
- * store(), from the same imsg_
- * auth_result the store-fork
- * request itself uses -- exists
- * purely so session_notify_
- * idle_peers() can find this
- * session's *other* concurrent
- * sessions (same user, v1 has
- * no shared mailboxes so that's
- * the only case that can ever
- * matter) without a second round
- * trip through auth just to ask
- * "whose session is this." */
- char pending_tag[IMAP_TAG_MAX]; /* copy of whichever
- * command's tag is waiting on an
- * async imsg round trip right now
- * (AUTHENTICATE -> auth, or SELECT
- * -> store) -- tag itself is a
- * pointer into inbuf, which gets
- * overwritten by the next read()
- * long before IMSG_AUTH_RESULT (or
- * the later IMSG_SETUP_PEER/
- * IMSG_STORE_FORK store-spawn
- * reply, or IMSG_MBOX_SELECTED)
- * comes back, so it has to be
- * saved out. Only ever one such
- * round trip in flight at a time
- * per session (SESSION_
- * AUTHENTICATING/STORE_PENDING/
- * SELECTING/FETCHING are all
- * mutually exclusive states), so
- * one field is enough. */
- uint32_t fetch_attrs; /* MBOX_FETCH_* bitmask for
- * the FETCH currently in flight
- * (SESSION_FETCHING) -- needed
- * because struct imsg_mbox_
- * fetch_meta's fields (uid,
- * size, ...) are always
- * populated by store regardless
- * of what was actually
- * requested, so session_send_
- * fetch_response() needs to
- * know which ones to print. */
- char *pending_header_buf; /* malloc(3)'d raw header
- * bytes from the most recent
- * IMSG_MBOX_FETCH_HEADER, held
- * here until the very next
- * IMSG_MBOX_FETCH_META (same
- * message, always sent
- * immediately after -- see that
- * struct's comment in
- * imapd.h) folds it into one
- * FETCH response line; freed and
- * NULL'd there. Never allocated
- * for more than one message at a
- * time -- store.c's ordering
- * contract means this is never
- * still set when the next
- * IMSG_MBOX_FETCH_HEADER
- * arrives, but session_teardown()
- * frees it defensively anyway in
- * case a FETCH is torn down
- * mid-stream. */
- uint32_t pending_header_len; /* valid only alongside
- * pending_header_buf != NULL */
- int pending_header_found; /* 0 -- this message's
- * header couldn't be read; 1
- * -- pending_header_buf/_len are
- * valid. Distinguishes "BODY[HEADER]
- * was requested but this message
- * has none to give" from "BODY[HEADER]
- * wasn't requested at all", the
- * latter never populating this or
- * pending_header_buf in the first
- * place. */
- char pending_header_label[HEADER_FIELDS_LABEL_MAX];
- /* verbatim client-typed section-spec
- * text -- "HEADER" for plain BODY.PEEK
- * [HEADER], or e.g. "HEADER.FIELDS
- * (DATE FROM)" for that variant --
- * echoed back as-is in the FETCH
- * response's "BODY[<label>]" rather than
- * reconstructed, since RFC 9051 doesn't
- * mandate an exact echo format and
- * verbatim is simplest and trivially
- * correct. Set once in fetch_dispatch(),
- * read by session_send_fetch_response()
- * -- never needs its own free()/reset
- * since it's a fixed-size buffer, not a
- * malloc(3)'d pointer like pending_
- * header_buf. */
- char *pending_body_buf; /* same stash-until-
- * the-next-IMSG_MBOX_FETCH_META
- * shape as pending_header_buf above,
- * just for IMSG_MBOX_FETCH_BODY
- * (BODY.PEEK[]/BODY.PEEK[TEXT])
- * instead -- see struct imsg_mbox_
- * fetch_body's comment in
- * imapd.h. Freed and NULL'd by
- * session_send_fetch_response();
- * session_teardown() frees it
- * defensively too, same reasoning as
- * pending_header_buf. */
- uint32_t pending_body_len; /* valid only alongside
- * pending_body_buf != NULL */
- int pending_body_found; /* same found/not-found
- * distinction as pending_header_
- * found, for BODY.PEEK[]/
- * BODY.PEEK[TEXT] */
- char pending_body_label[SECTION_PART_MAX];
- /* verbatim section text for the
- * "BODY[<label>]" response --
- * "" for BODY.PEEK[] (whole
- * message), "TEXT" for BODY.PEEK
- * [TEXT], or the client-typed
- * dotted-numeric path (e.g. "3.1")
- * for BODY.PEEK[<section-part>] --
- * same "precompute the full
- * verbatim label at dispatch time"
- * idiom as pending_header_label,
- * unifying what used to be a
- * pending_body_is_text boolean
- * (adequate when only WHOLE/TEXT
- * existed, not once a third,
- * client-supplied-text variant --
- * PART -- joined them). Set once in
- * fetch_dispatch() from whichever of
- * MBOX_FETCH_BODY_WHOLE/_TEXT/_PART
- * attrs selects (same WHOLE > TEXT >
- * PART precedence store.c's
- * handle_mbox_fetch() applies --
- * see that function's comment),
- * read by session_send_fetch_
- * response(). */
- int pending_body_has_partial; /* 1 if the
- * client's own BODY.PEEK[...]
- * token carried a <<start.count>>
- * suffix (RFC 9051 SS6.4.5) for
- * whichever of WHOLE/TEXT/PART was
- * actually selected -- see struct
- * imsg_mbox_fetch's has_partial
- * comment in imapd.h. Only
- * pending_body_partial_origin (the
- * *requested* start octet, not
- * store.c's own bodylen -- SS6.4.5:
- * "The origin octet facility MUST
- * NOT be used ... unless the client
- * specifically requested it", and
- * the response echoes only the
- * origin, never the count) is echoed
- * back; set alongside pending_body_
- * label in fetch_dispatch(). */
- uint32_t pending_body_partial_origin;
- char *pending_envelope_buf; /* same stash-
- * until-the-next-IMSG_MBOX_
- * FETCH_META shape as pending_
- * header_buf/pending_body_buf
- * above, for IMSG_MBOX_FETCH_
- * ENVELOPE -- see struct imsg_
- * mbox_fetch_envelope's comment
- * in imapd.h. Unlike those
- * two, holds already-formatted
- * "(...)" envelope response
- * text, not raw message bytes,
- * so session_send_fetch_
- * response() writes it directly
- * rather than wrapping it in a
- * `{n}` literal. Freed and
- * NULL'd by session_send_fetch_
- * response(); session_teardown()
- * frees it defensively too, same
- * reasoning as pending_header_buf. */
- uint32_t pending_envelope_len; /* valid only
- * alongside pending_envelope_buf
- * != NULL */
- int pending_envelope_found; /* same found/
- * not-found distinction as
- * pending_header_found, for
- * ENVELOPE */
- char *pending_bodystructure_buf; /* same stash-
- * until-the-next-IMSG_MBOX_
- * FETCH_META shape, same
- * already-formatted-text shape,
- * as pending_envelope_buf above,
- * for IMSG_MBOX_FETCH_
- * BODYSTRUCTURE -- see struct
- * imsg_mbox_fetch_bodystructure's
- * comment in imapd.h. */
- uint32_t pending_bodystructure_len; /* valid only
- * alongside pending_
- * bodystructure_buf != NULL */
- int pending_bodystructure_found; /* same found/
- * not-found distinction as
- * pending_header_found, for
- * BODYSTRUCTURE */
- char pending_bodystructure_label[16]; /*
- * "BODY" or "BODYSTRUCTURE" --
- * RFC 9051 SS9's msg-att-static:
- * `"BODY" ["STRUCTURE"] SP body`
- * means the response label
- * itself echoes whichever bare
- * token the client used (both
- * set the same MBOX_FETCH_
- * BODYSTRUCTURE bit and produce
- * identical body text in this
- * implementation -- see that
- * bit's imapd.h comment --
- * but the label still has to
- * match what was asked for).
- * Set once in fetch_dispatch(),
- * read by session_send_fetch_
- * response() -- same fixed-
- * buffer, no-free-needed shape
- * as pending_header_label. */
- int close_after_expunge; /* 1 if the in-flight
- * SESSION_EXPUNGING round trip
- * was started by CLOSE, not a
- * real EXPUNGE command -- both
- * send the identical IMSG_MBOX_
- * EXPUNGE (CLOSE with silent=1)
- * and get back the identical
- * IMSG_MBOX_RESULT, so this is
- * the only way session_handle_
- * mbox_result() can tell which
- * tagged completion text/next
- * state applies: CLOSE goes to
- * SESSION_AUTHENTICATED, a real
- * EXPUNGE goes back to SESSION_
- * SELECTED. */
- int literal_pending; /* 1 while the next bytes off
- * the wire are a client
- * literal's raw octets (RFC 9051
- * SS4.3), not a CRLF-delimited
- * line -- set by cmd_append()
- * when it finds a trailing
- * "{n}"/"{n+}" on the APPEND
- * command line, cleared once
- * literal_buf_len reaches
- * literal_len. Checked at the
- * very top of session_dispatch_
- * client()'s read loop, *before*
- * the normal CRLF search -- so
- * unlike every async-imsg-wait
- * state above, this needs no
- * ST_* dispatch-table exclusion:
- * while it's set, session_
- * handle_line() is never reached
- * at all, regardless of s->state,
- * so no new complete "tag SP
- * command CRLF" can ever be
- * mis-parsed out of literal
- * bytes. */
- char *literal_buf; /* malloc(3)'d accumulator for
- * the literal currently being
- * read, literal_len bytes, freed
- * once handed off to session_
- * finish_append() (or on early
- * teardown -- see session_
- * teardown()) */
- uint64_t literal_len; /* total announced literal size
- * (what "{n}" both this and
- * literal_remaining start from) */
- uint64_t literal_remaining; /* bytes of the literal still
- * needed -- literal_len minus
- * however much has landed in
- * literal_buf so far, across
- * possibly many reads */
- char append_mailbox[MBOX_NAME_MAX]; /* APPEND's
- * arguments, parsed by
- * cmd_append() *before* the
- * literal is read (mailbox/
- * flags/date all precede the
- * literal in RFC 9051 SS6.3.12's
- * grammar) and held here across
- * the literal-read phase until
- * session_finish_append() needs
- * them to build IMSG_MBOX_
- * APPEND */
- uint32_t append_sysflags;
- char append_keywords[MBOX_FLAGS_MAX];
- int append_has_date;
- int64_t append_date;
- enum session_state append_prev_state; /* SESSION_AUTHENTICATED or
- * SESSION_SELECTED -- whichever
- * s->state was when cmd_append()
- * was first called (APPEND is
- * valid in either, RFC 9051
- * command-auth), so session_
- * handle_mbox_appended() knows
- * which one to restore once
- * IMSG_MBOX_APPENDED arrives --
- * unlike FETCH/STORE/EXPUNGE,
- * which are only ever dispatched
- * from (and so only ever return
- * to) SESSION_SELECTED, APPEND's
- * "go back to" state isn't
- * fixed. */
- uint32_t status_attrs; /* STATUS_ATT_* bitmask for the
- * STATUS currently in flight
- * (SESSION_STATUSING) -- which
- * status-att values to include
- * in the response, in listener.c's
- * fixed canonical order; store.c
- * always computes MESSAGES/
- * UIDNEXT/UIDVALIDITY/
- * HIGHESTMODSEQ regardless (see
- * imapd.h's imsg_mbox_status
- * comment), same "store always
- * computes, listener decides
- * whether to print" split as
- * s->fetch_attrs. */
- char status_mailbox[MBOX_NAME_MAX]; /* the mailbox
- * name STATUS was asked about
- * (RFC 9051 SS6.3.4-SS6.3.6's
- * flat multi-mailbox support --
- * before that, STATUS could only
- * ever mean INBOX, so this field
- * didn't need to exist) --
- * stashed here so session_
- * handle_mbox_status_result() can
- * echo it back in the untagged
- * "* STATUS <mailbox> (...)"
- * response; store.c's own reply
- * (struct imsg_mbox_status_
- * result) has no mailbox field of
- * its own to read it back from,
- * since listener.c already knows
- * what it asked. */
- enum session_state status_prev_state; /* SESSION_AUTHENTICATED or
- * SESSION_SELECTED -- same
- * "STATUS is command-auth, valid
- * in either state" reasoning as
- * s->append_prev_state above, and
- * for the identical reason:
- * session_handle_mbox_status_
- * result() needs to know which
- * one to restore. */
- enum session_state mbox_op_prev_state; /* SESSION_AUTHENTICATED or
- * SESSION_SELECTED -- same
- * "record whichever ST_AUTH
- * state was current before,
- * restore it after" pattern as
- * s->status_prev_state just
- * above, shared across all four
- * of SESSION_CREATING/DELETING/
- * RENAMING/LISTING (see that enum
- * block's own comment) since only
- * one of the four can ever be in
- * flight at once for a given
- * session. session_finish_mbox_
- * op()/session_finish_list() both
- * restore it. */
- char list_pattern[2 * MBOX_NAME_MAX]; /* canonical
- * LIST/LSUB pattern (reference
- * concatenated with the mailbox
- * pattern by list_dispatch(),
- * same construction the old
- * synchronous 'canon' local
- * used) stashed across the
- * SESSION_LISTING round trip so
- * session_handle_mbox_list_
- * item() can test each IMSG_
- * MBOX_LIST_ITEM name against it
- * as it streams in, one at a
- * time -- unlike s->search_
- * matches, nothing needs to be
- * accumulated first, since each
- * match can be turned into its
- * untagged response
- * immediately. */
- int list_is_lsub; /* 1 if the in-flight SESSION_
- * LISTING round trip was started
- * by LSUB rather than LIST --
- * picks the untagged response
- * keyword and the tagged
- * completion text, same is_lsub
- * parameter list_dispatch()
- * already threads through
- * synchronously today. */
- char rename_oldname[MBOX_NAME_MAX]; /* RENAME's
- * two arguments, stashed by
- * cmd_rename() across the
- * SESSION_RENAMING round trip
- * purely so session_finish_
- * mbox_op() can compare
- * rename_oldname against s->
- * selected_mailbox on success --
- * if this session had the
- * just-renamed mailbox itself
- * selected, its own s->selected_
- * mailbox needs to follow along
- * to rename_newname, the same
- * way store.c's handle_mbox_
- * rename() already makes its own
- * cwd/current_mailbox_dir follow
- * (see that function's own real-
- * hardware-bug comment). Neither
- * name is otherwise needed here
- * -- req.oldname/req.newname
- * already made the trip to
- * store.c in the imsg itself. */
- char rename_newname[MBOX_NAME_MAX];
- uint32_t search_return_opts; /* SEARCH_RETURN_* bitmask
- * for the SEARCH currently in
- * flight (SESSION_SEARCHING) --
- * which of MIN/MAX/ALL/COUNT
- * session_finish_search() should
- * include in the ESEARCH
- * response; store.c never needs
- * this, since it only computes
- * *which* messages match, not
- * how the client wants that
- * reported. */
- uint32_t *search_matches; /* malloc(3)'d/realloc(3)'d,
- * growable -- ascending sequence
- * numbers streamed in via
- * IMSG_MBOX_SEARCH_MATCH, kept
- * in full (not range-compacted
- * until formatting time) so
- * MIN/MAX/COUNT/ALL can all be
- * derived from one array. Freed
- * by session_finish_search() once
- * the ESEARCH response is sent,
- * or by session_teardown() on
- * early disconnect. */
- uint32_t search_nmatches; /* entries actually in
- * search_matches */
- uint32_t search_matches_cap; /* allocated capacity of
- * search_matches, >= search_
- * nmatches */
- int search_alloc_failed; /* 1 if a realloc(3) in
- * session_handle_mbox_search_
- * match() ever failed for this
- * SEARCH -- makes session_
- * finish_search() send a NO
- * instead of a silently
- * incomplete ESEARCH response;
- * see that function's comment on
- * why "some matches dropped" is
- * treated as a hard failure, not
- * a best-effort partial result. */
- int search_used_modseq; /* 1 if the SEARCH program
- * currently in flight
- * contained a MODSEQ
- * search-key -- RFC 7162
- * SS3.1.5/SS3.1.8: makes
- * this SEARCH a CONDSTORE-
- * enabling command, and
- * makes session_finish_
- * search() append "(MODSEQ
- * n)" using the running
- * max below. */
- uint64_t search_max_modseq; /* highest modseq seen
- * across all matches of
- * the SEARCH currently in
- * flight, tracked by
- * session_handle_mbox_
- * search_match() --
- * store.c always sends
- * modseq per match (cheap,
- * see imapd.h's struct
- * imsg_mbox_search_match
- * comment), so this is
- * just a running max, not
- * conditional on search_
- * used_modseq itself. */
+#include "listener.h"
- /*
- * RFC 7162 (CONDSTORE/QRESYNC) session state, added this pass.
- * condstore_enabled/qresync_enabled are both sticky for the whole
- * connection once set (SS3.1: "Once a client issues a CONDSTORE
- * enabling command, it has announced itself... until the connection
- * is closed"; SS3.2.3 says the same for QRESYNC) -- never cleared
- * back to 0 anywhere in this file. qresync_enabled implies
- * condstore_enabled is also 1 (SS3.2.3: "the presence of the
- * 'QRESYNC' capability implies support for the CONDSTORE IMAP
- * extension"), enforced at every place qresync_enabled gets set.
- */
- int condstore_enabled;
- int qresync_enabled;
-
- /*
- * Best-known HIGHESTMODSEQ of the currently selected mailbox --
- * cached from every IMSG_MBOX_SELECTED/IMSG_MBOX_RESULT reply that
- * carries one (store.c always populates it; see imapd.h's
- * imsg_mbox_selected/imsg_mbox_result comments), regardless of
- * whether this session is CONDSTORE-aware yet. Exists specifically
- * for session_condstore_enable()'s "CONDSTORE enabling command
- * issued while a mailbox is already selected" case (RFC 7162 SS3.1:
- * the server MUST emit an unsolicited HIGHESTMODSEQ OK response at
- * that moment) -- without a cached value there would be nothing to
- * report without an extra store round trip just for this edge case.
- */
- uint64_t mbox_highestmodseq;
-
- /*
- * RFC 9051 SS6.3.3: whether the currently selected mailbox was
- * opened via EXAMINE (1) rather than SELECT (0) -- meaningful only
- * while s->state == SESSION_SELECTED (and, transiently, SESSION_
- * SELECTING/SESSION_EXPUNGING while a select_or_examine()/session_
- * request_expunge() round trip is in flight). Set synchronously by
- * select_or_examine() before the IMSG_MBOX_SELECT round trip even
- * starts -- like s->state = SESSION_SELECTING itself, this doesn't
- * need to wait for store.c's reply, since it's derived purely from
- * which command the client typed, not from anything store.c knows.
- * Consulted by session_handle_mbox_selected() (README/OK response
- * code, PERMANENTFLAGS) and by every command that would otherwise
- * mutate the selected mailbox's permanent state -- store_do(),
- * session_request_expunge(), copy_move_dispatch()'s is_move case --
- * to refuse (or, for CLOSE specifically, silently no-op) per SS6.3.3
- * ("No changes to the permanent state of the mailbox... are
- * permitted") and SS6.4.1's CLOSE exception.
- */
- int mbox_readonly;
- char selected_mailbox[MBOX_NAME_MAX]; /* which
- * mailbox SESSION_SELECTED actually
- * refers to -- didn't need to exist
- * before RFC 9051 SS6.3.4-SS6.3.6's flat
- * multi-mailbox support (docs/openimap-
- * storage-backend.md item 10), since
- * "selected" and "INBOX is selected"
- * were the same fact. Written
- * optimistically by select_or_examine()
- * at the same point s->state moves to
- * SESSION_SELECTING -- safe even though
- * the SELECT/EXAMINE might still fail,
- * because every reader of this field
- * also gates on s->state ==
- * SESSION_SELECTED first, and a failed
- * SELECT/EXAMINE never reaches that
- * state (RFC 9051 SS6.3.2: "the session
- * is in authenticated state" on
- * failure, deselected same as before
- * the attempt). Consulted so far by
- * session_handle_mbox_appended()'s own
- * "is the mailbox APPEND just targeted
- * the one actually selected right now"
- * check -- see that function's
- * comment. */
-
- /*
- * RFC 9051 SS6.3.13 (IDLE) v1 scope: EXISTS and EXPUNGE only (see
- * imapd.h's imsg_mbox_idle_uid comment for the sourcing/reasoning
- * behind not also pushing unsolicited FETCH). idle_known_uids is this
- * session's own cached, ordered snapshot of "which UIDs existed as of
- * the last time this session's view was authoritative" -- populated
- * from an IMSG_MBOX_IDLE_REFRESH round trip, either the baseline one
- * cmd_idle() triggers right after sending "+ idling" (idle_baseline_
- * valid is 0 until that first one lands, so session_handle_idle_
- * refreshed() knows not to diff against garbage), or a later one
- * triggered by session_notify_idle_peers() when some *other* session
- * belonging to the same uid successfully mutates the mailbox.
- * Diffing this array against a freshly streamed IMSG_MBOX_IDLE_UID
- * list is what lets session_handle_idle_refreshed() compute correct
- * seqno-based "* N EXPUNGE" lines for whatever UIDs dropped out --
- * same "position in the shrinking list, removed in ascending order"
- * algorithm handle_mbox_expunge() already needs on the store side,
- * just recomputed here in listener.c from two UID arrays instead of
- * from the index file directly.
- *
- * idle_refresh_pending/idle_refresh_again exist because a refresh is
- * itself an async imsg round trip: if a second trigger arrives while
- * one is already in flight for this session (e.g. two sibling
- * sessions both mutate in quick succession), there's no way to ask
- * store.c to hurry up or to safely start a second overlapping
- * request on the same store_iev channel -- idle_refresh_again just
- * remembers to immediately re-request once the in-flight one's
- * IMSG_MBOX_IDLE_REFRESHED reply lands, rather than either blocking
- * or silently dropping the second trigger.
- */
- uint32_t *idle_known_uids;
- uint32_t idle_known_nuids;
- uint32_t idle_known_cap;
- int idle_baseline_valid;
- int idle_refresh_pending;
- int idle_refresh_again;
-
- /*
- * Accumulates the freshly streamed IMSG_MBOX_IDLE_UID list for an
- * in-flight refresh -- deliberately a *separate* array from idle_
- * known_uids above, not built in place over it, since session_
- * handle_idle_refreshed() needs both the old and the new list at
- * once to diff them. Same "held back until the terminal reply"
- * shape as s->vanished_ranges/s->qresync_fetches during a QRESYNC
- * SELECT resync.
- */
- uint32_t *idle_incoming_uids;
- uint32_t idle_incoming_n;
- uint32_t idle_incoming_cap;
-
- /*
- * Accumulates VANISHED (EARLIER) ranges streamed in via zero or more
- * IMSG_MBOX_SELECT_VANISHED during an in-flight QRESYNC SELECT
- * resync (s->state == SESSION_SELECTING) -- store.c already reports
- * these pre-compacted into ranges (see imsg_mbox_select_vanished's
- * comment in imapd.h), so this array just collects them in
- * arrival order (already ascending -- store.c streams its single
- * forward pass in increasing-UID order) for session_handle_mbox_
- * selected() to join into one combined "* VANISHED (EARLIER) ..."
- * line once IMSG_MBOX_SELECTED arrives -- RFC 7162 SS3.2.6 requires
- * every VANISHED (EARLIER) response to precede any FETCH response
- * in the same resync, which is only guaranteed by holding all of it
- * (and the FETCH data below) until the terminal reply, not by
- * relaying each piece to the client as it streams in.
- */
- struct vanished_range *vanished_ranges;
- uint32_t vanished_nranges;
- uint32_t vanished_cap;
-
- /*
- * Accumulates the QRESYNC resync's per-message FETCH-with-UID-and-
- * MODSEQ data, streamed in via IMSG_MBOX_FETCH_META while s->state
- * == SESSION_SELECTING (see session_store_dispatch()'s IMSG_MBOX_
- * FETCH_META case) -- held back for the same "VANISHED must precede
- * FETCH" ordering reason vanished_ranges is. Holds full struct
- * imsg_mbox_fetch_meta copies (not just seqno/uid, unlike search_
- * matches) because session_send_qresync_fetch_response() needs
- * FLAGS too -- heavier per-entry than search_matches, but v1-scale
- * resyncs are small (this project's usual "personal use, modest
- * mailbox size" tolerance).
- */
- struct imsg_mbox_fetch_meta *qresync_fetches;
- uint32_t qresync_nfetches;
- uint32_t qresync_fetches_cap;
-
- /*
- * COPY/MOVE (RFC 9051 SS6.4.7/SS6.4.8), SESSION_COPYING. Two parallel
- * growable arrays accumulating each IMSG_MBOX_COPY_MAPPING streamed
- * in during the round trip -- src_uid/dest_uid pairs, already
- * ascending (store.c streams its single forward pass in increasing
- * order, same guarantee vanished_ranges above relies on), range-
- * compacted independently via format_seq_list() into COPYUID's two
- * UID sets once the terminal reply arrives. cmd_is_move distinguishes
- * which of COPY/MOVE is in flight (same session-state-plus-flag
- * pattern as s->close_after_expunge for EXPUNGE/CLOSE) -- both share
- * SESSION_COPYING since their in-flight bookkeeping is otherwise
- * identical, differing only in the terminal formatting (see session_
- * finish_copy_or_move()). move_expunged buffers each IMSG_MBOX_
- * EXPUNGED that arrives during a MOVE specifically (rather than
- * writing it to the client immediately, the way a real EXPUNGE/CLOSE
- * does) -- SS6.4.8 requires COPYUID to precede any EXPUNGE/VANISHED
- * for the same operation, which (like vanished_ranges/qresync_fetches
- * above) is only guaranteed by holding everything until the terminal
- * reply, not by relaying each piece as it streams in.
- */
- uint32_t *copy_src_uids;
- uint32_t *copy_dest_uids;
- uint32_t copy_n;
- uint32_t copy_cap;
- int copy_alloc_failed; /* same "don't silently drop
- * a mapping" reasoning as
- * s->search_alloc_failed */
- int cmd_is_move;
- struct imsg_mbox_expunged *move_expunged;
- uint32_t move_expunged_n;
- uint32_t move_expunged_cap;
-
- /*
- * Accumulates messages that failed a STORE's UNCHANGEDSINCE test,
- * streamed in via zero or more IMSG_MBOX_STORE_MODIFIED while
- * s->state == SESSION_STORING, for session_handle_mbox_result() to
- * range-compact (format_seq_list(), same helper SEARCH/ESEARCH
- * already use) into the tagged response's MODIFIED response code
- * (RFC 7162 SS3.1.3). Holds sequence numbers for a plain STORE, UIDs
- * for a UID STORE (SS3.1.3: "the message number (or unique
- * identifier in the case of the UID STORE command)") -- a single
- * array, not a parallel uid array, since exactly one of the two
- * values is ever meaningful for a given STORE; see session_handle_
- * store_modified()'s use of s->cmd_by_uid to pick which.
- */
- uint32_t *store_modified;
- uint32_t store_modified_n;
- uint32_t store_modified_cap;
-
- /*
- * RFC 9051 SS6.4.9 (UID command), added this pass: 1 if the async
- * operation currently in flight (SESSION_FETCHING/STORING/SEARCHING/
- * EXPUNGING) was dispatched via UID <cmd> rather than the bare
- * command. Every entry point that starts one of those four states
- * sets this explicitly (fetch_dispatch()/store_do()/search_dispatch()/
- * session_request_expunge()), so a stale value from a previous,
- * different-mode command can never leak into the next one -- there is
- * no "clear it back to 0" step anywhere else, matching this struct's
- * existing condstore_enabled/qresync_enabled precedent of "every
- * setter is unconditional, so no separate reset is needed."
- *
- * Consulted by: session_send_store_fetch_response() (UID STORE's
- * FETCH echo must include UID, SS6.4.9's "MUST implicitly include
- * the UID message data item"), session_handle_mbox_search_match()/
- * session_finish_search() (UID SEARCH reports UIDs instead of
- * sequence numbers, plus the "UID" ESEARCH correlator token),
- * session_handle_store_modified() (UID STORE's MODIFIED response
- * code lists UIDs, not sequence numbers -- RFC 7162 SS3.1.3: "the
- * message number (or unique identifier in the case of the UID STORE
- * command)"), and session_handle_mbox_result() (every one of the four
- * commands' tagged completion text becomes "UID <CMD> completed" --
- * RFC 9051's own SS6.4.9 example shows "UID FETCH completed"
- * verbatim; UID EXPUNGE's own worked example shows "UID EXPUNGE
- * completed"; UID STORE/UID SEARCH aren't shown as literal worked
- * examples but follow the same "UID <cmd> completed" pattern by
- * direct symmetry with those two, not fabricated). Plain FETCH
- * doesn't need to consult this at all -- forcing MBOX_FETCH_UID into
- * s->fetch_attrs at dispatch time (see fetch_dispatch()) already
- * makes session_send_fetch_response()'s existing attrs-driven UID
- * printing do the right thing with no further changes there.
- */
- int cmd_by_uid;
-
- TAILQ_ENTRY(session) entry;
-};
-
-static TAILQ_HEAD(, session) sessions = TAILQ_HEAD_INITIALIZER(sessions);
+struct session_list sessions = TAILQ_HEAD_INITIALIZER(sessions);
static uint32_t next_session_id = 1;
-/* see this file's header comment for what each of these actually is */
-static struct imsgev iev_auth;
-static struct imsgev iev_parent;
-/*
- * Dual-stack ("listen on *") support: up to LISTENER_MAX_ADDRS bound
- * sockets per port purpose now, one per resolved address family, instead
- * of exactly one -- see imapd.h's LISTENER_MAX_ADDRS comment for why
- * OpenBSD needs two separate sockets rather than one dual-mapped one.
- * n_cleartext_fd/n_tls_fd (set once, at the end of the boot-time drain
- * loop below) say how many of each array's slots are actually live.
- */
+struct imsgev iev_auth; /* channel to the AUTH process */
+struct imsgev iev_parent; /* fd 3, alive for the process's lifetime */
+
+/* n_cleartext_fd/n_tls_fd say how many of each array's slots are live. */
static int cleartext_fd[LISTENER_MAX_ADDRS] = { -1, -1 };
static int tls_fd[LISTENER_MAX_ADDRS] = { -1, -1 };
static int n_cleartext_fd, n_tls_fd;
static struct event ev_accept_cleartext[LISTENER_MAX_ADDRS];
static struct event ev_accept_tls[LISTENER_MAX_ADDRS];
-/*
- * The server-wide TLS context, built once at boot in listener_main()
- * from the cert/key bytes parent sends over IMSG_TLS_CERT/IMSG_TLS_KEY.
- * tls_server()+tls_configure(), sourced directly against httpd's
- * server_tls_init() (openbsd_source/src/usr.sbin/httpd/server.c) and
- * src/lib/libtls/tls.h. NULL if TLS setup failed (bad cert/key, etc.) --
- * checked before every tls_accept_socket() call rather than treated as
- * fatal, so a cert problem degrades to "no TLS" (implicit-TLS port
- * closes, STARTTLS/AUTHENTICATE keep replying NO) instead of taking the
- * whole daemon down; cleartext CAPABILITY/NOOP/LOGOUT/ID still work.
- *
- * Also rebuilt post-boot, on a live SIGHUP reload -- see listener_reload_
- * tls() below and parent.c's sighup_handler(). tls_accept_socket()
- * (man.openbsd.org/tls_accept_socket.3: "these functions create a new
- * context... and return it in *cctx") gives each already-connected
- * session's own struct tls * (s->tls_ctx), entirely independent of this
- * server-wide pair, so swapping these two pointers here on reload has no
- * effect on sessions already mid-handshake or established -- only new
- * tls_accept_socket() calls from this point on see the new cert/key.
- */
static struct tls_config *listener_tls_config;
-static struct tls *listener_tls_ctx;
+struct tls *listener_tls_ctx; /* NULL if TLS setup failed -- degrades to no-TLS, not fatal */
-/* Matches parent.c's send_tls_certs() read buffer size -- see that
- * function's comment for the (flagged, unsolved) chain-size limitation
- * this shares. Moved up from just before listener_main() (its original,
- * boot-only location) since the SIGHUP reload staging buffers just below
- * need these two constants as well now. */
+/* Matches parent.c's send_tls_certs() read buffer size. */
#define TLS_CERT_MAX 8192
#define TLS_KEY_MAX 8192
-/*
- * Staging area for a SIGHUP reload's IMSG_TLS_CERT/IMSG_TLS_KEY bytes,
- * received post-boot on the same parent channel (iev_parent) as the
- * ongoing IMSG_STORE_FORK/IMSG_SETUP_PEER traffic listener_dispatch_
- * parent() already handles. Parent sends these back to back (send_tls_
- * certs(), unchanged since boot) but each arrives as its own imsg, and
- * nothing forces both to land in the same event-loop dispatch call --
- * these persist across calls the same way got_cert/got_key are local to
- * listener_main()'s one-shot boot drain loop, except here as file-scope
- * statics since the reload path runs repeatedly, from the event loop.
- * listener_reload_tls() is only invoked once both have arrived; then both
- * flags reset so a later reload starts clean.
- */
+/* SIGHUP reload staging; listener_reload_tls() fires once both flags are set. */
static char reload_cert_buf[TLS_CERT_MAX], reload_key_buf[TLS_KEY_MAX];
static size_t reload_cert_len, reload_key_len;
static int reload_got_cert, reload_got_key;
-static void listener_accept(int, short, void *);
-static void listener_dispatch_auth(int, short, void *);
-static void listener_dispatch_parent(int, short, void *);
-static void listener_reload_tls(const char *, size_t, const char *,
- size_t);
-static void session_dispatch_client(int, short, void *);
-static void session_store_dispatch(int, short, void *);
-static void session_handle_mbox_selected(struct session *,
- struct imsg_mbox_selected *);
-static void session_handle_mbox_status_result(struct session *,
- struct imsg_mbox_status_result *);
-static void session_send_fetch_response(struct session *,
- struct imsg_mbox_fetch_meta *);
-static void session_send_store_fetch_response(struct session *,
- struct imsg_mbox_fetch_meta *);
-static void session_send_expunge_response(struct session *,
- struct imsg_mbox_expunged *);
-static void session_handle_mbox_result(struct session *,
- struct imsg_mbox_result *);
-static int session_request_expunge(struct session *, const char *,
- int, int, uint32_t, uint32_t, int, int);
-static int session_finish_append(struct session *);
-static void session_handle_mbox_appended(struct session *,
- struct imsg_mbox_appended *);
-static void format_internaldate(int64_t, char *, size_t);
-static int parse_nz_number(const char *, uint32_t *);
-static int parse_seq_range(const char *, uint32_t *, uint32_t *,
- int *, int *);
-static int parse_fetch_atts(char *, uint32_t *, int *, int *, char *,
- size_t, char *, size_t, int *, char *, size_t, int *,
- uint32_t *, uint32_t *, const char **);
-static int parse_header_fields_att(const char *, int *, char *, size_t);
-static int section_part_valid(const char *);
-static int parse_partial_suffix(const char *, int *, uint32_t *,
- uint32_t *);
-static int parse_store_flags(char *, uint32_t *, char *, size_t,
- const char **);
-static int parse_date_time(const char *, int64_t *);
-struct append_parsed;
-static int parse_append_args(char *, struct append_parsed *,
- const char **);
-static int parse_search_date(const char *, int64_t *);
-struct search_parse_ctx;
-static int parse_search_key(char **, struct search_parse_ctx *,
- const char **);
-static int parse_search_key_inner(char **, struct search_parse_ctx *,
- const char **);
-static int parse_search_key_list(char **, struct search_parse_ctx *,
- const char **, int);
-static int parse_search_return_opts(char **, uint32_t *, const char **);
-static void session_handle_mbox_search_match(struct session *,
- struct imsg_mbox_search_match *);
-static void session_finish_search(struct session *,
- struct imsg_mbox_result *);
-static void session_request_store(struct session *,
- struct imsg_auth_result *);
-static void session_send_greeting(struct session *);
-static void session_teardown(struct session *);
-static struct session *session_find(uint32_t);
-
-/* RFC 7162 (CONDSTORE/QRESYNC) helpers, added this pass. */
-static void session_condstore_enable(struct session *);
-static size_t format_seq_list(char *, size_t, const uint32_t *, uint32_t,
- int *);
-static size_t format_range_list(char *, size_t,
- const struct vanished_range *, uint32_t, int *);
-static void session_send_qresync_fetch_response(struct session *,
- struct imsg_mbox_fetch_meta *);
-static void session_handle_select_vanished(struct session *,
- struct imsg_mbox_select_vanished *);
-static void session_handle_select_fetch(struct session *,
- struct imsg_mbox_fetch_meta *);
-static void session_handle_store_modified(struct session *,
- struct imsg_mbox_store_modified *);
-static void session_handle_idle_uid(struct session *,
- struct imsg_mbox_idle_uid *);
-static void session_handle_idle_refreshed(struct session *,
- struct imsg_mbox_idle_refreshed *);
-static void session_request_idle_refresh(struct session *);
-static void session_push_idle_expunges(struct session *,
- const uint32_t *, uint32_t, const uint32_t *, uint32_t);
-static void session_notify_idle_peers(struct session *);
-static int session_handle_idle_continuation(struct session *, char *);
-static int parse_select_params(char *, struct imsg_mbox_select *,
- struct session *, int *, const char **);
-static int parse_qresync_group(char *, struct imsg_mbox_select *,
- const char **);
-static char *split_trailing_modifiers(char *);
-static int parse_fetch_modifiers(char *, struct imsg_mbox_fetch *,
- struct session *, int, int *, const char **);
-static int parse_store_modifiers(char *, struct imsg_mbox_store *,
- const char **);
-
-/* RFC 9051 SS6.4.9 (UID command) helpers, added this pass. */
-static int fetch_dispatch(struct session *, const char *, char *, int);
-static int store_do(struct session *, const char *, char *, int);
-static int search_dispatch(struct session *, const char *, char *, int);
-static int uid_expunge_dispatch(struct session *, const char *, char *);
-static void session_handle_fetch_vanished(struct session *,
- struct imsg_mbox_select_vanished *);
-
-/* RFC 9051 SS6.4.7/SS6.4.8 (COPY/MOVE) helpers, added this pass. */
-static int copy_move_dispatch(struct session *, const char *, char *,
- int, int);
-static int mailbox_name_valid(const char *);
-static int mailbox_name_is_inbox(const char *);
-static int parse_list_token(char **, char *, size_t, const char **);
-static void session_finish_mbox_op(struct session *,
- struct imsg_mbox_result *);
-static void session_finish_list(struct session *,
- struct imsg_mbox_result *);
-static void session_handle_mbox_list_item(struct session *,
- struct imsg_mbox_list_item *);
-static void session_handle_mbox_copy_mapping(struct session *,
- struct imsg_mbox_copy_mapping *);
-static void session_finish_copy_or_move(struct session *,
- struct imsg_mbox_result *);
-
-static void session_tls_start(struct session *);
-static void session_tls_handshake(int, short, void *);
-static void session_arm_client_read(struct session *);
-
-static void session_write(struct session *, const char *, size_t);
-static void session_reply(struct session *, const char *, const char *,
- const char *);
-static void session_untagged(struct session *, const char *);
-static int parse_command_line(char *, char **, char **, char **);
-static int session_handle_line(struct session *, char *);
-static int session_handle_auth_continuation(struct session *, char *);
-static int sasl_plain_finish(struct session *, const char *,
- const char *, int);
-
-static int cmd_capability(struct session *, const char *, char *);
-static int cmd_noop(struct session *, const char *, char *);
-static int cmd_logout(struct session *, const char *, char *);
-static int cmd_id(struct session *, const char *, char *);
-static int cmd_login(struct session *, const char *, char *);
-static int cmd_starttls(struct session *, const char *, char *);
-static int cmd_authenticate(struct session *, const char *, char *);
-
-/* command-auth (RFC 9051 SS6.3 -- valid in Authenticated or Selected
- * state): ENABLE is real (see cmd_enable()'s comment for why that's a
- * complete implementation, not a stub, for v1). Everything else here
- * needs a mailbox layer that doesn't exist yet -- store.c's IMSG_MBOX_*
- * family is entirely unimplemented (see store.c's own header comment),
- * and its wire payload shapes aren't designed, so these all reply a
- * generic NO via stub_not_implemented() rather than doing anything real.
- * They're in the dispatch table anyway, correctly gated by session
- * state, because "recognized command, can't do it right now" (NO) and
- * "not a real IMAP command at all" (BAD, session_handle_line()'s unknown-
- * command fallback) are different, both spec-meaningful responses -- see
- * openimap-v1-dispatch.md's dispatch table for the same distinction. */
-static int stub_not_implemented(struct session *, const char *,
- const char *);
-static int cmd_enable(struct session *, const char *, char *);
-static int cmd_select(struct session *, const char *, char *);
-static int cmd_examine(struct session *, const char *, char *);
-static int cmd_create(struct session *, const char *, char *);
-static int cmd_delete(struct session *, const char *, char *);
-static int cmd_rename(struct session *, const char *, char *);
-static int cmd_subscribe(struct session *, const char *, char *);
-static int cmd_unsubscribe(struct session *, const char *, char *);
-static int cmd_list(struct session *, const char *, char *);
-static int cmd_lsub(struct session *, const char *, char *);
-static int cmd_namespace(struct session *, const char *, char *);
-static int cmd_status(struct session *, const char *, char *);
-static int cmd_append(struct session *, const char *, char *);
-static int cmd_idle(struct session *, const char *, char *);
-
-/* command-select (RFC 9051 SS6.4 -- valid only in Selected state).
- * SESSION_SELECTED is currently unreachable (SELECT itself is a stub
- * above), so none of these can be dispatched to yet in practice -- listed
- * for the same "correctly gated, honestly stubbed" reason as command-auth
- * above. */
-static int cmd_close(struct session *, const char *, char *);
-static int cmd_unselect(struct session *, const char *, char *);
-static int cmd_expunge(struct session *, const char *, char *);
-static int cmd_search(struct session *, const char *, char *);
-static int cmd_fetch(struct session *, const char *, char *);
-static int cmd_store_cmd(struct session *, const char *, char *);
-static int cmd_copy(struct session *, const char *, char *);
-static int cmd_move(struct session *, const char *, char *);
-static int cmd_uid(struct session *, const char *, char *);
-
-/*
- * v1 CAPABILITY strings, originally quoted verbatim from openimap-v1-
- * dispatch.md's "v1 CAPABILITY strings" section (itself sourced against
- * RFC 9051 SS6.1.1 and the IANA IMAP Capabilities Registry). Selected via
- * session->tls_active, which a real STARTTLS/implicit-TLS handshake now
- * sets -- see cmd_starttls() and session_tls_handshake().
- *
- * CONDSTORE and QRESYNC added this pass (RFC 7162). Both listed in both
- * strings, not gated on tls_active/auth state the way AUTH=PLAIN is --
- * SS3.1.1/SS3.2.2 define support for each purely in terms of whether the
- * server returns the token in CAPABILITY at all, with no mention of a TLS
- * or authentication precondition (unlike AUTH=PLAIN, which this server
- * deliberately withholds pre-TLS to keep LOGINDISABLED honest). Listing
- * QRESYNC implies CONDSTORE support too (SS3.2.3), but SS3.2.2 separately
- * "SHOULD"s advertising CONDSTORE explicitly as well for CONDSTORE-only
- * clients, so both tokens are listed rather than relying on the implication
- * alone.
- *
- * IDLE (added this pass, RFC 9051 SS6.3.13) needs no new token in either
- * string: unlike RFC 2177 (where IDLE was an extension gated on an "IDLE"
- * capability token), SS6.3.13 redefines IDLE natively in IMAP4rev2 with no
- * capability-gating language at all -- it's base spec, covered by the
- * existing "IMAP4rev2" token already in both strings.
- */
-#define CAPABILITY_PRE_TLS "IMAP4rev2 STARTTLS LOGINDISABLED ID CONDSTORE QRESYNC"
-#define CAPABILITY_POST_TLS "IMAP4rev2 AUTH=PLAIN ID CONDSTORE QRESYNC"
-
-/*
- * v1 command dispatch table. "any state" (RFC 9051 command-any, plus RFC
- * 2971's ID, which command-any is explicitly extended to include) and
- * "not authenticated state" (command-nonauth) are fully implemented.
- * command-auth and command-select now have real table entries too --
- * ENABLE is a genuine, complete implementation (see cmd_enable()); every
- * other command-auth/command-select entry is an honest stub
- * (stub_not_implemented()) pending store.c's still-undesigned IMSG_MBOX_*
- * wire protocol. See session_handle_line()'s "unknown command" fallback
- * for anything not listed here at all.
- */
struct imap_cmd_entry {
const char *name;
unsigned int states; /* bitmask of 1U << SESSION_* */
(1U << SESSION_LISTING))
#define ST_NOTAUTH (1U << SESSION_NOT_AUTH)
-/*
- * RFC 9051 SS9's `command-auth`/`command-select` ABNF productions, whose
- * inline comments are quoted directly: command-auth is "Valid only in
- * Authenticated or Selected state"; command-select is "Valid only when
- * in Selected state". SESSION_AUTHENTICATING/SESSION_STORE_PENDING (the
- * async window between a successful IMSG_AUTH_RESULT and the client's
- * tagged OK -- see sasl_plain_finish()/session_request_store()) are
- * deliberately excluded from ST_AUTH: the client hasn't been told
- * AUTHENTICATE succeeded yet, so from its point of view it's still in the
- * Not Authenticated state and shouldn't be able to pipeline a command-auth
- * command into that window. SESSION_SELECTING (the same kind of async
- * window, this time between IMSG_MBOX_SELECT and IMSG_MBOX_SELECTED -- see
- * select_or_examine()/session_store_dispatch()) is excluded for the identical
- * reason: the client hasn't been told SELECT succeeded or failed yet.
- * SESSION_FETCHING (IMSG_MBOX_FETCH sent, awaiting the IMSG_MBOX_FETCH_META
- * stream + terminal IMSG_MBOX_RESULT) is excluded from ST_SELECTED for a
- * related but distinct reason: s->pending_tag and s->fetch_attrs belong to
- * the in-flight FETCH until that terminal reply arrives, so a second
- * command-select command dispatched into that window would clobber both
- * before session_handle_mbox_result() gets to use them. SESSION_STORING
- * (IMSG_MBOX_STORE sent) is excluded for the identical reason -- it shares
- * the same s->pending_tag and the same IMSG_MBOX_FETCH_META/IMSG_MBOX_
- * RESULT reply shape as SESSION_FETCHING (see cmd_store_cmd()'s comment).
- * SESSION_EXPUNGING (IMSG_MBOX_EXPUNGE sent, by either EXPUNGE or CLOSE)
- * is excluded for the same reason again -- s->pending_tag and s->close_
- * after_expunge belong to that in-flight request until IMSG_MBOX_RESULT
- * arrives. SESSION_APPENDING (IMSG_MBOX_APPEND sent, once the client's
- * literal has been fully read) is excluded for the same reason -- s->
- * pending_tag and s->append_prev_state belong to that in-flight request
- * until IMSG_MBOX_APPENDED arrives; note this is a *different* window
- * than s->literal_pending (the client-literal-read phase that precedes
- * it), which is intercepted at the raw-byte level in session_dispatch_
- * client() before command dispatch is ever reached at all, and so needs
- * no ST_* exclusion of its own -- see that field's comment. SESSION_
- * SEARCHING (IMSG_MBOX_SEARCH sent) is excluded for the same reason
- * again -- s->pending_tag, s->search_return_opts, and s->search_matches
- * (accumulating across the IMSG_MBOX_SEARCH_MATCH stream) all belong to
- * the in-flight SEARCH until IMSG_MBOX_RESULT arrives. SESSION_STATUSING
- * (IMSG_MBOX_STATUS sent) is excluded from ST_AUTH for the same reason
- * again -- s->pending_tag, s->status_attrs, and s->status_prev_state
- * belong to the in-flight STATUS until IMSG_MBOX_STATUS_RESULT arrives;
- * note STATUS itself is dispatched *from* ST_AUTH (it's valid in either
- * Authenticated or Selected state, RFC 9051 SS6.3.11), so this exclusion
- * only matters for a second command arriving while one STATUS is still
- * outstanding, exactly like SELECTING/SEARCHING/etc. above. SESSION_
- * COPYING (IMSG_MBOX_COPY or IMSG_MBOX_MOVE sent) is excluded from
- * ST_SELECTED for the same reason as SESSION_SEARCHING -- s->pending_tag,
- * s->copy_src_uids/copy_dest_uids, s->cmd_is_move, and (for a MOVE)
- * s->move_expunged all belong to the in-flight COPY/MOVE until IMSG_MBOX_
- * RESULT arrives.
- */
+/* RFC 9051 SS9; transient in-flight states excluded (own pending_tag until their reply arrives). */
#define ST_AUTH \
((1U << SESSION_AUTHENTICATED) | (1U << SESSION_SELECTED))
#define ST_SELECTED (1U << SESSION_SELECTED)
if (imsgbuf_init(&ibuf3, 3) == -1)
fatal("imsgbuf_init");
- imsgbuf_allow_fdpass(&ibuf3); /* this channel receives fd-passed
- * IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS
- * and IMSG_SETUP_PEER messages below
- * -- see imsgev.c's
- * imsgev_init() comment. */
+ imsgbuf_allow_fdpass(&ibuf3); /* receives fd-passed socket/peer messages below */
/* boot-time handshake: one peer (auth), then SETUP_DONE+ack. */
peer_fd = setup_recv_one_peer(&ibuf3);
setup_recv_done_and_ack(&ibuf3);
- /*
- * IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS (one each normally, two each
- * for "listen on *" -- see imapd.h's LISTENER_MAX_ADDRS comment),
- * IMSG_TLS_CERT, IMSG_TLS_KEY, and IMSG_LISTENER_INIT still arrive
- * on fd 3 from parent, sent right after the handshake above (see
- * parent.c's parent_main()) -- read them synchronously here, before
- * entering the event loop, since we need the socket fds to exist
- * before we can event_set() an accept handler on them. ibuf3 itself
- * (not a fresh imsgbuf) is later handed to imsgev_init_from_ibuf()
- * below -- see this file's header comment for why a second
- * imsgbuf_init() on fd 3 would be wrong.
- *
- * How many socket messages to expect isn't known until
- * IMSG_LISTENER_INIT itself arrives (it carries n_cleartext_addrs/
- * n_tls_addrs), so the loop below only checks recv_cleartext/
- * recv_tls against those counts once got_init is true -- the
- * short-circuit "!got_init ||" keeps the loop going regardless of
- * what those still-zeroed counts would otherwise say. Every message
- * type here can arrive in any order relative to the others (parent
- * fires them off back to back with no synchronization forcing a
- * read in between), including sockets before init.
- */
- /*
- * imsg_get() before imsgbuf_read(), same reasoning throughout this
- * loop as imsgev.c's setup_recv_one_peer() header comment: the
- * SETUP_DONE-ack exchange immediately preceding this loop
- * (setup_recv_done_and_ack(), just above) can leave more than one
- * of these five messages already sitting fully buffered in ibuf3
- * -- parent fires them off back to back with no synchronization
- * forcing a read in between -- so checking imsg_get() first avoids
- * ever issuing a real blocking recvmsg() for bytes that already
- * arrived. Restructured from the previous "outer imsgbuf_read(),
- * inner drain-while-imsg_get()" shape into one flat loop that
- * always tries imsg_get() first, since that outer/inner split had
- * the exact same bug on its very first iteration.
- */
+ /* Socket/cert/key/init arrive on fd 3 in any order; read synchronously before event_set(). */
while (!got_init || !got_cert || !got_key ||
recv_cleartext < init.n_cleartext_addrs ||
recv_tls < init.n_tls_addrs) {
got_key = 1;
break;
case IMSG_LISTENER_INIT:
- /* see imapd.h's imsg_listener_init comment --
- * n_cleartext_addrs/n_tls_addrs are load-bearing
- * now (this loop's own exit condition reads
- * them), not just a log line. The listening
- * sockets themselves are never rebuilt from
- * listen_addr here; that already happened in
- * parent before this process even existed. */
if (imsg_get_data(&imsg, &init, sizeof(init))
== -1) {
log_warnx("bad IMSG_LISTENER_INIT");
if (chdir("/") == -1)
fatal("chdir /");
- /*
- * listener's own privilege drop -- previously flagged as entirely
- * missing (this process stayed at whatever uid execvp()'d it, i.e.
- * root). _imapd here is an ordinary system daemon user, the
- * listener-role counterpart to auth.c's _imapauth (renamed from
- * _openimap/_openimapd along with the rest of the daemon's own
- * on-disk/system identity -- see imapd.h's header comment for the
- * rename-scoping policy) -- unrelated to the credential-file/
- * mailbox-user design decision, same distinction auth.c's header
- * comment makes for its own daemon user.
- */
+ /* _imapd: ordinary daemon user, listener-role counterpart to auth.c's _imapauth. */
if (setgroups(1, &pw->pw_gid) == -1 ||
setresgid(pw->pw_gid, pw->pw_gid, pw->pw_gid) == -1 ||
setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1)
fatal("cannot drop privileges to _imapd");
- /*
- * Build the server-wide TLS context from the cert/key bytes read
- * above, sourced directly against httpd's server_tls_init()
- * (openbsd_source/src/usr.sbin/httpd/server.c) and src/lib/libtls/
- * tls.h: tls_config_new()+tls_server()+tls_config_set_keypair_mem()
- * +tls_configure(), then tls_config_clear_keys() and scrubbing our
- * own copy -- httpd does the equivalent with freezero(), we don't
- * have that in this sandbox's stub environment so explicit_bzero()+
- * the buffer simply going out of scope at function return serves
- * the same purpose (it's a fixed-size stack buffer, not a heap
- * allocation, so there's nothing to free()).
- *
- * A failure here is NOT fatal to the whole daemon -- see
- * listener_tls_ctx's file-scope comment for why: cleartext
- * CAPABILITY/NOOP/LOGOUT/ID still work without TLS, so a bad
- * cert/key degrades to "no TLS" rather than killing listener
- * entirely. tls_accept_socket() call sites below all check for
- * listener_tls_ctx == NULL first.
- *
- * tls_config_set_ciphers(..., "secure") is explicit here rather
- * than left at whatever tls_config_new()'s own unset default
- * happens to be -- prompted by relayd(8)/httpd(8) both explicitly
- * moving their own default cipher sets to "secure" this same
- * OpenBSD development cycle (rsadowski.de's "Dead Software
- * Walking" writeup on the two daemons' ongoing modernization).
- * "secure" is a real, documented named cipher-list value
- * (tls_config_set_ciphers(3): "secure (or alias default)"), so
- * this removes any ambiguity about which list actually applies
- * rather than relying on an implicit library default that could
- * change across libtls versions.
- */
+ /* Failure here isn't fatal -- degrades to no-TLS, checked via listener_tls_ctx == NULL below. */
if (cert_len == 0 || key_len == 0) {
log_warnx("listener: no TLS cert/key received -- TLS "
"disabled for this run");
imsgev_init(&iev_auth, peer_fd, listener_dispatch_auth, NULL);
- /*
- * Hands off fd 3's already-populated ibuf3 to the event loop
- * without a second imsgbuf_init() -- see this file's header
- * comment and imsgev.c's imsgev_init_from_ibuf() comment for why
- * that distinction is load-bearing here, not just tidiness. This
- * is the channel parent uses for the ongoing per-session
- * store-fork protocol (IMSG_STORE_FORK out, IMSG_SETUP_PEER /
- * IMSG_STORE_FORK-as-failure in).
- */
+ /* Reuses fd 3's populated ibuf3 -- a fresh imsgbuf_init() would drop buffered bytes. */
imsgev_init_from_ibuf(&iev_parent, &ibuf3, listener_dispatch_parent,
NULL);
- /* Each listening socket gets its own struct event -- reusing
- * an imsg channel's .ev here (an earlier draft of this function
- * did) would clobber that channel's own event registration, since
- * event_set()/event_add() on a struct event that's already
- * registered elsewhere silently repurposes it rather than erroring.
- * That now also means each *additional* dual-stack socket needs its
- * own struct event too, not just each port purpose -- ev_accept_
- * cleartext[]/ev_accept_tls[] arrays, one slot per bound fd, rather
- * than one struct event per array. listener_accept()'s own arg
- * ((void *)0 cleartext, (void *)1 tls) is unchanged: it only ever
- * needed to know which *port purpose* accepted the connection, not
- * which address family did. */
for (i = 0; i < n_cleartext_fd; i++) {
event_set(&ev_accept_cleartext[i], cleartext_fd[i],
EV_READ | EV_PERSIST, listener_accept, (void *)0);
fatalx("listener: exited event loop");
}
-/*
- * Accepts a connection and allocates its struct session. On the
- * cleartext port, wires session_dispatch_client() and sends the RFC
- * 9051 greeting immediately. On the implicit-TLS port (RFC 8314), no
- * protocol bytes -- not even the greeting -- may cross the wire before
- * TLS is established, so the handshake starts immediately instead (see
- * session_tls_start()) and the greeting is deferred until it completes
- * (s->pending_greeting, sent from session_tls_handshake()).
- */
-static void
+/* RFC 8314: on the implicit-TLS port the greeting waits for the handshake (s->pending_greeting). */
+void
listener_accept(int fd, short event, void *arg)
{
struct sockaddr_storage ss;
s->id = next_session_id++;
s->client_fd = client_fd;
s->state = SESSION_NOT_AUTH;
- s->implicit_tls = (arg != (void *)0); /* see the two event_set()
- * calls in listener_main() --
- * (void *)1 for the port-993
- * listener */
+ s->implicit_tls = (arg != (void *)0); /* (void *)1 == port-993 listener */
TAILQ_INSERT_TAIL(&sessions, s, entry);
log_debug("session %u: accepted (%s)", s->id,
if (s->implicit_tls) {
if (listener_tls_ctx == NULL) {
- /* TLS didn't configure successfully at boot -- see
- * listener_tls_ctx's file-scope comment. Nothing
- * correct to do on this port without it. */
log_warnx("session %u: implicit-TLS port, but TLS "
"isn't configured -- closing", s->id);
session_teardown(s);
session_send_greeting(s);
}
-/*
- * (Re-)registers client_ev in normal EV_READ|EV_PERSIST mode pointing at
- * session_dispatch_client() -- the steady-state read handler once no TLS
- * handshake is in progress (either because there's no TLS at all, or
- * because one just completed). Guards the event_del() with client_ev_
- * added the same way session_teardown() does, since this can be the
- * very first registration for a session (the plaintext accept path) or
- * a re-registration after a handshake (which repurposed client_ev to a
- * different handler/flags -- see session_tls_start()).
- */
-static void
+/* (Re-)registers client_ev for steady-state reads; guarded for handshake-repurposed re-registration. */
+void
session_arm_client_read(struct session *s)
{
if (s->client_ev_added)
s->client_ev_added = 1;
}
-/*
- * Starts a TLS handshake on s->client_fd: tls_accept_socket() creates
- * the per-connection struct tls (non-blocking, doesn't itself perform
- * handshake I/O), then client_ev is armed for EV_READ pointing at
- * session_tls_handshake() to drive the actual handshake once the socket
- * is readable -- sourced against httpd's server_input()/tls_accept_
- * socket() call site and server_tls_handshake() (openbsd_source/src/
- * usr.sbin/httpd/server.c), which arms EV_READ and waits for the first
- * callback rather than calling tls_handshake() synchronously here.
- * Called both from listener_accept() (implicit-TLS port, client_ev not
- * yet registered) and cmd_starttls() (cleartext port, client_ev already
- * in normal read-dispatch mode) -- the client_ev_added guard, same
- * pattern as session_arm_client_read() and session_teardown(), covers
- * both.
- */
-static void
+/* Creates the per-connection struct tls (non-blocking), then arms client_ev to drive the handshake. */
+void
session_tls_start(struct session *s)
{
if (tls_accept_socket(listener_tls_ctx, &s->tls_ctx, s->client_fd)
s->client_ev_added = 1;
}
-/*
- * Drives a non-blocking TLS handshake to completion, re-arming client_ev
- * for whichever direction tls_handshake() reports it's waiting on
- * (TLS_WANT_POLLIN/TLS_WANT_POLLOUT can each occur regardless of which
- * direction the *previous* call was armed for -- a TLS handshake isn't
- * a simple read-then-write sequence). Sourced directly against httpd's
- * server_tls_handshake(): ret == 0 is success, TLS_WANT_POLLIN/POLLOUT
- * re-arm and wait for another callback, anything else is a hard
- * failure. This is the one place in this codebase where blocking would
- * be a real bug rather than just a simplification -- listener is a
- * single event loop serving every session, so blocking here on one
- * client's slow handshake would stall all the others; unlike
- * session_write()'s short-message blocking-write simplification, there
- * is no equivalent shortcut available for a multi-round-trip handshake.
- */
-static void
+/* Drives a non-blocking TLS handshake, re-arming client_ev for whichever direction it wants next. */
+void
session_tls_handshake(int fd, short event, void *arg)
{
struct session *s = arg;
session_teardown(s);
}
-/*
- * "* OK IMAP4rev2 server ready" -- the exact example text from RFC 9051
- * SS7.1.1's OK-response section, used verbatim rather than paraphrased.
- * Goes through session_write() like every other response, so it's
- * transparently TLS-aware for the implicit-TLS accept path (see
- * session_tls_handshake(), which calls this once the handshake
- * completes) without needing its own TLS-vs-plaintext branch here.
- */
-static void
+/* RFC 9051 SS7.1.1's example OK-response text, used verbatim. */
+void
session_send_greeting(struct session *s)
{
static const char greeting[] = "* OK IMAP4rev2 server ready\r\n";
session_write(s, greeting, sizeof(greeting) - 1);
}
-/*
- * Reads raw bytes into s->inbuf and splits them into CRLF-terminated
- * lines, per RFC 9051 SS2.2's "all interactions ... are in the form of
- * lines" -- a bare LF (some clients/testing tools are sloppy about this)
- * is deliberately NOT treated as a line ending, matching the ABNF's
- * literal CRLF requirement rather than being lenient about it. Each
- * complete line goes to session_handle_line() for tag/command parsing
- * and dispatch.
- *
- * Correctness note: session_handle_line() can tear down (free) *s* --
- * currently only LOGOUT does this deliberately, see cmd_logout(). Once
- * that happens this loop MUST NOT touch s again, which is why `consumed`
- * is computed *before* the call (pure pointer arithmetic on data already
- * in the buffer, no s-> field mutation needed yet) and the loop returns
- * immediately rather than falling through to the memmove/inbuflen
- * update below. Write failures do NOT tear down the session here (see
- * session_write()'s comment) specifically to avoid needing this same
- * care in every command handler -- LOGOUT is the one deliberate,
- * well-understood exception.
- */
-static void
+/* Forward decls: session_is_busy()/session_enqueue_cmd() are defined below session_dispatch_client() but called from it. */
+static int session_is_busy(const struct session *);
+static int session_enqueue_cmd(struct session *, const char *);
+
+/* Splits s->inbuf into CRLF lines (bare LF isn't one, RFC 9051 SS2.2); session_handle_line() can free *s* (LOGOUT). */
+void
session_dispatch_client(int fd, short event, void *arg)
{
struct session *s = arg;
n = tls_read(s->tls_ctx, s->inbuf + s->inbuflen,
sizeof(s->inbuf) - s->inbuflen);
if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT) {
- /*
- * tls_read() can want to WRITE (renegotiation,
- * session ticket rotation -- see httpd's server_
- * tls_readcb() for the same pattern) even though
- * this is a read path. Re-arm client_ev for whichever
- * direction it actually needs and wait for the next
- * callback -- unlike session_write()'s bounded
- * blocking poll() retry, this is already inside the
- * event loop, so there's no need to block at all,
- * just re-register and return. Not EV_PERSIST: this
- * is a one-shot retry registration, restored to the
- * normal EV_READ|EV_PERSIST steady state below once
- * real data (or EOF) is actually obtained.
- */
+ /* tls_read() can want to write (renegotiation); re-arm one-shot for the direction it needs. */
event_del(&s->client_ev);
event_set(&s->client_ev, s->client_fd,
(n == TLS_WANT_POLLIN) ? EV_READ : EV_WRITE,
session_teardown(s);
return;
}
- /*
- * Got real data or EOF (n == 0, handled below) -- the event
- * that delivered us here might have been a temporary,
- * non-persistent WANT_POLLIN/WANT_POLLOUT retry registration
- * (above) rather than the steady-state EV_READ|EV_PERSIST
- * one, so restore that steady state unconditionally rather
- * than tracking which case we're in. Redundant but harmless
- * in the common case where it was already correct.
- */
- session_arm_client_read(s);
+ session_arm_client_read(s); /* restore steady-state EV_READ|EV_PERSIST; harmless if already correct */
} else {
n = read(fd, s->inbuf + s->inbuflen,
sizeof(s->inbuf) - s->inbuflen);
size_t consumed;
int alive;
- /*
- * RFC 9051 SS4.3 literal in flight (cmd_append() found a
- * trailing "{n}"/"{n+}" and is waiting for its raw octets --
- * see s->literal_pending's comment). Checked *before* the
- * CRLF search below on purpose: a literal's bytes can
- * contain CRLF sequences of their own (SS4.3: "a sequence of
- * zero or more octets (including CR and LF)"), so treating
- * them as line-oriented input here would both misparse the
- * literal itself and, worse, could let embedded bytes that
- * happen to look like "tag SP command CRLF" get dispatched
- * as a bogus command mid-literal. This block runs first,
- * every iteration, until the full literal (and its
- * mandatory trailing CRLF) has been consumed -- no ST_*
- * dispatch-table exclusion is needed for this phase, unlike
- * every async-imsg-wait state, because session_handle_line()
- * is simply never reached while it's active.
- */
+ /* RFC 9051 SS4.3 literal in flight; checked before CRLF search since raw octets can contain CRLF. */
if (s->literal_pending) {
uint64_t want, take;
}
if (s->literal_remaining > 0)
- break; /* need more data -- wait for the
- * next read */
+ break; /* need more data */
- /*
- * Literal body fully received. RFC 9051 SS9's
- * `command = tag SP ... CRLF` still requires a
- * trailing CRLF after the literal's raw octets --
- * SS4.3's `literal` production ends with the octets
- * themselves, not a CRLF; the CRLF belongs to the
- * outer `command` production, same as after any
- * other argument. APPEND's own grammar (SS6.3.12:
- * `append = ... SP literal`) puts the literal last,
- * so nothing else can follow it on the line -- v1
- * doesn't support a literal anywhere but the final
- * argument, a deliberate simplification flagged in
- * parse_append_args()'s comment.
- */
+ /* Literal body received; `command` still needs its trailing CRLF (RFC 9051 SS9). */
if (s->inbuflen < 2)
break; /* trailing CRLF hasn't arrived yet */
if (s->inbuf[0] != '\r' || s->inbuf[1] != '\n') {
- /*
- * Genuinely hard to resync from here -- we
- * have no reliable way to know where the
- * next real command boundary is inside
- * whatever the client actually sent instead
- * of CRLF. Same "give up rather than guess"
- * call session_teardown() elsewhere in this
- * file makes for comparably confused
- * protocol states, rather than risk
- * mis-parsing arbitrary subsequent bytes as
- * commands.
- */
+ /* no reliable resync point -- give up */
log_warnx("session %u: expected CRLF after "
"literal data, closing", s->id);
session_teardown(s);
*crlf = '\0';
consumed = (size_t)(crlf - s->inbuf) + 2;
- /*
- * A bare SASL continuation-response line (base64, or "*" to
- * cancel -- RFC 9051 SS6.2.2) is NOT a tagged command and
- * must not go through session_handle_line()'s tag/name/args
- * parser -- s->auth_cont, set by cmd_authenticate() when the
- * client sent "AUTHENTICATE PLAIN" with no inline initial
- * response, routes it to session_handle_auth_continuation()
- * instead. Likewise, a bare "DONE" continuation line while
- * idling (RFC 9051 SS6.3.13) is NOT a tagged command either
- * -- s->idling, set by cmd_idle(), routes it to session_
- * handle_idle_continuation() the same way. All three share
- * the same alive/torn-down (1/0) return convention this loop
- * already relies on.
- */
- alive = s->auth_cont ?
- session_handle_auth_continuation(s, s->inbuf) :
- s->idling ?
- session_handle_idle_continuation(s, s->inbuf) :
- session_handle_line(s, s->inbuf);
+ /* SASL continuation (auth_cont) and IDLE's "DONE" (idling) route around the tag/name/args parser and the pipeline queue below. */
+ if (s->auth_cont) {
+ alive = session_handle_auth_continuation(s, s->inbuf);
+ } 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). */
+ if (!session_enqueue_cmd(s, s->inbuf)) {
+ static const char bad[] = "* BAD too many "
+ "pipelined commands, closing connection"
+ "\r\n";
+
+ log_warnx("session %u: pipelined command "
+ "queue full, closing", s->id);
+ session_write(s, bad, sizeof(bad) - 1);
+ session_teardown(s);
+ return;
+ }
+ alive = 1;
+ } else {
+ alive = session_handle_line(s, s->inbuf);
+ }
if (alive == 0)
- return; /* s was torn down (LOGOUT) -- must not
- * touch it again, see this function's
- * header comment. */
+ return; /* s was torn down (LOGOUT) -- do not touch */
- /*
- * A handler can mutate s->inbuflen itself -- cmd_starttls()
- * deliberately zeroes it to discard any plaintext pipelined
- * past the STARTTLS line (see its comment). Clamp `consumed`
- * (computed above, before the handler ran) against the
- * *current* inbuflen so the subtraction below can't
- * underflow a size_t into a huge value. A no-op in the
- * ordinary case, since inbuflen only otherwise grows via new
- * reads between session_dispatch_client() invocations, never
- * shrinks except through this same loop.
- */
+ /* cmd_starttls() zeroes inbuflen to discard pipelined plaintext -- clamp to avoid underflow. */
if (consumed > s->inbuflen)
consumed = s->inbuflen;
}
if (s->inbuflen == sizeof(s->inbuf)) {
- /* Buffer full with no CRLF found -- matches the spirit of
- * RFC 9051 SS7.1.3's own example ("* BAD Command line too
- * long"), though that exact string is example text, not a
- * normative response -- see SESSION_INBUF_MAX's comment. */
+ /* Buffer full, no CRLF -- matches the spirit of RFC 9051 SS7.1.3's example text. */
static const char bad[] = "* BAD command line too long\r\n";
log_warnx("session %u: command line too long, closing",
s->id);
- (void)write(s->client_fd, bad, sizeof(bad) - 1);
+ /* was a raw write(2) -- wrong on a TLS session, bytes would land unencrypted on the wire */
+ session_write(s, bad, sizeof(bad) - 1);
session_teardown(s);
}
}
-/*
- * Writes to the client socket -- plain blocking write(2) for a
- * plaintext session, or tls_write() for a TLS one. Every response in
- * this file (including the greeting -- see session_send_greeting())
- * goes through this one function.
- *
- * Blocking is a deliberate v1 simplification: worth revisiting together
- * (event-driven EV_WRITE + a per-session outbound queue, mirroring
- * imsgev.c's pattern) once any response needs to be larger than one
- * send (multi-line FETCH output, etc.) -- v1's responses are all short
- * and fixed, so this has always been an accepted shortcut, not a claim
- * of correctness at scale.
- *
- * For TLS specifically: tls_write() can return TLS_WANT_POLLIN or
- * TLS_WANT_POLLOUT even for a plain short write (renegotiation, session
- * ticket rotation -- see httpd's server_tls_writecb() for the same
- * pattern in event-driven form), so this retries via poll(2) with a
- * BOUNDED timeout rather than looping forever. Unlike session_tls_
- * handshake() (which MUST be event-driven, since listener is one event
- * loop serving every session and a slow multi-round-trip handshake
- * would stall all of them), a bounded blocking retry here is a much
- * smaller, deliberately accepted risk: it can only stall this one
- * session's own short write, and only in the rare renegotiation case,
- * and only for SESSION_WRITE_POLL_TIMEOUT_MS at worst -- not the
- * unbounded, whole-daemon-hanging risk an infinite poll() timeout would
- * be against a client that simply never reads its socket.
- *
- * Deliberately does NOT call session_teardown() on failure (including a
- * poll(2) timeout) -- unlike the "line too long" path. A write failure
- * here is logged and otherwise ignored; the read side will discover the
- * same dead connection on its next read()/tls_read() (EOF or error) and
- * tear down then. This avoids every command handler needing to guard
- * against *s* being freed out from under it mid-dispatch -- LOGOUT is
- * the one place that tears a session down deliberately, and it does so
- * explicitly, not via a write failure. See session_dispatch_client()'s
- * header comment for the reentrancy hazard this sidesteps.
- */
+/* Blocking write(2)/tls_write(): v1 simplification (short fixed responses); never tears s down on failure. */
#define SESSION_WRITE_POLL_TIMEOUT_MS 5000
-static void
+void
session_write(struct session *s, const char *buf, size_t len)
{
size_t sent = 0;
+ /*
+ * Permanent outbound-traffic diagnostic, kept deliberately (not the
+ * leftover it started as during Canary/Airmail debugging). 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.
+ */
+ if (log_getverbose() > 0) {
+ char dbuf[301];
+ size_t dlen = len < sizeof(dbuf) - 1 ? len : sizeof(dbuf) - 1;
+ size_t i;
+
+ memcpy(dbuf, buf, dlen);
+ dbuf[dlen] = '\0';
+ for (i = 0; i < dlen; i++)
+ if (dbuf[i] == '\r' || dbuf[i] == '\n')
+ dbuf[i] = ' ';
+ log_debug("session %u: >>> %s%s", s->id, dbuf,
+ len > dlen ? "...(truncated)" : "");
+ }
+
if (!s->tls_active) {
- /*
- * F12 fix: loop on partial writes and retry EINTR/EAGAIN
- * (poll like the TLS branch below). Previously a single
- * write() ignored short counts, silently truncating a
- * multi-write response under back-pressure.
- */
- while (sent < len) {
+ while (sent < len) { /* retry EINTR/EAGAIN via poll */
ssize_t n;
struct pollfd pfd;
}
}
-static void
+
+void
session_reply(struct session *s, const char *tag, const char *status,
const char *text)
{
len = snprintf(buf, sizeof(buf), "%s %s %s\r\n", tag, status, text);
if (len < 0)
return;
- if ((size_t)len >= sizeof(buf))
- len = sizeof(buf) - 1; /* truncate rather than overflow --
- * fine for v1's short fixed messages,
- * none of which approach this bound */
+ if ((size_t)len >= sizeof(buf)) {
+ /*
+ * text can legitimately exceed this buffer (e.g. COPYUID's
+ * sequence-set text, up to ~4KB) -- snprintf(3) already
+ * truncated safely, but a bare truncation drops the trailing
+ * CRLF, and this project's clients are strictly line-based
+ * (RFC 9051 SS2.2.1). A response missing its terminator would
+ * merge with whatever comes next, corrupting every later
+ * response on this connection. 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);
}
-static void
+
+void
session_untagged(struct session *s, const char *text)
{
char buf[512];
len = snprintf(buf, sizeof(buf), "* %s\r\n", text);
if (len < 0)
return;
- if ((size_t)len >= sizeof(buf))
+ 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);
}
-/*
- * RFC 7162 SS3.1: marks this session as a "CONDSTORE-aware client" --
- * called from every CONDSTORE-enabling command this server recognizes
- * (ENABLE CONDSTORE/QRESYNC, FETCH with the MODSEQ fetch-att or CHANGEDSINCE
- * modifier, STORE with UNCHANGEDSINCE, SEARCH with a MODSEQ criterion).
- * SELECT/EXAMINE with a CONDSTORE or QRESYNC select-param is deliberately
- * NOT routed through this function -- see cmd_select()'s comment -- because
- * that command's own response already includes HIGHESTMODSEQ as part of its
- * normal reply sequence (session_handle_mbox_selected()), so calling this
- * from there would emit a redundant, spec-violating second copy.
- *
- * Idempotent (does nothing once already enabled) and, per SS3.1's own
- * parenthetical ("A first CONDSTORE enabling command executed in the
- * session with a mailbox selected MUST cause the server to return
- * HIGHESTMODSEQ... (if any is selected)"), only emits the unsolicited
- * HIGHESTMODSEQ OK response when a mailbox is actually selected right now
- * -- e.g. ENABLE is normally issued pre-SELECT (RFC 9051 SS6.3.1), where
- * this is correctly a no-op beyond setting the flag. Uses s->mbox_
- * highestmodseq (cached from the last SELECT/STORE/EXPUNGE reply) rather
- * than asking store.c for a fresh value -- see that field's comment in
- * struct session for why a cached value is an acceptable, deliberate
- * choice for this specific edge case.
- */
-static void
+/* RFC 7162 SS3.1: marks the session CONDSTORE-aware; emits an unsolicited HIGHESTMODSEQ OK if selected. */
+void
session_condstore_enable(struct session *s)
{
char buf[48];
}
}
-/*
- * Splits one already CRLF-stripped line into tag/name/args, per RFC
- * 9051's ABNF: `command = tag SP (command-any / ...) CRLF`. Tokens are
- * split on single spaces; RFC 9051 SS2.2.1 says extraneous spaces are
- * technically a client syntax error, but this parser is deliberately
- * lenient about repeated spaces between tokens (skips them) rather than
- * rejecting -- a simplification, not a claim of full ABNF conformance.
- * Tag characters themselves aren't validated against the real ASTRING-
- * CHAR class (RFC 9051 SS9, `tag = 1*<any ASTRING-CHAR except "+">`) --
- * another deliberate simplification, flagged rather than silently
- * assumed correct.
- *
- * Returns -1 if no tag could be found at all (line was empty or all
- * spaces) -- the caller sends RFC 9051 SS7.1.3's own example response
- * for exactly this case, untagged "* BAD Empty command line". Returns 0
- * otherwise; *name may be NULL if a tag was found but no command name
- * followed it (caller sends a tagged BAD).
- */
-static int
+/* Splits a CRLF-stripped line into tag/name/args (RFC 9051 `command = tag SP ...`); lenient on spaces. */
+int
parse_command_line(char *line, char **tag, char **name, char **args)
{
char *p = line;
return (0);
}
-/*
- * Parses and dispatches one command line. Returns 1 if the session is
- * still alive afterward, 0 if it was torn down (LOGOUT only, in v1) --
- * see session_dispatch_client()'s header comment for why callers must
- * check this before touching *s* again.
- */
+/* True only for the post-auth async-round-trip states; pre-auth states (AUTHENTICATING/STORE_PENDING) deliberately excluded -- see SESSION_CMD_QUEUE_MAX's comment. */
static int
+session_is_busy(const struct session *s)
+{
+ switch (s->state) {
+ case SESSION_SELECTING:
+ case SESSION_FETCHING:
+ case SESSION_STORING:
+ case SESSION_EXPUNGING:
+ case SESSION_APPENDING:
+ case SESSION_SEARCHING:
+ case SESSION_STATUSING:
+ case SESSION_COPYING:
+ case SESSION_CREATING:
+ case SESSION_DELETING:
+ case SESSION_RENAMING:
+ case SESSION_LISTING:
+ return (1);
+ default:
+ return (0);
+ }
+}
+
+/* Appends a pipelined line to s->cmd_queue; returns 0 on a full queue or strdup(3) failure -- caller must teardown. */
+static int
+session_enqueue_cmd(struct session *s, const char *line)
+{
+ char *copy;
+
+ if (s->cmd_queue_n >= SESSION_CMD_QUEUE_MAX)
+ return (0);
+
+ if ((copy = strdup(line)) == NULL) {
+ log_warn("session %u: strdup pipelined command", s->id);
+ return (0);
+ }
+
+ s->cmd_queue[s->cmd_queue_n++] = copy;
+ return (1);
+}
+
+/* Dispatches queued pipelined commands while the session stays idle; returns 0 if one of them tore *s* down (LOGOUT). */
+int
+session_dequeue_next(struct session *s)
+{
+ while (s->cmd_queue_n > 0 && !session_is_busy(s)) {
+ char *line = s->cmd_queue[0];
+ uint32_t i;
+ int alive;
+
+ for (i = 1; i < s->cmd_queue_n; i++)
+ s->cmd_queue[i - 1] = s->cmd_queue[i];
+ s->cmd_queue_n--;
+
+ alive = session_handle_line(s, line);
+ free(line);
+ if (!alive)
+ return (0);
+ }
+ return (1);
+}
+
+/* Returns 1 if the session is still alive, 0 if torn down (LOGOUT) -- caller must not touch *s* if 0. */
+int
session_handle_line(struct session *s, char *line)
{
char *tag, *name, *args;
session_reply(s, "*", "BAD", "Empty command line");
return (1);
}
+
+ /* Checked once here, not per strlcpy(3) site -- an overlong tag must not be echoed back truncated. */
+ if (strlen(tag) >= IMAP_TAG_MAX) {
+ session_reply(s, "*", "BAD", "Tag too long");
+ return (1);
+ }
if (name == NULL) {
session_reply(s, tag, "BAD", "Missing command");
return (1);
return (1);
}
if (!(imap_cmds[i].states & (1U << s->state))) {
- /* RFC 9051 SS3: "the server will respond with a BAD or NO
- * (depending upon server implementation)" for a command
- * received in the wrong state -- BAD chosen here. */
+ /* RFC 9051 SS3: BAD or NO for wrong-state command -- BAD chosen here. */
session_reply(s, tag, "BAD",
"Command not permitted in this state");
return (1);
return (imap_cmds[i].handler(s, tag, args));
}
-static int
-cmd_capability(struct session *s, const char *tag, char *args)
-{
- (void)args; /* RFC 9051: "Arguments: none" -- extra args are
- * silently ignored rather than rejected, a
- * leniency simplification like parse_command_line()'s. */
-
- session_untagged(s, s->tls_active ?
- "CAPABILITY " CAPABILITY_POST_TLS : "CAPABILITY " CAPABILITY_PRE_TLS);
- session_reply(s, tag, "OK", "CAPABILITY completed");
- return (1);
-}
-
-static int
-cmd_noop(struct session *s, const char *tag, char *args)
-{
- (void)args;
- session_reply(s, tag, "OK", "NOOP completed");
- return (1);
-}
-
-static int
-cmd_logout(struct session *s, const char *tag, char *args)
-{
- (void)args;
-
- /* Exact example text from RFC 9051 SS6.1.3. */
- session_untagged(s, "BYE IMAP4rev2 Server logging out");
- session_reply(s, tag, "OK", "LOGOUT completed");
- session_teardown(s);
- return (0);
-}
-
-static int
-cmd_id(struct session *s, const char *tag, char *args)
-{
- /*
- * RFC 2971 SS3.1: full parsing of the client's parenthesized
- * field/value list isn't implemented (real IMAP list/string
- * argument parsing -- quoted strings, literals -- doesn't exist
- * anywhere in this codebase yet, and nothing here needs to look
- * inside the list per RFC 2971's own "MUST NOT make operational
- * changes based on the data" rule). Logged raw and discarded,
- * matching openimap-v1-dispatch.md's stated v1 behavior ("accepts
- * client ID params, logs them, no persistence needed for v1").
- * Always replies NIL, per SS3.2's "a server MAY send NIL in place
- * of the list" -- simplest spec-compliant response, and matches
- * the RFC's own minimal example (C: ID NIL / S: * ID NIL).
- */
- log_debug("session %u: ID params: %s", s->id,
- args != NULL ? args : "(none)");
- session_untagged(s, "ID NIL");
- session_reply(s, tag, "OK", "ID completed");
- return (1);
-}
-
-static int
-cmd_login(struct session *s, const char *tag, char *args)
-{
- (void)args;
-
- /*
- * Permanently disabled regardless of TLS state -- resolved design
- * decision, openimap-v1-dispatch.md's LOGIN row: RFC 9051 SS6.2.3
- * only mandates refusing LOGIN when unprotected (a floor, not a
- * ceiling); we rely solely on AUTHENTICATE PLAIN under TLS.
- */
- session_reply(s, tag, "NO",
- "LOGIN disabled -- use AUTHENTICATE PLAIN under TLS");
- return (1);
-}
-
-static int
-cmd_starttls(struct session *s, const char *tag, char *args)
-{
- if (args != NULL) {
- /* RFC 9051 SS6.2.1 Result: "BAD - ... arguments invalid". */
- session_reply(s, tag, "BAD", "STARTTLS takes no arguments");
- return (1);
- }
- if (s->tls_active) {
- /* RFC 9051 SS6.2.1 Result: "BAD - STARTTLS received after a
- * successful TLS negotiation ...". */
- session_reply(s, tag, "BAD", "TLS already active");
- return (1);
- }
- if (listener_tls_ctx == NULL) {
- /* RFC 9051 SS6.2.1 Result: "NO - TLS negotiation can't be
- * initiated, due to server configuration error" -- exactly
- * this codebase's situation if cert/key loading failed at
- * boot (see listener_tls_ctx's file-scope comment). RFC
- * 5530: UNAVAILABLE -- "Temporary failure because a
- * subsystem is down," which is exactly what a boot-time
- * cert/key failure leaves TLS in for this run. */
- session_reply(s, tag, "NO",
- "[UNAVAILABLE] TLS negotiation unavailable");
- return (1);
- }
-
- /*
- * RFC 9051 SS6.2.1: "A TLS negotiation begins immediately after
- * the CRLF at the end of the tagged OK response from the server."
- * -- matches the RFC's own example exactly ("C: a002 STARTTLS" /
- * "S: a002 OK Begin TLS negotiation now"). Must go out in
- * cleartext BEFORE the handshake starts.
- */
- session_reply(s, tag, "OK", "Begin TLS negotiation now");
-
- /*
- * RFC 9051 SS6.2.1's STARTTLS command-injection mitigation: any
- * plaintext bytes already sitting in our own read buffer past the
- * STARTTLS line itself (a pipelining client, or an attacker) are
- * discarded rather than reinterpreted as the start of the TLS
- * handshake -- the "throw it away" option the RFC explicitly
- * permits as an alternative to "treat as start of handshake",
- * chosen here for simplicity. session_dispatch_client()'s caller
- * loop clamps its own `consumed` calculation against the
- * (possibly now smaller) s->inbuflen after this handler returns,
- * specifically to make this safe -- see its comment.
- */
- s->inbuflen = 0;
-
- session_tls_start(s);
- return (1);
-}
-
-/*
- * RFC 4616 SS2: authzid/authcid/passwd are each "MUST accept up to and
- * including 255 octets", joined by two single-octet NUL delimiters --
- * 255*3+2 = 767, rounded up. Sourced by fetching the RFC's own text
- * (https://www.rfc-editor.org/rfc/rfc4616.txt) this session, since it
- * wasn't present in research/ or openbsd_source/ -- see this file's
- * git history / session notes for that lookup.
- */
-#define SASL_PLAIN_MAX 768
-
-/*
- * Decodes and verifies one SASL PLAIN client message (RFC 4616 SS2:
- * "message = [authzid] UTF8NUL authcid UTF8NUL passwd"), then sends
- * IMSG_AUTH_REQUEST to auth.c over iev_auth. b64 is the raw base64 token
- * from the wire -- either an inline initial response on the AUTHENTICATE
- * line itself, or the client's line following our "+ " continuation
- * request (RFC 9051 SS6.2.2).
- *
- * b64_pton() (declared in <resolv.h>; OpenBSD's __b64_pton, the same
- * codec smtpd.c uses -- see openbsd_source/src/usr.sbin/smtpd/util.c)
- * does the actual decode. Its full implementation (openbsd_source/src/
- * lib/libc/net/base64.c) was read this project: it returns -1 on any
- * character outside the base64 alphabet, on target-buffer overflow, and
- * on misplaced/non-terminal "=" padding -- exactly RFC 9051 SS6.2.2's own
- * required rejection condition ("if it receives an invalid base64 string
- * ... it MUST reject the AUTHENTICATE command by sending a tagged BAD
- * response", explicitly calling out "characters outside the base64
- * alphabet" and non-terminal "=" as the two examples).
- *
- * allow_empty_equals is set only by the inline-initial-response call site
- * (cmd_authenticate()) -- RFC 9051's initial-resp ABNF is literally
- * "(base64 / \"=\")", a single pad character meaning "response present,
- * but zero-length" (SS6.2.2: "To send a zero-length initial response, the
- * client MUST send a single pad character"). That shorthand is NOT part
- * of the plain `base64` grammar used for continuation-response lines, so
- * session_handle_auth_continuation() passes 0 here -- a lone "=" from a
- * continuation line falls through to b64_pton() and is correctly rejected
- * as invalid (non-terminal, in fact orphaned) padding.
- *
- * Never tears down the session -- an AUTHENTICATE failure keeps the
- * connection open for a retry, per RFC 9051 SS6.2.2's own "the client MAY
- * try another authentication mechanism" -- so this always returns 1; the
- * int return exists only to match session_handle_line()'s alive/torn-
- * down calling convention that session_dispatch_client()'s loop relies
- * on.
- */
-static int
-sasl_plain_finish(struct session *s, const char *tag, const char *b64,
- int allow_empty_equals)
-{
- unsigned char raw[SASL_PLAIN_MAX];
- unsigned char *authcid, *passwd, *nul;
- int rawlen;
- size_t off, authcidlen, passwdlen;
- struct imsg_auth_request req;
-
- if (allow_empty_equals && strcmp(b64, "=") == 0) {
- rawlen = 0;
- } else {
- rawlen = b64_pton(b64, raw, sizeof(raw));
- if (rawlen < 0) {
- session_reply(s, tag, "BAD", "invalid base64");
- return (1);
- }
- }
-
- nul = memchr(raw, '\0', (size_t)rawlen);
- if (nul == NULL) {
- session_reply(s, tag, "BAD", "malformed SASL PLAIN message");
- explicit_bzero(raw, sizeof(raw));
- return (1);
- }
- off = (size_t)(nul - raw) + 1;
- authcid = raw + off;
- nul = memchr(authcid, '\0', (size_t)rawlen - off);
- if (nul == NULL) {
- session_reply(s, tag, "BAD", "malformed SASL PLAIN message");
- explicit_bzero(raw, sizeof(raw));
- return (1);
- }
- authcidlen = (size_t)(nul - authcid);
- passwd = nul + 1;
- passwdlen = (size_t)rawlen - off - authcidlen - 1;
-
- /*
- * RFC 4616 SS2: "if preparation fails or results in an empty
- * string, verification SHALL fail". An empty authcid or passwd is
- * a syntactically well-formed message that can never authenticate
- * -- reject it the same way (NO) as a wrong password, not as a
- * malformed message (BAD): nothing about the SASL PLAIN framing
- * itself is wrong.
- */
- if (authcidlen == 0 || passwdlen == 0) {
- session_reply(s, tag, "NO", "[AUTHENTICATIONFAILED] authentication failed");
- explicit_bzero(raw, sizeof(raw));
- return (1);
- }
- /*
- * Not a protocol error either -- just bigger than this
- * implementation's fixed-size struct imsg_auth_request fields
- * (imapd.h) support. Same generic NO as any other credential
- * that won't authenticate, rather than a distinct error that
- * would hand the client a live oracle for an implementation
- * limit -- same spirit as auth.c's auth_verify() comment on not
- * short-circuiting differently for an unknown username.
- */
- if (authcidlen >= AUTH_USERNAME_MAX || passwdlen >= AUTH_PASSWORD_MAX) {
- session_reply(s, tag, "NO", "[AUTHENTICATIONFAILED] authentication failed");
- explicit_bzero(raw, sizeof(raw));
- return (1);
- }
-
- memset(&req, 0, sizeof(req));
- req.session_id = s->id;
- memcpy(req.username, authcid, authcidlen);
- memcpy(req.password, passwd, passwdlen);
- explicit_bzero(raw, sizeof(raw));
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- s->state = SESSION_AUTHENTICATING;
-
- if (imsg_compose(&iev_auth.ibuf, IMSG_AUTH_REQUEST, 0, 0, -1,
- &req, sizeof(req)) == -1)
- log_warn("session %u: imsg_compose IMSG_AUTH_REQUEST", s->id);
- imsgev_add(&iev_auth);
- /*
- * imsg_compose() copies req into its own queue immediately -- safe
- * to scrub our own stack copy right after, same reasoning as
- * parent.c's send_tls_certs() scrubbing its cert/key stack buffers
- * right after the matching imsg_compose() calls.
- */
- explicit_bzero(&req, sizeof(req));
-
- return (1);
-}
-
-/*
- * Handles one line received while s->auth_cont is set -- the client's
- * response to our "+ " continuation request after a bare "AUTHENTICATE
- * PLAIN" (see cmd_authenticate()). Always clears auth_cont first: whether
- * this line succeeds, fails, or cancels, the *next* line is back to being
- * an ordinary tagged command either way.
- */
-static int
-session_handle_auth_continuation(struct session *s, char *line)
-{
- s->auth_cont = 0;
-
- /* RFC 9051 SS6.2.2: "If the client wishes to cancel an
- * authentication exchange, it issues a line consisting of a
- * single '*'. If the server receives such a response ... it MUST
- * reject the AUTHENTICATE command by sending a tagged BAD
- * response." */
- if (strcmp(line, "*") == 0) {
- session_reply(s, s->pending_tag, "BAD",
- "AUTHENTICATE cancelled");
- return (1);
- }
-
- return sasl_plain_finish(s, s->pending_tag, line, 0);
-}
-
-/*
- * Handles one line received while s->idling is set -- the client's
- * response to our "+ idling" continuation after IDLE (RFC 9051 SS6.3.13;
- * see cmd_idle()). Always clears idling first, same "next line is back to
- * an ordinary tagged command either way" reasoning as session_handle_auth_
- * continuation() above.
- *
- * RFC 9051 SS6.3.13: "The IDLE command is terminated by the receipt of a
- * 'DONE' continuation from the client... The client MUST NOT send a
- * command while the server is waiting for the DONE, since the server will
- * not be able to distinguish a command from a continuation." Matched
- * case-insensitively -- same leniency this codebase applies to every
- * other IMAP keyword (strcasecmp throughout), and RFC 2177's original
- * IDLE spec (SS3, worked example) shows literal "DONE" but neither RFC
- * spells out case-sensitivity for it specifically. A client that sends
- * anything else here has violated the MUST NOT above; there's no
- * principled way to recover (we don't know what they meant), so the IDLE
- * simply fails with BAD rather than silently accepting or hanging.
- */
-static int
-session_handle_idle_continuation(struct session *s, char *line)
-{
- s->idling = 0;
-
- if (strcasecmp(line, "DONE") != 0) {
- session_reply(s, s->pending_tag, "BAD",
- "expected DONE");
- return (1);
- }
-
- session_reply(s, s->pending_tag, "OK", "IDLE terminated");
- return (1);
-}
-
-static int
-cmd_authenticate(struct session *s, const char *tag, char *args)
-{
- char *mech, *p, *initial;
-
- if (args == NULL) {
- /* RFC 9051 SS6.2.2 Result: "BAD - ... arguments invalid". */
- session_reply(s, tag, "BAD", "Missing SASL mechanism name");
- return (1);
- }
- mech = args;
- for (p = mech; *p != '\0' && *p != ' '; p++)
- continue;
- if (*p == '\0') {
- initial = NULL;
- } else {
- *p++ = '\0';
- while (*p == ' ')
- p++;
- initial = (*p != '\0') ? p : NULL;
- }
-
- /*
- * openimap-privsep-design.md / RFC 9051 SS6.2.2's quoted "MUST
- * implement a configuration in which it does NOT permit any
- * plaintext password mechanisms, unless the STARTTLS command has
- * been negotiated..." -- s->tls_active reflects a real TLS
- * handshake (STARTTLS and implicit-TLS both work as of the TLS-
- * wiring pass, see session_tls_handshake()).
- */
- if (!s->tls_active) {
- /* RFC 5530: PRIVACYREQUIRED -- "If TLS is not in use, the
- * client could try STARTTLS ... and then repeat the
- * operation," exactly this situation. */
- session_reply(s, tag, "NO",
- "[PRIVACYREQUIRED] plaintext authentication requires TLS");
- return (1);
- }
-
- /* v1 only implements PLAIN -- CAPABILITY_POST_TLS only ever
- * advertises AUTH=PLAIN, so anything else is a client asking for
- * something we never claimed to support. */
- if (strcasecmp(mech, "PLAIN") != 0) {
- session_reply(s, tag, "NO",
- "authentication mechanism not available");
- return (1);
- }
-
- if (initial != NULL) {
- /* RFC 9051 SS6.2.2 "initial response" (SASL SS4): the
- * mechanism name was followed by a token on the same line --
- * either a base64 blob, or "=" for a present-but-zero-length
- * response (initial-resp = base64 / "=") -- so the exchange
- * finishes in one round trip, no continuation request
- * needed. sasl_plain_finish() itself saves the tag into
- * s->pending_tag once it knows the exchange is actually going
- * async (IMSG_AUTH_REQUEST sent) -- no need to duplicate
- * that here. */
- return sasl_plain_finish(s, tag, initial, 1);
- }
-
- /* No initial response: RFC 9051 SS6.2.2's continue-req -- "+ SP
- * [text] CRLF" (resp-text's text is optionally empty per the
- * errata-updated ABNF, RFC 9051 SS8 erratum note 23) -- prompts the
- * client for its base64 response on the next line. auth_cont routes
- * that next raw line to session_handle_auth_continuation() instead
- * of the ordinary tagged-command dispatcher. */
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- s->auth_cont = 1;
- session_write(s, "+ \r\n", 4);
- return (1);
-}
-
-/*
- * Shared reply for every command-auth/command-select entry that's
- * recognized (correctly gated by session state, so this is never
- * confused with session_handle_line()'s "Unknown command" BAD) but can't
- * actually run yet -- store.c's IMSG_MBOX_* family (imapd.h) has no
- * designed wire payloads, so there's nothing for these handlers to send.
- * NO (not BAD) is the correct RFC 9051 result category here: the command
- * itself is syntactically fine, the server is just failing to perform it
- * -- the same distinction cmd_starttls() already draws when TLS isn't
- * configured. cmdname is only used for the log line, kept separate from
- * the client-facing reply text (which stays generic) the same way
- * cmd_login()'s and cmd_starttls()'s replies do.
- */
-static int
-stub_not_implemented(struct session *s, const char *tag, const char *cmdname)
-{
- log_debug("session %u: %s not implemented (store.c's IMSG_MBOX_* "
- "wire protocol isn't designed yet)", s->id, cmdname);
- session_reply(s, tag, "NO", "not implemented");
- return (1);
-}
-
-/*
- * RFC 9051 SS6.3.1: `enable = "ENABLE" 1*(SP capability)` -- at least one
- * capability argument is mandatory (Result: "BAD - No arguments, or
- * syntax error in an argument"). For each argument, "If the argument is
- * not an extension known to the server, the server MUST ignore the
- * argument", and the untagged ENABLED response is sent unconditionally,
- * "even if no extensions were enabled" -- listing only what THIS command
- * actually newly enabled (SS6.3.1's own multi-ENABLE example: a second
- * ENABLE "should not" re-list an already-enabled extension).
- *
- * RFC 7162 addition this pass: CONDSTORE and QRESYNC are now real,
- * recognized ENABLE arguments (matched case-insensitively, per SS6.3.1's
- * own worked example using "ENABLE CONDSTORE"). Enabling QRESYNC also
- * enables CONDSTORE (RFC 7162 SS3.2.3: "the presence of the 'QRESYNC'
- * capability implies support for the CONDSTORE IMAP extension"), and -- a
- * deliberate implementation choice, matching how several real QRESYNC
- * servers behave, though the RFC doesn't explicitly mandate the ENABLED
- * echo specifically -- "ENABLE QRESYNC" alone reports both "QRESYNC" and
- * "CONDSTORE" as newly enabled, not just QRESYNC, since CONDSTORE really
- * is being enabled as a side effect of this exact command. Goes through
- * session_condstore_enable() so the "CONDSTORE enabling command issued
- * while a mailbox is already selected" unsolicited-HIGHESTMODSEQ case
- * (SS3.1) is handled uniformly with every other enabling command -- even
- * though RFC 9051 SS6.3.1 says ENABLE is only valid pre-SELECT and clients
- * "MUST NOT issue ENABLE" after selecting a mailbox, the same section adds
- * "server implementations don't have to check" for that, so this server
- * doesn't reject a late ENABLE outright; handling it correctly rather than
- * either rejecting or silently misbehaving is the safer choice.
- */
-static int
-cmd_enable(struct session *s, const char *tag, char *args)
-{
- char buf[64];
- char *tok, *save;
- int newly_condstore = 0, newly_qresync = 0;
-
- if (args == NULL) {
- session_reply(s, tag, "BAD",
- "ENABLE requires at least one capability argument");
- return (1);
- }
-
- for (tok = strtok_r(args, " ", &save); tok != NULL;
- tok = strtok_r(NULL, " ", &save)) {
- if (strcasecmp(tok, "QRESYNC") == 0) {
- if (!s->qresync_enabled) {
- s->qresync_enabled = 1;
- newly_qresync = 1;
- if (!s->condstore_enabled)
- newly_condstore = 1;
- }
- } else if (strcasecmp(tok, "CONDSTORE") == 0) {
- if (!s->condstore_enabled)
- newly_condstore = 1;
- }
- /* Anything else: not an extension this server advertises at
- * all -- SS6.3.1 says ignore it, so no else-branch needed. */
- }
-
- if (newly_condstore || newly_qresync)
- session_condstore_enable(s);
-
- buf[0] = '\0';
- if (newly_qresync)
- strlcat(buf, "QRESYNC", sizeof(buf));
- if (newly_condstore) {
- if (buf[0] != '\0')
- strlcat(buf, " ", sizeof(buf));
- strlcat(buf, "CONDSTORE", sizeof(buf));
- }
-
- if (buf[0] != '\0') {
- char untagged[80];
-
- snprintf(untagged, sizeof(untagged), "ENABLED %s", buf);
- session_untagged(s, untagged);
- } else
- session_untagged(s, "ENABLED");
-
- session_reply(s, tag, "OK", "ENABLE completed");
- return (1);
-}
-
-/*
- * Parses the interior of QRESYNC's own parenthesized argument list (RFC
- * 7162 SS3.2.5): `uidvalidity SP mod-sequence-value [SP known-uids [SP
- * seq-match-data]]`. inner is already NUL-terminated at its closing paren
- * by the caller (parse_select_params()) and modified in place.
- *
- * known-uids gets this codebase's usual single-range restriction (no
- * comma-separated sequence-set, matching FETCH/STORE/SEARCH's UID-range
- * scope elsewhere) and explicitly rejects "*", which SS3.2.5.1's own
- * grammar comment forbids here ("Sequence of UIDs; '*' is not allowed").
- * seq-match-data is parsed only enough to confirm balanced parentheses and
- * skipped -- its content is never sent to store.c at all (see imapd.h's
- * imsg_mbox_select comment for why: this implementation's chosen minimal
- * QRESYNC state model never uses it to narrow anything, which RFC 7162
- * SS5.2 explicitly sanctions as compliant, just less bandwidth-optimal).
- */
-static int
-parse_qresync_group(char *inner, struct imsg_mbox_select *req,
- const char **errmsg)
-{
- char *p, *tok;
- uint32_t uidvalidity;
- uint64_t modseq;
- char *ep;
-
- p = inner;
- while (*p == ' ')
- p++;
- tok = p;
- while (*p != '\0' && *p != ' ')
- p++;
- if (*p != '\0') {
- *p = '\0';
- p++;
- }
- if (parse_nz_number(tok, &uidvalidity) == -1) {
- *errmsg = "invalid QRESYNC uidvalidity";
- return (-1);
- }
-
- while (*p == ' ')
- p++;
- tok = p;
- while (*p != '\0' && *p != ' ')
- p++;
- if (*p != '\0') {
- *p = '\0';
- p++;
- }
- if (*tok == '\0') {
- *errmsg = "QRESYNC requires uidvalidity and mod-sequence";
- return (-1);
- }
- errno = 0;
- modseq = strtoull(tok, &ep, 10);
- if (*ep != '\0' || errno != 0) {
- *errmsg = "invalid QRESYNC mod-sequence";
- return (-1);
- }
-
- req->qresync_uidvalidity = uidvalidity;
- req->qresync_modseq = modseq;
- req->qresync_has_uids = 0;
-
- while (*p == ' ')
- p++;
- if (*p == '\0')
- return (0);
-
- if (*p == '(') {
- *errmsg = "QRESYNC seq-match-data requires known-uids first";
- return (-1);
- }
-
- tok = p;
- while (*p != '\0' && *p != ' ')
- p++;
- if (*p != '\0') {
- *p = '\0';
- p++;
- }
- if (strchr(tok, ',') != NULL) {
- *errmsg = "comma-separated known-uids not supported in v1";
- return (-1);
- }
- {
- uint32_t lo, hi;
- int lo_star, hi_star;
-
- if (parse_seq_range(tok, &lo, &hi, &lo_star, &hi_star) == -1 ||
- lo_star || hi_star) {
- *errmsg = "invalid known-uids -- '*' is not allowed "
- "here (RFC 7162 SS3.2.5.1)";
- return (-1);
- }
- req->qresync_has_uids = 1;
- req->qresync_uid_lo = lo;
- req->qresync_uid_hi = hi;
- }
-
- while (*p == ' ')
- p++;
- if (*p == '\0')
- return (0);
-
- if (*p != '(') {
- *errmsg = "expected seq-match-data";
- return (-1);
- }
- {
- char *end = p;
- int depth = 0;
-
- for (;;) {
- if (*end == '(')
- depth++;
- else if (*end == ')') {
- depth--;
- if (depth == 0)
- break;
- } else if (*end == '\0') {
- *errmsg = "unterminated seq-match-data";
- return (-1);
- }
- end++;
- }
- p = end + 1;
- }
-
- while (*p == ' ')
- p++;
- if (*p != '\0') {
- *errmsg = "unexpected data after QRESYNC arguments";
- return (-1);
- }
-
- return (0);
-}
-
-/*
- * Parses the interior of SELECT/EXAMINE's select-params list (RFC 4466's
- * generic syntax, extended by RFC 7162 SS3.1.8/SS3.2.5 with the CONDSTORE
- * and QRESYNC select-params): zero or more space-separated params, where
- * CONDSTORE is a bare token and QRESYNC is a token followed by its own
- * parenthesized argument group (parsed above). p is already NUL-terminated
- * at the outer list's closing paren by the caller (cmd_select()) and
- * modified in place.
- *
- * Sets req->qresync (and fills the rest of req's qresync_* fields via
- * parse_qresync_group()) and *want_condstore -- the latter is a plain
- * out-parameter rather than something written straight to s->condstore_
- * enabled, since cmd_select() itself decides exactly when to flip that (see
- * its own comment on why this doesn't go through session_condstore_
- * enable()).
- *
- * A QRESYNC select-param requires "ENABLE QRESYNC" to have already
- * succeeded this connection (RFC 7162 SS3.2.5: tagged BAD otherwise) --
- * checked here against s->qresync_enabled, since by the time this SELECT's
- * response could otherwise be built it would be too late to reject it
- * cleanly.
- */
-static int
-parse_select_params(char *p, struct imsg_mbox_select *req, struct session *s,
- int *want_condstore, const char **errmsg)
-{
- *want_condstore = 0;
-
- while (*p != '\0') {
- while (*p == ' ')
- p++;
- if (*p == '\0')
- break;
-
- if (strncasecmp(p, "CONDSTORE", 9) == 0 &&
- (p[9] == '\0' || p[9] == ' ')) {
- *want_condstore = 1;
- p += 9;
- continue;
- }
-
- if (strncasecmp(p, "QRESYNC", 7) == 0 &&
- (p[7] == '\0' || p[7] == ' ')) {
- char *q = p + 7;
- char *end;
- int depth;
-
- while (*q == ' ')
- q++;
- if (*q != '(') {
- *errmsg = "QRESYNC requires a parenthesized "
- "argument list";
- return (-1);
- }
- if (!s->qresync_enabled) {
- *errmsg = "QRESYNC select-param requires "
- "ENABLE QRESYNC first (RFC 7162 SS3.2.5)";
- return (-1);
- }
-
- depth = 0;
- end = q;
- for (;;) {
- if (*end == '(')
- depth++;
- else if (*end == ')') {
- depth--;
- if (depth == 0)
- break;
- } else if (*end == '\0') {
- *errmsg = "unterminated QRESYNC "
- "argument list";
- return (-1);
- }
- end++;
- }
- *end = '\0';
-
- if (parse_qresync_group(q + 1, req, errmsg) == -1)
- return (-1);
-
- req->qresync = 1;
- *want_condstore = 1; /* SS3.2.3: QRESYNC implies
- * CONDSTORE */
- p = end + 1;
- continue;
- }
-
- *errmsg = "unrecognized SELECT parameter";
- return (-1);
- }
-
- return (0);
-}
-
-/*
- * RFC 9051 SS6.3.2: `select = "SELECT" SP mailbox`, extended by RFC 4466's
- * generic select-param syntax and RFC 7162's CONDSTORE/QRESYNC select-
- * params (SS3.1.8/SS3.2.5): `select = "SELECT" SP mailbox [SP "(" select-
- * param *(SP select-param) ")"]`. args is whatever parse_command_line()
- * left after the command name -- a bare atom (e.g. "INBOX", or, as of RFC
- * 9051 SS6.3.4-SS6.3.6's flat multi-mailbox support, any other valid
- * mailbox name too -- see handle_mbox_select()'s comment in store.c) in
- * every real-world case, but RFC 9051's `mailbox` production also allows
- * a quoted string or a literal. Only the quoted-
- * string case is handled here (strip a single matching pair of double
- * quotes, no backslash-escape decoding) -- a deliberate simplification in
- * the same spirit as parse_command_line()'s own "not full ABNF conformance"
- * comment, not an oversight; IMAP literals ({n}-prefixed octet counts)
- * aren't supported anywhere in this codebase yet (see SESSION_INBUF_MAX's
- * comment).
- *
- * The mailbox token's own boundary is found first (respecting a leading
- * quote, so a quoted mailbox name doesn't get accidentally split at an
- * internal space), *then* the existing quote-stripping logic runs on just
- * that substring, unchanged from before this pass -- anything left over is
- * handed to parse_select_params().
- *
- * Shared by cmd_select() (readonly=0) and cmd_examine() (readonly=1): RFC
- * 9051 SS6.3.3 says "The EXAMINE command is identical to SELECT and returns
- * the same output" -- the only difference is that the selected mailbox
- * ends up marked read-only, which this function threads through as
- * req.readonly (to store.c, which SS6.3.2's own struct comment already
- * notes doesn't need to do anything different with it) and s->mbox_readonly
- * (to this session, consulted by session_handle_mbox_selected() for the
- * READ-ONLY/READ-WRITE response code and PERMANENTFLAGS list, and by every
- * command that can mutate the selected mailbox's permanent state). RFC 7162
- * SS3.1.8/SS3.2.5 extend both SELECT and EXAMINE with the identical
- * select-param syntax, so CONDSTORE/QRESYNC support doesn't need to
- * special-case either command.
- */
-static int
-select_or_examine(struct session *s, const char *tag, char *args, int readonly)
-{
- struct imsg_mbox_select req;
- char *mailbox_tok, *params, *p;
- size_t len;
- int want_condstore = 0;
- const char *cmdname = readonly ? "EXAMINE" : "SELECT";
-
- if (args == NULL) {
- char text[40];
-
- snprintf(text, sizeof(text), "%s requires a mailbox name",
- cmdname);
- session_reply(s, tag, "BAD", text);
- return (1);
- }
-
- p = args;
- if (*p == '"') {
- p++;
- while (*p != '\0' && *p != '"')
- p++;
- if (*p == '"')
- p++;
- } else {
- while (*p != '\0' && *p != ' ')
- p++;
- }
- mailbox_tok = args;
- if (*p == ' ') {
- *p = '\0';
- p++;
- while (*p == ' ')
- p++;
- params = (*p != '\0') ? p : NULL;
- } else if (*p == '\0') {
- params = NULL;
- } else {
- char text[40];
-
- snprintf(text, sizeof(text), "malformed %s arguments", cmdname);
- session_reply(s, tag, "BAD", text);
- return (1);
- }
-
- args = mailbox_tok;
- len = strlen(args);
- if (len >= 2 && args[0] == '"' && args[len - 1] == '"') {
- args[len - 1] = '\0';
- args++;
- len -= 2;
- }
- if (len == 0) {
- session_reply(s, tag, "BAD", "empty mailbox name");
- return (1);
- }
- if (len >= sizeof(req.mailbox)) {
- session_reply(s, tag, "BAD", "mailbox name too long");
- return (1);
- }
-
- if (s->store_iev == NULL) {
- /* Shouldn't happen -- ST_AUTH only dispatches here once
- * SESSION_AUTHENTICATED/SELECTED, both of which require
- * store_iev to already be wired (see listener_dispatch_
- * parent()'s IMSG_SETUP_PEER case) -- but this is exactly
- * the kind of internal-invariant check this codebase prefers
- * to state explicitly rather than silently assume. */
- log_warnx("session %u: %s with no store channel wired",
- s->id, cmdname);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
-
- memset(&req, 0, sizeof(req));
- strlcpy(req.mailbox, args, sizeof(req.mailbox));
- req.readonly = readonly;
-
- if (params != NULL) {
- size_t plen = strlen(params);
- const char *errmsg = NULL;
-
- if (plen < 2 || params[0] != '(' || params[plen - 1] != ')') {
- session_reply(s, tag, "BAD",
- "malformed select-param list");
- return (1);
- }
- params[plen - 1] = '\0';
-
- if (parse_select_params(params + 1, &req, s, &want_condstore,
- &errmsg) == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
- }
-
- /*
- * RFC 7162 SS3.1.8/SS3.2.3: a CONDSTORE or QRESYNC select-param
- * enables CONDSTORE (and, for QRESYNC, QRESYNC too -- though that
- * half only ever gets here already true, since parse_select_params()
- * requires it up front) for this session immediately -- not routed
- * through session_condstore_enable(), since that function's
- * unsolicited-HIGHESTMODSEQ-if-already-selected behavior would be
- * redundant here: this SELECT's own response (session_handle_mbox_
- * selected()) already includes HIGHESTMODSEQ as part of its normal
- * sequence once s->condstore_enabled is set, which is exactly what
- * this line accomplishes.
- */
- if (want_condstore)
- s->condstore_enabled = 1;
-
- /*
- * RFC 9051 SS6.3.2: "The SELECT command automatically deselects any
- * currently selected mailbox before attempting the new selection
- * ... the server MUST return an untagged OK response with the
- * '[CLOSED]' response code when the currently selected mailbox is
- * closed." Known synchronously, from s->state right now (about to
- * be overwritten below) -- no need to wait for store's reply to
- * say this, unlike the rest of the required SELECT responses.
- */
- if (s->state == SESSION_SELECTED)
- session_untagged(s, "OK [CLOSED] Previous mailbox is now closed");
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- s->state = SESSION_SELECTING;
- s->mbox_readonly = readonly;
- strlcpy(s->selected_mailbox, args, sizeof(s->selected_mailbox));
-
- if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_SELECT, 0, 0, -1,
- &req, sizeof(req)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_SELECT", s->id);
- imsgev_add(s->store_iev);
-
- return (1);
-}
-
-static int
-cmd_select(struct session *s, const char *tag, char *args)
-{
- return select_or_examine(s, tag, args, 0);
-}
-
-static int
-cmd_examine(struct session *s, const char *tag, char *args)
-{
- return select_or_examine(s, tag, args, 1);
-}
-
-/*
- * listener.c's own copy of store.c's mailbox_name_valid()/mailbox_name_
- * is_inbox() -- same rules (RFC 9051 SS5.1's CTL-character MAY, SS5.1.1's
- * reserved "/" hierarchy delimiter, the length cap, and the "tmp"/"new"/
- * "cur" reserved-name hazard documented at store.c's own copy), so a
- * client sending an obviously-invalid CREATE/DELETE/RENAME argument gets a
- * fast BAD/NO with zero store round trip, the same "cheap client-side
- * check before an async round trip" precedent cmd_status()'s INBOX-only
- * check already established. Not a substitute for store.c's own check --
- * store.c re-validates independently once its request arrives (see that
- * function's comment for the defense-in-depth rationale across the
- * privsep boundary); this is purely a fast-path optimization on this
- * side, so the two copies are kept deliberately in sync rather than
- * shared via some new cross-file helper that would be its own scope
- * creep.
- */
-static int
-mailbox_name_valid(const char *name)
-{
- size_t i, len;
-
- len = strlen(name);
- if (len == 0 || len >= MBOX_NAME_MAX)
- 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);
-
- return (1);
-}
-
-static int
-mailbox_name_is_inbox(const char *name)
-{
- return (strcasecmp(name, "INBOX") == 0);
-}
-
-/*
- * RFC 9051 SS6.3.4 CREATE: `create = "CREATE" SP mailbox` -- a single
- * mailbox-name argument, same token shape as LIST/STATUS's own mailbox
- * argument, so parse_list_token() is reused here too. "It is an error to
- * attempt to create INBOX" and "It is an error to attempt to create a
- * mailbox with a name that refers to an extant mailbox" -- the first is a
- * client-side check (mailbox_name_is_inbox()); the second can only be
- * answered by store.c, which owns the filesystem, so an extant-name
- * refusal always costs a real round trip (mkdir(2)'s own EEXIST, per
- * handle_mbox_create()'s design -- see docs/openimap-storage-backend.md
- * item 10).
- *
- * Flat v1 has no hierarchy for a trailing delimiter to declare "create
- * children of" (SS6.3.4's "If the mailbox name is suffixed with the
- * hierarchy separator" clause) -- mailbox_name_valid() already refuses any
- * name containing "/" outright, which also catches a trailing one, so
- * that clause is unreachable here rather than silently ignored partway
- * through.
- *
- * Async round trip: same shape as cmd_status()'s STATUS dispatch --
- * s->mbox_op_prev_state records whichever ST_AUTH state was current (CREATE
- * is command-auth, RFC 9051 SS6.3, valid in Authenticated or Selected
- * state, and never changes SELECTED-ness), s->pending_tag holds the tag,
- * s->state transitions to SESSION_CREATING until store.c's terminal
- * IMSG_MBOX_RESULT arrives at session_finish_mbox_op().
- */
-static int
-cmd_create(struct session *s, const char *tag, char *args)
-{
- struct imsg_mbox_create req;
- char mailbox[MBOX_NAME_MAX];
- char *p;
- const char *errmsg = NULL;
-
- if (args == NULL) {
- session_reply(s, tag, "BAD", "CREATE requires a mailbox name");
- return (1);
- }
-
- p = args;
- if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
-
- if (mailbox_name_is_inbox(mailbox)) {
- session_reply(s, tag, "NO", "[CANNOT] cannot create INBOX");
- return (1);
- }
- if (!mailbox_name_valid(mailbox)) {
- session_reply(s, tag, "BAD", "invalid mailbox name");
- return (1);
- }
-
- if (s->store_iev == NULL) {
- log_warnx("session %u: CREATE with no store channel wired",
- s->id);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
-
- memset(&req, 0, sizeof(req));
- strlcpy(req.mailbox, mailbox, sizeof(req.mailbox));
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- s->mbox_op_prev_state = s->state;
- s->state = SESSION_CREATING;
-
- 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);
- imsgev_add(s->store_iev);
-
- return (1);
-}
-
-/*
- * RFC 9051 SS6.3.5 DELETE: `delete = "DELETE" SP mailbox`. "It is an error
- * to attempt to delete INBOX" and "It is an error to attempt to delete a
- * mailbox that does not exist" -- same split as CREATE: INBOX is a client-
- * side check, existence is store.c's (handle_mbox_delete()'s own stat(2)
- * check -- see docs/openimap-storage-backend.md item 10). Flat v1's other
- * DELETE sub-rules (inferior hierarchical names, the \Noselect-plus-
- * children carve-out) are moot by construction -- no mailbox here can ever
- * have children -- so nothing else needs checking on either side.
- *
- * Same async shape as cmd_create() -- see that function's comment.
- */
-static int
-cmd_delete(struct session *s, const char *tag, char *args)
-{
- struct imsg_mbox_delete req;
- char mailbox[MBOX_NAME_MAX];
- char *p;
- const char *errmsg = NULL;
-
- if (args == NULL) {
- session_reply(s, tag, "BAD", "DELETE requires a mailbox name");
- return (1);
- }
-
- p = args;
- if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
-
- if (mailbox_name_is_inbox(mailbox)) {
- session_reply(s, tag, "NO", "[CANNOT] cannot delete INBOX");
- return (1);
- }
- if (!mailbox_name_valid(mailbox)) {
- /* RFC 5530 NONEXISTENT: an invalid name can never have
- * existed, same worked-example fit STATUS's own non-INBOX
- * check already uses. */
- session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
- return (1);
- }
-
- if (s->store_iev == NULL) {
- log_warnx("session %u: DELETE with no store channel wired",
- s->id);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
-
- memset(&req, 0, sizeof(req));
- strlcpy(req.mailbox, mailbox, sizeof(req.mailbox));
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- s->mbox_op_prev_state = s->state;
- s->state = SESSION_DELETING;
-
- 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);
- imsgev_add(s->store_iev);
-
- return (1);
-}
-
-/*
- * RFC 9051 SS6.3.6 RENAME: `rename = "RENAME" SP mailbox SP mailbox`
- * (existing-name, new-name). "It is an error to attempt to rename from a
- * mailbox name that does not exist or to a mailbox name that already
- * exists" -- both existence checks are store.c's (handle_mbox_rename()'s
- * own stat(2) checks). Renaming *to* INBOX is already covered by "already
- * exists" (INBOX always exists for an authenticated session), so no
- * separate client-side check is needed for the destination; renaming
- * *from* INBOX is a distinct rule ("using the special name INBOX as the
- * source... some servers disallow renaming INBOX") that v1 takes the
- * RFC's own sanctioned refusal on -- see docs/openimap-storage-backend.md
- * item 10 for the full citation -- checked here, client-side, same as
- * CREATE/DELETE's own INBOX checks.
- *
- * Same async shape as cmd_create()/cmd_delete() -- see cmd_create()'s
- * comment.
- */
-static int
-cmd_rename(struct session *s, const char *tag, char *args)
-{
- struct imsg_mbox_rename req;
- char oldname[MBOX_NAME_MAX];
- char newname[MBOX_NAME_MAX];
- char *p;
- const char *errmsg = NULL;
-
- if (args == NULL) {
- session_reply(s, tag, "BAD",
- "RENAME requires two mailbox names");
- return (1);
- }
-
- p = args;
- if (parse_list_token(&p, oldname, sizeof(oldname), &errmsg) == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
- if (parse_list_token(&p, newname, sizeof(newname), &errmsg) == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
-
- if (mailbox_name_is_inbox(oldname)) {
- /* RFC 9051 SS6.3.6 explicitly sanctions this refusal -- see
- * this function's header comment. RFC 5530 has no sharper
- * fit than CANNOT ("The operation violates some invariant
- * of the server and can never succeed"). */
- session_reply(s, tag, "NO", "[CANNOT] cannot rename INBOX");
- return (1);
- }
- if (!mailbox_name_valid(oldname)) {
- session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
- return (1);
- }
- if (mailbox_name_is_inbox(newname) || !mailbox_name_valid(newname)) {
- session_reply(s, tag, "BAD", "invalid mailbox name");
- return (1);
- }
-
- if (s->store_iev == NULL) {
- log_warnx("session %u: RENAME with no store channel wired",
- s->id);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
-
- memset(&req, 0, sizeof(req));
- strlcpy(req.oldname, oldname, sizeof(req.oldname));
- strlcpy(req.newname, newname, sizeof(req.newname));
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- strlcpy(s->rename_oldname, oldname, sizeof(s->rename_oldname));
- strlcpy(s->rename_newname, newname, sizeof(s->rename_newname));
- s->mbox_op_prev_state = s->state;
- s->state = SESSION_RENAMING;
-
- 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);
- imsgev_add(s->store_iev);
-
- return (1);
-}
-
-static int
-cmd_subscribe(struct session *s, const char *tag, char *args)
-{
- (void)args;
- return stub_not_implemented(s, tag, "SUBSCRIBE");
-}
-
-static int
-cmd_unsubscribe(struct session *s, const char *tag, char *args)
-{
- (void)args;
- return stub_not_implemented(s, tag, "UNSUBSCRIBE");
-}
-
-/*
- * RFC 9051 SS6.3.9's wildcard matching ("*" matches zero or more
- * characters including a hierarchy delimiter; "%" is the same but does
- * NOT match a delimiter), restricted to what it actually needs to do in
- * v1's flat, non-nested namespace: no delimiter can ever occur within any
- * candidate name (mailbox_name_valid() refuses "/" outright for every
- * real mailbox, and the fixed literal "INBOX" obviously has none either),
- * so the one behavioral difference between "*" and "%" never has anything
- * to bite on -- both are treated identically here as "match zero or more
- * of anything." This is a real simplification specific to v1's flat
- * namespace, not a general-purpose IMAP wildcard matcher; it would need
- * to actually distinguish the two if this server ever grew a real nested
- * hierarchy. SS6.3.9's further note that a trailing "%" also causes
- * intermediate, not-yet-selectable levels of hierarchy to be returned
- * with \Noselect doesn't apply here either, for the same reason: v1 has
- * no intermediate levels for a trailing "%" to ever expose.
- *
- * ci (case-insensitive) is the caller's choice, not baked in: RFC 9051
- * SS5.1's case-insensitivity is scoped to the single reserved name INBOX
- * ("The case-insensitive mailbox name INBOX..."), not to mailbox names in
- * general -- real, named mailboxes (RFC 9051 SS6.3.4-SS6.3.6) are
- * case-sensitive on this server, matching store.c's own strcmp() (not
- * strcasecmp()) for every name but INBOX throughout mailbox_name_valid()/
- * handle_mbox_create()/handle_mbox_delete()/handle_mbox_rename(). Callers
- * pass ci=1 only when matching against the literal "INBOX".
- *
- * Iterative two-pointer wildcard match with a single remembered backtrack
- * point -- the standard technique behind glob(3)-family matchers, bounded
- * at O(n*m) time in the worst case rather than the exponential blowup a
- * naive recursive backtracking matcher (what this function used to be)
- * hits on adversarial input.
- *
- * Found during manual security review: the previous implementation tried
- * every possible split point for a wildcard via plain recursion, with no
- * memoization. An alternating pattern like "a%a%a%...%a" matched against a
- * same-shaped, non-fully-matching name is the textbook case for
- * catastrophic backtracking in that style of matcher -- each wildcard's
- * "try matching here, then here, then here..." loop calls back into a
- * function that repeats the same search over the remaining wildcards,
- * multiplying out exponentially. Both operands here are attacker-reachable
- * by an authenticated user (a mailbox name via CREATE, up to MBOX_NAME_MAX
- * bytes; the LIST pattern itself, up to 2*MBOX_NAME_MAX bytes as `canon`),
- * and listener.c runs one event loop across every session, so a single
- * pathological LIST could have stalled the process for every connected
- * session, not just the one that sent it -- not a memory-safety bug, but a
- * real CPU-exhaustion DoS.
- *
- * This rewrite keeps this function's own established, deliberate
- * simplifications unchanged: "*" and "%" are still treated identically
- * (see the header comment above -- v1's flat namespace makes the RFC's
- * delimiter-crossing distinction between them moot), and ci is still the
- * caller's choice, not baked in. Only the matching algorithm itself
- * changed, not what it matches.
- *
- * Standard algorithm: walk pat and name together; on a literal mismatch,
- * if a wildcard was seen earlier, retry from just after that wildcard
- * with one additional character of name absorbed by it (star_s tracks how
- * much the most recent wildcard has already absorbed) rather than
- * recursing into a fresh search. Because star_s only ever advances
- * forward, the whole scan is bounded by name's length times the number of
- * wildcards in pat, not exponential in either.
- */
-static int
-list_pattern_match(const char *pat, const char *name, int ci)
-{
- const char *p = pat;
- const char *s = name;
- const char *star_p = NULL; /* pat position just past the most
- * recently seen wildcard run */
- const char *star_s = NULL; /* name position that wildcard has
- * absorbed through so far */
-
- /*
- * Loop is driven by "any name bytes left to consume", exactly like
- * the reference iterative algorithm (LeetCode-style "wildcard
- * matching", translated to pointers) -- not an unconditional loop
- * with internal breaks. That distinction matters: an earlier draft
- * of this fix used a for(;;) with breaks and left star_s un-updated
- * on an ordinary character match, so a stale star_s could still
- * read non-'\0' after p and s both legitimately reached the end,
- * triggering a spurious extra backtrack that broke any pattern with
- * a literal after a wildcard (e.g. "A*Z" against "AhelloZ") --
- * caught by this fix's own standalone regression test before ever
- * reaching store.c/listener.c, not by inspection.
- */
- while (*s != '\0') {
- if (*p == '*' || *p == '%') {
- while (*p == '*' || *p == '%')
- p++;
- star_p = p;
- star_s = s;
- } else if (*p != '\0' && (ci ?
- toupper((unsigned char)*p) == toupper((unsigned char)*s) :
- *p == *s)) {
- p++;
- s++;
- } else if (star_p != NULL) {
- star_s++;
- s = star_s;
- p = star_p;
- } else {
- return (0);
- }
- }
-
- while (*p == '*' || *p == '%')
- p++;
- return (*p == '\0');
-}
-
-/*
- * Pulls one `list-mailbox`-shaped token (RFC 9051 SS9: `list-mailbox =
- * 1*list-char / string`) off *pp, advancing *pp past it -- used for both
- * of LIST's basic-syntax positional arguments (reference name, mailbox
- * pattern). The quoted-string case strips one matching pair of DQUOTEs
- * with no backslash-escape decoding, same deliberate simplification
- * cmd_select()'s own mailbox-argument comment already documents; the
- * unquoted case is simply "everything up to the next space", which is a
- * superset of "1*list-char" (ATOM-CHAR / list-wildcards / resp-specials)
- * that doesn't bother validating individual characters -- consistent
- * with this file's general practice of not fully policing atom-syntax
- * conformance. The `string` grammar alternative also allows an IMAP
- * literal ("{n}"-prefixed) reference/pattern; not supported here, same
- * gap cmd_select()'s mailbox argument already has.
- *
- * An empty quoted string ("") is a legal zero-length token and is
- * accepted here -- both LIST's basic-syntax empty-mailbox special case
- * and an empty reference argument (RFC 9051: "Clients SHOULD use the
- * empty reference argument") depend on that.
- */
-static int
-parse_list_token(char **pp, char *out, size_t outsize, const char **errmsg)
-{
- char *p = *pp;
-
- while (*p == ' ')
- p++;
-
- if (*p == '\0') {
- *errmsg = "LIST requires two arguments";
- return (-1);
- }
-
- if (*p == '"') {
- char *start = p + 1;
- char *end = strchr(start, '"');
- size_t len;
-
- if (end == NULL) {
- *errmsg = "unterminated quoted string";
- return (-1);
- }
- len = (size_t)(end - start);
- if (len >= outsize) {
- *errmsg = "argument too long";
- return (-1);
- }
- memcpy(out, start, len);
- out[len] = '\0';
- p = end + 1;
- } else {
- char *start = p;
- size_t len;
-
- while (*p != '\0' && *p != ' ')
- p++;
- len = (size_t)(p - start);
- if (len >= outsize) {
- *errmsg = "argument too long";
- return (-1);
- }
- memcpy(out, start, len);
- out[len] = '\0';
- }
-
- *pp = p;
- return (0);
-}
-
-/*
- * RFC 9051 SS6.3.9: basic syntax only -- `list = "LIST" SP mailbox SP
- * mbox-or-pat` where mbox-or-pat here is a single list-mailbox, not the
- * parenthesized `patterns` alternative, and with no list-select-opts or
- * list-return-opts. Extended syntax (detected per SS6.3.9's own three
- * conditions: select-opts as the first word, a parenthesized pattern
- * list as the second word, or more than two parameters) is recognized
- * but rejected with NO, not BAD -- openimap-v1-dispatch.md's own scope
- * note for LIST is "v1 should support at minimum the basic form
- * cleanly", so this is a deliberate, flagged scope cut, not a syntax
- * error.
- *
- * INBOX itself is answered synchronously, with no store round trip --
- * it always exists unconditionally for any authenticated session (there's
- * no CREATE that could make its existence conditional), so matching it is
- * pure string/wildcard matching against the fixed name "INBOX", nothing
- * store.c needs to be asked about. Real, named mailboxes (RFC 9051
- * SS6.3.4-SS6.3.6 multi-mailbox support, docs/openimap-storage-backend.md
- * item 10) are a different story -- only store.c can enumerate what
- * actually exists on disk in this session's own maildir root, so those go
- * through a real IMSG_MBOX_LIST/SESSION_LISTING round trip (see this
- * function's tail and session_finish_list()/session_handle_mbox_list_
- * item()) once the INBOX-only synchronous check above is done.
- *
- * LSUB (RFC 3501 -- dropped from base IMAP4rev2, but still sent by real
- * clients for back-compat, e.g. Apple Mail's very first mailbox-listing
- * round trip): real bug caught testing against Apple Mail live on
- * premio -- LSUB wasn't in the dispatch table at all, so it fell through
- * to session_handle_line()'s generic "Unknown command" BAD, which (paired
- * with the FETCH gap fixed just below) left Mail with literally nothing
- * to render, hence a blank Inbox. v1 has no subscription list to
- * maintain (no UNSUBSCRIBE, no per-mailbox subscribed bit stored
- * anywhere) and only one mailbox that could ever be subscribed to, so
- * "LSUB matched X" and "LIST matched X, and X happens to always be
- * subscribed" are indistinguishable in this server -- is_lsub only
- * changes the response keyword (LSUB vs LIST) and the completion text,
- * not the matching logic itself.
- */
-static int
-list_dispatch(struct session *s, const char *tag, char *args, int is_lsub)
-{
- char reference[MBOX_NAME_MAX];
- char pattern[MBOX_NAME_MAX];
- char canon[2 * MBOX_NAME_MAX];
- char *p;
- const char *errmsg;
- const char *cmdname = is_lsub ? "LSUB" : "LIST";
- const char *kw = is_lsub ? "LSUB" : "LIST";
- char text[64];
-
- if (args == NULL) {
- snprintf(text, sizeof(text), "%s requires two arguments",
- cmdname);
- session_reply(s, tag, "BAD", text);
- return (1);
- }
-
- p = args;
- while (*p == ' ')
- p++;
-
- if (*p == '(') {
- /* SS6.3.9 condition 1: "the first word after the command
- * name begins with a parenthesis" -- list-select-opts. */
- snprintf(text, sizeof(text),
- "extended %s selection options not supported", cmdname);
- session_reply(s, tag, "NO", text);
- return (1);
- }
-
- if (parse_list_token(&p, reference, sizeof(reference), &errmsg) ==
- -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
-
- while (*p == ' ')
- p++;
-
- if (*p == '(') {
- /* SS6.3.9 condition 2: "the second word after the command
- * name begins with a parenthesis" -- the parenthesized
- * `patterns` form of mbox-or-pat, not a single
- * list-mailbox. */
- snprintf(text, sizeof(text),
- "extended %s mailbox-pattern lists not supported",
- cmdname);
- session_reply(s, tag, "NO", text);
- return (1);
- }
-
- if (parse_list_token(&p, pattern, sizeof(pattern), &errmsg) == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
-
- while (*p == ' ')
- p++;
- if (*p != '\0') {
- /* SS6.3.9 condition 3: "the LIST command has more than 2
- * parameters" -- trailing list-return-opts. */
- snprintf(text, sizeof(text),
- "extended %s return options not supported", cmdname);
- session_reply(s, tag, "NO", text);
- return (1);
- }
-
- /*
- * RFC 9051 SS6.3.9: "In the basic syntax only, an empty ('' string)
- * mailbox name argument is a special request to return the
- * hierarchy delimiter and the root name of the name given in the
- * reference... The value returned as the root MAY be the empty
- * string if the reference is non-rooted or is an empty string."
- * v1 has no rooting/hierarchy concept at all to resolve a non-empty
- * reference against -- there is still no real folder tree to root
- * anything in, only INBOX -- so taking the RFC's own "MAY be empty"
- * allowance and always returning an empty root, regardless of the
- * reference argument's contents, is a real simplification but a
- * spec-permitted one, not a violation. "/" is the same real
- * hierarchy delimiter SELECT's own untagged LIST response and
- * cmd_namespace()'s NAMESPACE response both use -- a resolved
- * project decision as of a later pass (openimap-storage-backend.md,
- * "Open items carried from this session" #9), not a borrowed
- * placeholder anymore.
- */
- if (pattern[0] == '\0') {
- snprintf(text, sizeof(text), "%s (\\Noselect) \"/\" \"\"", kw);
- session_untagged(s, text);
- snprintf(text, sizeof(text), "%s completed", cmdname);
- session_reply(s, tag, "OK", text);
- return (1);
- }
-
- /*
- * Canonical LIST pattern: reference concatenated with the mailbox
- * pattern. RFC 9051 SS6.3.9: "If a server implementation has no
- * concept of break out characters, the canonical form is normally
- * the reference name appended with the mailbox name" -- squarely
- * true here: v1 has no "current working directory"/break-out-
- * character concept at all (a single flat INBOX-only namespace), so
- * plain concatenation is the correct reading of that sentence for
- * this server, not just the convenient one.
- */
- {
- size_t n;
-
- n = strlcpy(canon, reference, sizeof(canon));
- if (n < sizeof(canon))
- n = strlcat(canon, pattern, sizeof(canon));
- if (n >= sizeof(canon)) {
- session_reply(s, tag, "BAD",
- "combined reference and pattern too long");
- return (1);
- }
- }
-
- /*
- * SS6.3.9: "Any syntactically valid pattern that is not accepted by
- * a server for any reason MUST be silently ignored, i.e., it
- * results in no LIST responses, and the LIST command still returns
- * a tagged OK response." Answered synchronously and immediately for
- * INBOX specifically -- it always exists for any authenticated
- * session (there's no CREATE that could make its existence
- * conditional), so matching it needs no store round trip, same
- * "nothing store.c needs to be asked about" reasoning this
- * function's header comment already gives.
- */
- if (list_pattern_match(canon, "INBOX", 1)) {
- /* "()" -- no attributes -- the same choice, for the same
- * reason, as SELECT's own untagged LIST response: INBOX has
- * no children and is selectable, and SS7.3.1 makes every
- * attribute here optional ("MAY send none of these"). */
- snprintf(text, sizeof(text), "%s () \"/\" INBOX", kw);
- session_untagged(s, text);
- }
-
- if (s->store_iev == NULL) {
- log_warnx("session %u: %s with no store channel wired",
- s->id, cmdname);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
-
- /*
- * RFC 9051 SS6.3.4-SS6.3.6 multi-mailbox support (docs/openimap-
- * storage-backend.md item 10): beyond INBOX, real mailbox names now
- * live on disk, and only store.c can enumerate them (this session's
- * own maildir root, chrooted/unveiled per-session -- listener.c has
- * no filesystem access of its own to do this locally the way the
- * INBOX-only check above still can). IMSG_MBOX_LIST takes no
- * request payload (handle_mbox_list() just opendir(2)s "."), so
- * nothing needs to be built here beyond the imsg itself -- s->list_
- * pattern (the same canonical reference+pattern concatenation just
- * used for the INBOX check above) and s->list_is_lsub are stashed
- * for session_handle_mbox_list_item() to test each streamed name
- * against as it arrives.
- */
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- strlcpy(s->list_pattern, canon, sizeof(s->list_pattern));
- s->list_is_lsub = is_lsub;
- s->mbox_op_prev_state = s->state;
- s->state = SESSION_LISTING;
-
- 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);
- imsgev_add(s->store_iev);
-
- return (1);
-}
-
-static int
-cmd_list(struct session *s, const char *tag, char *args)
-{
- return list_dispatch(s, tag, args, 0);
-}
-
-static int
-cmd_lsub(struct session *s, const char *tag, char *args)
-{
- return list_dispatch(s, tag, args, 1);
-}
-
-/*
- * RFC 9051 SS6.3.10 NAMESPACE: `namespace-response = "NAMESPACE" SP
- * namespace SP namespace SP namespace` -- Personal, then Other Users',
- * then Shared, each either NIL or a parenthesized list of `(prefix
- * delimiter)` pairs. Answered entirely locally, no store round trip, same
- * "v1 is single-mailbox, nothing for store.c to be asked" precedent as
- * cmd_list() -- NAMESPACE doesn't even take a mailbox-name argument for
- * store.c to validate the way LIST/STATUS/SELECT do.
- *
- * v1's hierarchy delimiter ("/") and Personal Namespace prefix ("") are
- * now a real, sourced design decision (openimap-storage-backend.md, "Open
- * items carried from this session" #9) rather than the placeholder
- * SELECT's own LIST response had been borrowing -- this is RFC 9051
- * SS6.3.10's own Example 1, verbatim: "a server supports a single
- * Personal Namespace. No leading prefix is used on personal mailboxes,
- * and '/' is the hierarchy delimiter" -> `* NAMESPACE (("" "/")) NIL
- * NIL`. No Other Users' Namespace or Shared Namespace (both NIL) -- v1 is
- * single-user with no shared-mailbox concept at all, so there's nothing
- * for either to expose. Arguments are ignored (RFC 9051 SS6.3.10:
- * "Arguments: none" -- session_handle_line()'s generic parser already
- * hands cmd_*() functions whatever trailed the command name, if
- * anything, same as e.g. cmd_capability() does; a client sending garbage
- * after NAMESPACE gets a clean OK rather than a pedantic BAD, consistent
- * with this codebase's existing leniency elsewhere for command-any/
- * command-auth commands that formally take no arguments).
- */
-static int
-cmd_namespace(struct session *s, const char *tag, char *args)
-{
- (void)args;
- session_untagged(s, "NAMESPACE ((\"\" \"/\")) NIL NIL");
- session_reply(s, tag, "OK", "NAMESPACE command completed");
- return (1);
-}
-
-/*
- * RFC 9051 SS6.3.11 STATUS: `status = "STATUS" SP mailbox SP "("
- * status-att *(SP status-att) ")"`. "does not change the currently
- * selected mailbox, nor does it affect the state of any messages" --
- * SESSION_STATUSING is a purely transient async-wait state (see that
- * enum value's comment); s->status_prev_state records whichever ST_AUTH
- * state (SESSION_AUTHENTICATED or SESSION_SELECTED) was current so
- * session_handle_mbox_status_result() can restore it, same pattern
- * cmd_append() already established for the identical "command-auth, not
- * command-select" situation.
- *
- * Mailbox-name argument reuses parse_list_token() (LIST's own token
- * parser -- STATUS's `mailbox` production is the same ABNF shape as one
- * of LIST's two arguments). RFC 9051 SS6.3.4-SS6.3.6 multi-mailbox support
- * (docs/openimap-storage-backend.md item 10) means a non-INBOX name can now
- * genuinely exist, so the only client-side check left is mailbox_name_
- * valid() (obviously-malformed names get a fast NONEXISTENT with zero store
- * round trip, same as CREATE/DELETE/RENAME's own fast path) -- actual
- * existence is store.c's call now, the same "delegates the check to
- * store.c because it needs a round trip regardless" reasoning cmd_select()
- * already used, not the old INBOX-only shortcut this comment used to
- * describe.
- */
-static int
-cmd_status(struct session *s, const char *tag, char *args)
-{
- struct imsg_mbox_status req;
- char mailbox[MBOX_NAME_MAX];
- char *p, *atts, *tok, *save;
- char attbuf[256];
- const char *errmsg = NULL;
- uint32_t attrs = 0;
- size_t plen;
-
- if (args == NULL) {
- session_reply(s, tag, "BAD",
- "STATUS requires a mailbox name and status-att list");
- return (1);
- }
-
- p = args;
- if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
-
- while (*p == ' ')
- p++;
- atts = p;
- plen = strlen(atts);
- if (plen < 2 || atts[0] != '(' || atts[plen - 1] != ')') {
- session_reply(s, tag, "BAD",
- "STATUS requires a parenthesized status-att list");
- return (1);
- }
- atts[plen - 1] = '\0';
- atts++;
-
- if (strlcpy(attbuf, atts, sizeof(attbuf)) >= sizeof(attbuf)) {
- session_reply(s, tag, "BAD", "status-att list too long");
- return (1);
- }
- for (tok = strtok_r(attbuf, " ", &save); tok != NULL;
- tok = strtok_r(NULL, " ", &save)) {
- if (strcasecmp(tok, "MESSAGES") == 0)
- attrs |= STATUS_ATT_MESSAGES;
- else if (strcasecmp(tok, "UIDNEXT") == 0)
- attrs |= STATUS_ATT_UIDNEXT;
- else if (strcasecmp(tok, "UIDVALIDITY") == 0)
- attrs |= STATUS_ATT_UIDVALIDITY;
- else if (strcasecmp(tok, "UNSEEN") == 0)
- attrs |= STATUS_ATT_UNSEEN;
- else if (strcasecmp(tok, "DELETED") == 0)
- attrs |= STATUS_ATT_DELETED;
- else if (strcasecmp(tok, "SIZE") == 0)
- attrs |= STATUS_ATT_SIZE;
- else if (strcasecmp(tok, "HIGHESTMODSEQ") == 0)
- attrs |= STATUS_ATT_HIGHESTMODSEQ;
- else {
- session_reply(s, tag, "BAD", "unknown status-att");
- return (1);
- }
- }
-
- /*
- * RFC 9051 SS9's `status-att-list` ABNF is `status-att *(SP
- * status-att)` on the request side -- at least one is required,
- * unlike the *response* side's `mailbox-data` production, `"STATUS"
- * SP mailbox SP "(" [status-att-list] ")"`, whose square brackets
- * explicitly allow empty parens. An empty "()" here is therefore a
- * client syntax error, not a legal "ask for nothing" request.
- */
- if (attrs == 0) {
- session_reply(s, tag, "BAD",
- "STATUS requires at least one status-att");
- return (1);
- }
-
- if (!mailbox_name_is_inbox(mailbox) && !mailbox_name_valid(mailbox)) {
- /* RFC 5530: NONEXISTENT -- its own worked example is
- * literally "No such mailbox". A malformed name can never
- * have existed, so this is answered client-side, same fast
- * path CREATE/DELETE/RENAME use -- an otherwise-valid name
- * that simply doesn't exist on disk is store.c's call now
- * (handle_mbox_status()'s own select_mailbox_dir() failure
- * path), not this one's. */
- session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
- return (1);
- }
-
- if (s->store_iev == NULL) {
- /* Same internal-invariant check as cmd_select()/cmd_fetch()/
- * cmd_store_cmd() -- ST_AUTH requires store_iev to already
- * be wired. */
- log_warnx("session %u: STATUS with no store channel wired",
- s->id);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
-
- /*
- * RFC 7162 SS3.1: STATUS (HIGHESTMODSEQ) is one of the six
- * CONDSTORE-enabling commands. Called synchronously here, before
- * s->state is overwritten below, matching fetch_dispatch()/
- * store_do()'s own ordering -- session_condstore_enable()'s
- * unsolicited-HIGHESTMODSEQ-if-already-selected check depends on
- * s->state == SESSION_SELECTED still being whatever it was when
- * this command was dispatched.
- */
- if (attrs & STATUS_ATT_HIGHESTMODSEQ)
- session_condstore_enable(s);
-
- memset(&req, 0, sizeof(req));
- strlcpy(req.mailbox, mailbox, sizeof(req.mailbox));
- req.attrs = attrs;
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- strlcpy(s->status_mailbox, mailbox, sizeof(s->status_mailbox));
- s->status_attrs = attrs;
- s->status_prev_state = s->state;
- s->state = SESSION_STATUSING;
-
- 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);
- imsgev_add(s->store_iev);
-
- return (1);
-}
-
-/*
- * v1's whole-message-in-one-imsg design (imapd.h's imsg_mbox_append
- * comment) needs the announced literal size to fit inside a single imsg.
- * Confirmed directly against the real imsg.c's imsg_create() this
- * session: `datalen += IMSG_HEADER_SIZE; if (datalen > imsgbuf->maxsize)
- * ... return NULL`, with maxsize set to MAX_IMSGSIZE (16384, imsg.h) by
- * imsgev_init(). struct imsg_mbox_append's own fixed fields (mailbox
- * MBOX_NAME_MAX=256, keywords MBOX_FLAGS_MAX=256, plus a handful of
- * ints/int64s) come to a bit over 500 bytes; 12000 leaves comfortable
- * headroom under that 16384 ceiling for both that and the imsg header
- * itself, while still covering v1's expected "personal notes/drafts/
- * small saved messages" use case. A message larger than that needs real
- * fd-passing, not implemented this pass; rejected with a plain NO (RFC
- * 9051 defines no response code for a size cap) rather than truncating or
- * crashing.
- *
- * Now defined in imapd.h (moved there when BODY.PEEK[]/BODY.PEEK[TEXT]
- * were added to store.c's read_message_body()): no message can legally
- * exist on disk larger than this cap in the first place, since APPEND is
- * v1's only way to create one, so reusing the same symbol for reading a
- * message back out -- rather than inventing a second, independently-
- * maintained size constant -- keeps the two enforcement points from ever
- * silently drifting apart.
- */
-
-/* Defined further down alongside format_internaldate(); forward-declared
- * here so parse_date_time() (which needs the same month-name table, just
- * in the reverse direction) can reuse it instead of duplicating it. */
-static const char *fetch_month_names[12];
-
-/*
- * Reverse of format_internaldate(): parses an RFC 9051 SS9 `date-time`
- * (already stripped of its surrounding DQUOTEs by the caller) --
- * `date-day-fixed "-" date-month "-" date-year SP time SP zone`. Uses
- * plain C89 sscanf(3), not anything OpenBSD-specific -- unlike strlcpy()
- * etc. elsewhere in this file, this doesn't need separate verification
- * against real OpenBSD headers. "%2d" on a space-padded single-digit day
- * (SS9's `date-day-fixed = (SP DIGIT) / 2DIGIT`) works without special-
- * casing: scanf's leading-whitespace skip for %d isn't counted against
- * the field width, so " 7" and "07" both parse to day=7. "%3s" for the
- * month name relies on month abbreviations always being exactly 3 letters
- * (SS9's `date-month` production lists only "Jan".."Dec") to land exactly
- * on the following "-" with no whitespace between them.
- *
- * Builds a struct tm from the fixed-format fields and uses timegm(3) (UTC
- * interpretation, no DST, no local-timezone dependency -- appropriate
- * since the fields already carry their own explicit zone offset) to get
- * a UTC instant for the wall-clock fields alone, then subtracts the
- * parsed zone offset to get the true UTC timestamp: a "-0800" zone means
- * the wall-clock time is 8 hours behind UTC, so UTC = wall_time -
- * (-8h) = wall_time + 8h, i.e. timegm(tm) - zone_offset_seconds where
- * zone_offset_seconds is already negative for a "-" zone.
- *
- * Only coarse range checks (day 1-31, hour/minute/second in range, a
- * recognized month name, a 4-digit year not before the Unix epoch) --
- * calendar validity (e.g. "31-Feb-2026") is NOT checked; timegm(3)
- * normalizes an out-of-range day forward rather than erroring, which is
- * accepted here as reasonable non-error behavior rather than treated as
- * a bug worth guarding against, consistent with this file's general
- * "reject clearly malformed input, don't chase every edge case" style.
- */
-static int
-parse_date_time(const char *s, int64_t *out)
-{
- struct tm tm;
- char mon[4];
- int day, year, hh, mm, ss, zh, zm, i;
- char zsign;
- time_t t;
- int64_t zoff;
-
- memset(&tm, 0, sizeof(tm));
-
- if (sscanf(s, "%2d-%3s-%4d %2d:%2d:%2d %c%2d%2d", &day, mon, &year,
- &hh, &mm, &ss, &zsign, &zh, &zm) != 9)
- return (-1);
-
- if (day < 1 || day > 31 || hh < 0 || hh > 23 || mm < 0 || mm > 59 ||
- ss < 0 || ss > 60 || year < 1970 ||
- (zsign != '+' && zsign != '-'))
- return (-1);
-
- for (i = 0; i < 12; i++) {
- if (strcasecmp(mon, fetch_month_names[i]) == 0)
- break;
- }
- if (i == 12)
- return (-1);
-
- tm.tm_mday = day;
- tm.tm_mon = i;
- tm.tm_year = year - 1900;
- tm.tm_hour = hh;
- tm.tm_min = mm;
- tm.tm_sec = ss;
-
- if ((t = timegm(&tm)) == (time_t)-1)
- return (-1);
-
- zoff = (int64_t)zh * 3600 + (int64_t)zm * 60;
- if (zsign == '-')
- zoff = -zoff;
-
- *out = (int64_t)t - zoff;
- return (0);
-}
-
-/* Parsed result of parse_append_args() below -- kept together as one
- * struct, unlike parse_seq_range()'s several out-parameters, simply
- * because there are too many fields here for that style to stay
- * readable. */
-struct append_parsed {
- char mailbox[MBOX_NAME_MAX];
- uint32_t sysflags;
- char keywords[MBOX_FLAGS_MAX];
- int has_date;
- int64_t date;
- uint64_t litlen;
- int litnonsync;
-};
-
-/*
- * RFC 9051 SS6.3.12: `append = "APPEND" SP mailbox [SP flag-list] [SP
- * date-time] SP literal`. A fixed positional grammar (mailbox, then an
- * optional flag-list, then an optional date-time, then a mandatory
- * literal, in exactly that order) -- parsed here as a straightforward
- * left-to-right scan over args (modified in place), not a generic
- * tokenizer, since a quoted date-time string can itself contain spaces
- * (ruling out simple whitespace-splitting the way cmd_fetch()'s sequence-
- * set/fetch-att parsing gets away with) and a flag-list's own internal
- * grammar is already handled by parse_store_flags() (reused here
- * directly, since SS6.3.12's flag-list is syntactically identical to
- * STORE's).
- *
- * The mailbox token accepts a bare atom or a single quoted string (no
- * backslash-escape decoding) -- same deliberate simplification cmd_
- * select() already documents; APPEND's grammar also allows a literal
- * mailbox name, not supported here for the same reason cmd_select()
- * doesn't support one either.
- *
- * The literal is required to be the final thing on the line -- true by
- * construction per this grammar (nothing follows `literal` in `append`),
- * so this isn't actually a simplification, just an explicit check that
- * catches a malformed line (trailing garbage after the "{n}") with a
- * clear BAD instead of silently ignoring it.
- *
- * Returns 0 on success, -1 (BAD) or -2 (NO) with *errmsg set on failure --
- * same convention as parse_fetch_atts()/parse_store_flags().
- */
-static int
-parse_append_args(char *args, struct append_parsed *out, const char **errmsg)
-{
- char *p = args;
-
- memset(out, 0, sizeof(*out));
- *errmsg = NULL;
-
- if (p == NULL || *p == '\0') {
- *errmsg = "APPEND requires a mailbox name";
- return (-1);
- }
-
- while (*p == ' ')
- p++;
- if (*p == '"') {
- char *start = p + 1;
- char *end = strchr(start, '"');
- size_t len;
-
- if (end == NULL) {
- *errmsg = "unterminated quoted mailbox name";
- return (-1);
- }
- len = (size_t)(end - start);
- if (len == 0) {
- *errmsg = "empty mailbox name";
- return (-1);
- }
- if (len >= sizeof(out->mailbox)) {
- *errmsg = "mailbox name too long";
- return (-1);
- }
- memcpy(out->mailbox, start, len);
- out->mailbox[len] = '\0';
- p = end + 1;
- } else {
- char *start = p;
- size_t len;
-
- while (*p != '\0' && *p != ' ')
- p++;
- len = (size_t)(p - start);
- if (len == 0) {
- *errmsg = "empty mailbox name";
- return (-1);
- }
- if (len >= sizeof(out->mailbox)) {
- *errmsg = "mailbox name too long";
- return (-1);
- }
- memcpy(out->mailbox, start, len);
- out->mailbox[len] = '\0';
- }
-
- while (*p == ' ')
- p++;
- if (*p == '(') {
- char *start = p;
- char *end = strchr(p, ')');
- char saved;
- int rc;
- const char *sub_err;
-
- if (end == NULL) {
- *errmsg = "unterminated flag list";
- return (-1);
- }
- end++; /* include the ')' itself in the substring below */
- saved = *end;
- *end = '\0';
- rc = parse_store_flags(start, &out->sysflags, out->keywords,
- sizeof(out->keywords), &sub_err);
- *end = saved;
- if (rc != 0) {
- *errmsg = sub_err;
- return (rc);
- }
- p = end;
- while (*p == ' ')
- p++;
- }
-
- if (*p == '"') {
- char *start = p + 1;
- char *end = strchr(start, '"');
- size_t dlen;
- char datebuf[64];
-
- if (end == NULL) {
- *errmsg = "unterminated date-time string";
- return (-1);
- }
- dlen = (size_t)(end - start);
- if (dlen >= sizeof(datebuf)) {
- *errmsg = "date-time string too long";
- return (-1);
- }
- memcpy(datebuf, start, dlen);
- datebuf[dlen] = '\0';
- if (parse_date_time(datebuf, &out->date) == -1) {
- *errmsg = "malformed date-time string";
- return (-1);
- }
- out->has_date = 1;
- p = end + 1;
- while (*p == ' ')
- p++;
- }
-
- if (*p != '{') {
- *errmsg = "expected a message literal";
- return (-1);
- }
- {
- char *start = p + 1;
- char *end = strchr(start, '}');
- char *digits_end;
- char *digits_stop;
- char digitsbuf[24];
- size_t digits_len;
- unsigned long long litlen;
-
- if (end == NULL) {
- *errmsg = "malformed literal announcement";
- return (-1);
- }
-
- out->litnonsync = (end > start && end[-1] == '+');
- digits_stop = out->litnonsync ? end - 1 : end;
- digits_len = (size_t)(digits_stop - start);
-
- if (digits_len == 0 || digits_len >= sizeof(digitsbuf)) {
- *errmsg = "malformed literal octet count";
- return (-1);
- }
- memcpy(digitsbuf, start, digits_len);
- digitsbuf[digits_len] = '\0';
-
- errno = 0;
- litlen = strtoull(digitsbuf, &digits_end, 10);
- if (*digits_end != '\0' || errno == ERANGE) {
- *errmsg = "malformed literal octet count";
- return (-1);
- }
- out->litlen = (uint64_t)litlen;
-
- if (out->litnonsync && out->litlen > 4096) {
- /* RFC 9051 SS4.3: "non-synchronizing literals MUST
- * NOT be larger than 4096 octets. Any literal larger
- * than 4096 bytes MUST be sent as a synchronizing
- * literal." A client violating this is a protocol
- * error, not merely an oversized message -- BAD, not
- * the plain-NO size-cap rejection cmd_append() does
- * separately for APPEND_LITERAL_MAX. */
- *errmsg = "non-synchronizing literal exceeds RFC "
- "9051 SS4.3's 4096-octet limit -- use a "
- "synchronizing literal instead";
- return (-1);
- }
-
- if (end[1] != '\0') {
- *errmsg = "literal must be the final argument";
- return (-1);
- }
- }
-
- return (0);
-}
-
-/*
- * RFC 9051 SS6.3.12: `append = "APPEND" SP mailbox [SP flag-list] [SP
- * date-time] SP literal`. Parses everything up through the literal
- * announcement via parse_append_args(), then -- if a literal was found --
- * allocates s->literal_buf and switches the session into the client-
- * literal-read phase (s->literal_pending; see session_dispatch_client()'s
- * header comment on that block for why this needs no new dispatch-table
- * state). The actual IMSG_MBOX_APPEND isn't sent from here: that happens
- * once the full literal (plus its trailing CRLF) has actually been read,
- * in session_finish_append().
- */
-static int
-cmd_append(struct session *s, const char *tag, char *args)
-{
- struct append_parsed parsed;
- int rc;
- const char *errmsg;
-
- rc = parse_append_args(args, &parsed, &errmsg);
- if (rc == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
- if (rc == -2) {
- session_reply(s, tag, "NO", errmsg);
- return (1);
- }
-
- if (parsed.litlen > APPEND_LITERAL_MAX) {
- /* RFC 5530: LIMIT -- "The operation ran up against an
- * implementation limit of some kind," precisely
- * APPEND_LITERAL_MAX's own situation. */
- session_reply(s, tag, "NO",
- "[LIMIT] message too large for this server (v1 size "
- "limit -- see APPEND_LITERAL_MAX)");
- return (1);
- }
-
- if (s->store_iev == NULL) {
- /* Same internal-invariant check as cmd_select()/cmd_fetch()/
- * cmd_store_cmd()/session_request_expunge() -- ST_AUTH
- * requires store_iev to already be wired. */
- log_warnx("session %u: APPEND with no store channel wired",
- s->id);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
-
- if (parsed.litlen > 0) {
- if ((s->literal_buf = malloc((size_t)parsed.litlen)) ==
- NULL) {
- log_warn("session %u: malloc APPEND literal buffer",
- s->id);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
- } else
- s->literal_buf = NULL; /* zero-length literal -- see
- * session_dispatch_client()'s literal
- * block, which handles this without
- * special-casing */
-
- strlcpy(s->append_mailbox, parsed.mailbox, sizeof(s->append_mailbox));
- s->append_sysflags = parsed.sysflags;
- strlcpy(s->append_keywords, parsed.keywords,
- sizeof(s->append_keywords));
- s->append_has_date = parsed.has_date;
- s->append_date = parsed.date;
- s->append_prev_state = s->state;
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- s->literal_len = parsed.litlen;
- s->literal_remaining = parsed.litlen;
- s->literal_pending = 1;
-
- /*
- * RFC 9051 SS4.3: a synchronizing literal requires the server to
- * send a command continuation request ("+") and the client to wait
- * for it before sending the literal's octets; a non-synchronizing
- * literal (the "+" inside the braces) requires neither -- "the
- * server does not generate a command continuation request... and
- * clients are not required to wait." Sending "+ Ready for literal
- * data" unconditionally would still be harmless protocol-wise (a
- * LITERAL+ client just ignores it, since it isn't waiting for
- * anything), but would misleadingly claim the server is waiting
- * when it isn't, so it's gated on !litnonsync.
- */
- if (!parsed.litnonsync)
- session_write(s, "+ Ready for literal data\r\n", 27);
-
- return (1);
-}
-
-/*
- * Called once session_dispatch_client() has fully read an APPEND
- * literal's octets plus its trailing CRLF (see that function's literal-
- * handling block). Builds the combined header-plus-message-bytes imsg
- * (imapd.h's imsg_mbox_append comment explains why it's one imsg, not
- * two or an fd) and sends it, entering SESSION_APPENDING to await the
- * single IMSG_MBOX_APPENDED reply. s->literal_buf is freed here either
- * way -- its contents have been copied into the imsg by the time imsg_
- * compose() returns (same copy-not-ownership semantics parent.c's send_
- * tls_certs() comment already documents for imsg_compose() generally),
- * so there's nothing left needing it afterward.
- */
-static int
-session_finish_append(struct session *s)
-{
- struct imsg_mbox_append req;
- char *combined;
- size_t combined_len;
-
- memset(&req, 0, sizeof(req));
- strlcpy(req.mailbox, s->append_mailbox, sizeof(req.mailbox));
- req.sysflags = s->append_sysflags;
- strlcpy(req.keywords, s->append_keywords, sizeof(req.keywords));
- req.has_date = s->append_has_date;
- req.date = s->append_date;
- req.msglen = (uint32_t)s->literal_len;
-
- if (s->store_iev == NULL) {
- log_warnx("session %u: APPEND with no store channel wired "
- "(literal already read)", s->id);
- session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error");
- free(s->literal_buf);
- s->literal_buf = NULL;
- s->state = s->append_prev_state;
- return (1);
- }
-
- combined_len = sizeof(req) + (size_t)s->literal_len;
- if ((combined = malloc(combined_len)) == NULL) {
- log_warn("session %u: malloc APPEND imsg buffer", s->id);
- session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error");
- free(s->literal_buf);
- s->literal_buf = NULL;
- s->state = s->append_prev_state;
- return (1);
- }
- memcpy(combined, &req, sizeof(req));
- if (s->literal_len > 0)
- memcpy(combined + sizeof(req), s->literal_buf,
- (size_t)s->literal_len);
-
- free(s->literal_buf);
- s->literal_buf = NULL;
-
- s->state = SESSION_APPENDING;
-
- 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);
- free(combined);
- imsgev_add(s->store_iev);
-
- return (1);
-}
-
-/*
- * Terminal reply for the APPEND round trip session_finish_append()
- * started. Restores s->state to whatever it was before APPEND began
- * (s->append_prev_state) -- SESSION_AUTHENTICATED or SESSION_SELECTED,
- * since APPEND is valid, and doesn't change the selected/authenticated
- * state, in either (RFC 9051 command-auth).
- *
- * SS6.3.12: "If the destination mailbox does not exist, a server MUST
- * return an error... Unless it is certain that the destination mailbox
- * cannot be created, the server MUST send the response code
- * '[TRYCREATE]'" -- v1 has no CREATE (still a stub), so retrying via
- * CREATE would never actually help, but the response code's job is to
- * tell the client *why* the append failed (no such mailbox), not to
- * promise CREATE will succeed, so it's sent regardless.
- *
- * SS6.3.12 also: "On successful completion of an APPEND, the server
- * returns an APPENDUID response code" (SS7.1: `"APPENDUID" SP nz-number
- * SP append-uid` -- uidvalidity then the new UID) and "If the mailbox is
- * currently selected... the server SHOULD notify the client immediately
- * via an untagged EXISTS response". Before RFC 9051 SS6.3.4-SS6.3.6's flat
- * multi-mailbox support (docs/openimap-storage-backend.md item 10),
- * append_prev_state == SESSION_SELECTED alone was a sound proxy for "the
- * mailbox is currently selected" -- v1 had exactly one mailbox, so
- * anything selected was necessarily APPEND's own destination. That's no
- * longer true (a session can have "INBOX" selected while APPENDing into
- * "Drafts"), so the check now also compares s->append_mailbox against
- * s->selected_mailbox -- case-insensitively if both are INBOX (RFC 9051
- * SS5.1), case-sensitively otherwise, same rule mailbox_name_is_inbox()/
- * mailbox_name_valid() apply everywhere else a mailbox name is compared.
- */
-static void
-session_handle_mbox_appended(struct session *s,
- struct imsg_mbox_appended *res)
-{
- int appended_to_selected;
-
- s->state = s->append_prev_state;
-
- if (!res->ok) {
- if (res->no_such_mailbox)
- session_reply(s, s->pending_tag, "NO",
- "[TRYCREATE] no such mailbox");
- else
- session_reply(s, s->pending_tag, "NO",
- "APPEND failed");
- return;
- }
-
- if (mailbox_name_is_inbox(s->append_mailbox) &&
- mailbox_name_is_inbox(s->selected_mailbox))
- appended_to_selected = 1;
- else
- appended_to_selected =
- (strcmp(s->append_mailbox, s->selected_mailbox) == 0);
-
- /*
- * RFC 9051 SS6.3.13 (IDLE): a successful APPEND always adds exactly
- * one message, so unlike EXPUNGE/CLOSE this needs no res->count-style
- * gate -- wake any other same-uid session idling on this mailbox so
- * it can push the new EXISTS. Fired regardless of whether *this*
- * session has that same mailbox selected (an APPEND into "Drafts"
- * from a session with "INBOX" selected should still wake a different
- * session idling on "Drafts") -- session_notify_idle_peers() itself
- * doesn't yet filter by which mailbox each peer actually has
- * selected (a pre-existing, accepted imprecision -- see that
- * function's own comment), so this is a strict improvement over
- * today regardless, not a new gap.
- */
- session_notify_idle_peers(s);
-
- if (s->append_prev_state == SESSION_SELECTED && appended_to_selected) {
- char buf[32];
-
- snprintf(buf, sizeof(buf), "%u EXISTS", res->exists);
- session_untagged(s, buf);
- }
-
- {
- char buf[96];
-
- snprintf(buf, sizeof(buf),
- "[APPENDUID %u %u] APPEND completed", res->uidvalidity,
- res->uid);
- session_reply(s, s->pending_tag, "OK", buf);
- }
-}
-
-/*
- * RFC 9051 SS6.3.13: "Arguments: none." Valid in the authenticated or
- * selected state -- RFC 2177's own formal grammar groups idle under
- * "command_auth ::= ... / idle ;; Valid only in Authenticated or Selected
- * state", matching this codebase's existing ST_AUTH bitmask (AUTHENTICATED
- * | SELECTED) exactly, so the dispatch table entry already in place from
- * before this command had a real implementation needed no change.
- *
- * "The server requests a response to the IDLE command using the
- * continuation ('+') response" -- sent synchronously here, same as
- * cmd_authenticate()'s own bare-AUTHENTICATE-PLAIN continuation (no imsg
- * round trip needed just to produce a continuation prompt). If a mailbox
- * is currently selected, also kicks off a background IMSG_MBOX_IDLE_
- * REFRESH purely to seed s->idle_known_uids -- this doesn't delay "+
- * idling" itself; it just means the first real change-triggered refresh
- * (session_notify_idle_peers(), triggered by some other same-uid session)
- * has an actual baseline to diff against instead of nothing.
- */
-static int
-cmd_idle(struct session *s, const char *tag, char *args)
-{
- (void)args; /* RFC 2177's grammar takes none; same leniency
- * toward stray trailing tokens as every other
- * zero-argument command in this file. */
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- s->idling = 1;
- session_write(s, "+ idling\r\n", 10);
-
- if (s->state == SESSION_SELECTED)
- session_request_idle_refresh(s);
-
- return (1);
-}
-
-/*
- * RFC 9051 SS6.4.1: "Arguments: none." Extra arguments are silently
- * ignored (like CAPABILITY's) rather than rejected -- see cmd_capability()'s
- * comment for the same leniency reasoning.
- *
- * CLOSE "permanently removes all messages that have the \Deleted flag set
- * ... and returns to the authenticated state ... No untagged EXPUNGE
- * responses are sent" -- implemented by sending the identical IMSG_MBOX_
- * EXPUNGE request cmd_expunge() sends, just with silent=1, via the shared
- * session_request_expunge() helper (see its comment). The SS6.4.1 exception
- * for a read-only (EXAMINE'd) mailbox -- "No messages are removed, and no
- * error is given, if the mailbox is selected by EXAMINE" -- is handled
- * inside session_request_expunge() itself now, via s->mbox_readonly: CLOSE
- * short-circuits to a plain "OK CLOSE completed" with no store round trip
- * at all when read-only (see that function's comment).
- */
-static int
-cmd_close(struct session *s, const char *tag, char *args)
-{
- (void)args;
- return session_request_expunge(s, tag, 1, 0, 0, 0, 0, 0);
-}
-
-/*
- * RFC 9051 SS6.4.2: "Arguments: none." UNSELECT "frees a session's
- * resources associated with the selected mailbox and returns the server
- * to the authenticated state... performs the same actions as CLOSE,
- * except that no messages are permanently removed." In this codebase
- * store.c holds no per-selection state of its own to free -- every
- * IMSG_MBOX_* request (SELECT included) is independently self-contained,
- * re-opening/re-locking the index as needed rather than caching anything
- * mailbox-specific in the store child between requests -- so there is
- * nothing to tell store about at all, and no async round trip is needed;
- * this is the one command-select handler in this file that's purely a
- * local state change.
- */
-static int
-cmd_unselect(struct session *s, const char *tag, char *args)
-{
- (void)args;
-
- s->state = SESSION_AUTHENTICATED;
- session_reply(s, tag, "OK", "Unselect completed");
- return (1);
-}
-
-/*
- * RFC 9051 SS6.4.3: "Arguments: none." Same extra-arguments leniency as
- * CLOSE above. Sends the real (non-silent) IMSG_MBOX_EXPUNGE via the
- * shared session_request_expunge() helper -- see that function's comment,
- * and imapd.h's imsg_mbox_expunge comment for the full CLOSE/EXPUNGE
- * request-sharing rationale.
- */
-static int
-cmd_expunge(struct session *s, const char *tag, char *args)
-{
- (void)args;
- return session_request_expunge(s, tag, 0, 0, 0, 0, 0, 0);
-}
-
-/* RFC 9051 SS6.4.4's SEARCH result options this server understands as
- * ESEARCH return items (SS7.3.4) -- SAVE (the "$" search result
- * variable, SS6.4.4.1) is recognized but rejected with a flagged NO; see
- * parse_search_return_opts()'s comment for why it's out of scope this
- * pass. */
-#define SEARCH_RETURN_MIN (1U << 0)
-#define SEARCH_RETURN_MAX (1U << 1)
-#define SEARCH_RETURN_ALL (1U << 2)
-#define SEARCH_RETURN_COUNT (1U << 3)
-
-/*
- * RFC 9051 SS9: `date = date-text / DQUOTE date-text DQUOTE`, `date-text
- * = date-day "-" date-month "-" date-year`, `date-day = 1*2DIGIT` --
- * used by SEARCH's BEFORE/ON/SINCE (internal date, no time-of-day or
- * zone component at all, unlike APPEND's `date-time`). Deliberately a
- * separate function from parse_date_time(): that one parses APPEND's
- * `date-time` (space-padded 2-digit day, full time-of-day, mandatory
- * zone offset) -- a different grammar production, not just a formatting
- * variant of this one. Reuses fetch_month_names[] (forward-declared
- * above, defined alongside format_internaldate() below) for the month
- * name, same as parse_date_time() does.
- *
- * "Disregarding time and timezone" (SS6.4.4's own words for BEFORE/ON/
- * SINCE) is interpreted here as a UTC calendar day: the internaldate
- * values being compared against are themselves stored as absolute UTC
- * epoch instants with no separate per-message zone metadata retained --
- * parse_maildir_timestamp() reads a delivery timestamp with no zone
- * information at all, and APPEND's own parse_date_time() folds any
- * client-supplied zone offset into an absolute UTC instant before
- * storing it (see that function's comment) -- so there is no remaining
- * per-message zone left to "disregard" separately at search time. UTC
- * midnight of the given date-text, via timegm(3), is therefore the only
- * coherent boundary available given what's actually on disk. This is a
- * specific, deliberate interpretation choice, not something RFC 9051
- * spells out explicitly -- flagged here rather than presented as if it
- * were an unambiguous reading of the spec text.
- *
- * Returns 0 and fills *out (Unix timestamp, UTC midnight of that date)
- * on success, -1 on any malformed input (bad digit counts, an
- * unrecognized month name, a day out of 1-31, a pre-1970 year, or
- * trailing garbage after the date-text -- checked via sscanf(3)'s "%n"
- * conversion, which records how many characters were consumed).
- */
-static int
-parse_search_date(const char *s, int64_t *out)
-{
- struct tm tm;
- char mon[4];
- int day, year, i, n;
- time_t t;
-
- memset(&tm, 0, sizeof(tm));
- n = 0;
- if (sscanf(s, "%2d-%3s-%4d%n", &day, mon, &year, &n) != 3)
- return (-1);
- if (s[n] != '\0')
- return (-1); /* trailing garbage after date-text */
-
- if (day < 1 || day > 31 || year < 1970)
- return (-1);
-
- for (i = 0; i < 12; i++) {
- if (strcasecmp(mon, fetch_month_names[i]) == 0)
- break;
- }
- if (i == 12)
- return (-1);
-
- tm.tm_mday = day;
- tm.tm_mon = i;
- tm.tm_year = year - 1900;
- /* tm_hour/tm_min/tm_sec already 0 from memset -- UTC midnight */
-
- if ((t = timegm(&tm)) == (time_t)-1)
- return (-1);
-
- *out = (int64_t)t;
- return (0);
-}
-
-/*
- * Accumulator for parse_search_key()/parse_search_key_list()'s compiled
- * postfix program -- see imapd.h's imsg_mbox_search comment for why
- * the wire format is a flat struct search_node array rather than a
- * pointer-linked tree. Kept as a small struct (not just a bare array +
- * count passed around) purely so search_push() has one thing to take a
- * pointer to.
- */
-#define SEARCH_MAX_DEPTH 64 /* F7 fix: max parse_search_key() recursion
- * depth -- prevents a deeply-nested SEARCH
- * from exhausting the listener stack. */
-struct search_parse_ctx {
- struct search_node nodes[SEARCH_PROGRAM_MAX_NODES];
- uint32_t n;
- int depth; /* F7: current recursion depth */
- int uses_modseq; /* set by parse_search_key() when
- * a MODSEQ search-key is pushed --
- * see cmd_search()'s use of it to
- * set s->search_used_modseq /
- * call session_condstore_enable(). */
-};
-
-static int
-search_push(struct search_parse_ctx *ctx, const struct search_node *node,
- const char **errmsg)
-{
- if (ctx->n >= SEARCH_PROGRAM_MAX_NODES) {
- *errmsg = "search criteria too complex";
- return (-1);
- }
- ctx->nodes[ctx->n++] = *node;
- return (0);
-}
-
-/*
- * Reads one sequence-set token (space- or ')'-delimited) starting at
- * *pp, rejects an internal comma (v1's established single-range-only
- * restriction -- see imapd.h's imsg_mbox_fetch comment), and parses
- * it via the same parse_seq_range() FETCH/STORE already use. Shared by
- * both the bare-sequence-set search key and the "UID" SP sequence-set
- * search key below -- their token grammar and v1 restrictions are
- * identical, only which field of struct search_node the caller stores
- * the result into (and which op it tags the node with) differs.
- */
-static int
-parse_search_seqset_token(char **pp, uint32_t *lo, uint32_t *hi,
- int *lo_star, int *hi_star, const char **errmsg)
-{
- char *p = *pp;
- char *start = p;
-
- while (*p != '\0' && *p != ' ' && *p != ')')
- p++;
-
- if (p == start) {
- *errmsg = "missing sequence set";
- return (-1);
- }
-
- {
- char tok[32];
- size_t len = (size_t)(p - start);
-
- if (len >= sizeof(tok)) {
- *errmsg = "sequence set too long";
- return (-1);
- }
- memcpy(tok, start, len);
- tok[len] = '\0';
-
- if (strchr(tok, ',') != NULL) {
- *errmsg = "comma-separated sequence sets not "
- "supported in v1 -- issue separate SEARCH "
- "commands";
- return (-1);
- }
-
- if (parse_seq_range(tok, lo, hi, lo_star, hi_star) == -1) {
- *errmsg = "invalid sequence set";
- return (-1);
- }
- }
-
- *pp = p;
- return (0);
-}
-
-/*
- * RFC 9051 SS6.4.4's `search-key` grammar (SS9's formal ABNF), recursive-
- * descent, one search-key at a time -- pushes exactly one resulting
- * value onto ctx's postfix program (a single leaf node for most keys, or
- * a leaf/subexpression followed by a combinator for NOT/OR/parenthesized
- * lists) and leaves *pp positioned just past what it consumed.
- *
- * v1 scope: search keys that need actual message content or headers --
- * BCC/BODY/CC/FROM/HEADER/SENTBEFORE/SENTON/SENTSINCE/SUBJECT/TEXT/TO --
- * are recognized by name (so a client seeing NO for one of them gets a
- * specific, honest explanation, not a generic BAD) but rejected
- * immediately with -2/NO, without attempting to parse or skip their
- * operand -- safe because the whole SEARCH command is abandoned on any
- * -2/-1 return anyway, so there's no later parse position that still
- * needs to be correct. This is the same "recognized, can't do it right
- * now" category FETCH's BODY[] rejection already uses, for the same
- * underlying reason: real support needs message content access, which
- * needs fd-passing from store, not designed yet (see cmd_fetch()'s own
- * comment).
- *
- * Returns 0 on success, -1 (BAD) for malformed syntax, -2 (NO) for a
- * recognized-but-unsupported search key -- same convention as parse_
- * fetch_atts()/parse_store_flags()/parse_append_args().
- */
-static int
-parse_search_key(char **pp, struct search_parse_ctx *ctx, const char **errmsg)
-{
- int rc;
-
- /*
- * F7 fix: bound recursion depth. Every level of the SEARCH-key
- * grammar (parenthesized lists, NOT, OR) re-enters this function,
- * so a single guard here caps total recursion and prevents a
- * crafted deeply-nested SEARCH from exhausting the listener stack.
- */
- if (++ctx->depth > SEARCH_MAX_DEPTH) {
- ctx->depth--;
- *errmsg = "search criteria nested too deeply";
- return (-1);
- }
- rc = parse_search_key_inner(pp, ctx, errmsg);
- ctx->depth--;
- return (rc);
-}
-
-static int
-parse_search_key_inner(char **pp, struct search_parse_ctx *ctx,
- const char **errmsg)
-{
- char *p = *pp;
- char word[32];
- size_t wlen;
-
- while (*p == ' ')
- p++;
-
- if (*p == '\0') {
- *errmsg = "missing search key";
- return (-1);
- }
-
- if (*p == '(') {
- int rc;
-
- p++;
- rc = parse_search_key_list(&p, ctx, errmsg, 1);
- if (rc != 0)
- return (rc);
- while (*p == ' ')
- p++;
- if (*p != ')') {
- *errmsg = "unterminated parenthesized search key list";
- return (-1);
- }
- p++;
- *pp = p;
- return (0);
- }
-
- if (isdigit((unsigned char)*p) || *p == '*') {
- struct search_node node;
-
- memset(&node, 0, sizeof(node));
- node.op = SEARCH_OP_SEQSET;
- if (parse_search_seqset_token(&p, &node.seq_lo, &node.seq_hi,
- &node.lo_is_star, &node.hi_is_star, errmsg) == -1)
- return (-1);
- if (search_push(ctx, &node, errmsg) == -1)
- return (-1);
- *pp = p;
- return (0);
- }
-
- {
- char *start = p;
-
- while (*p != '\0' && *p != ' ' && *p != ')')
- p++;
- wlen = (size_t)(p - start);
- if (wlen == 0 || wlen >= sizeof(word)) {
- *errmsg = "unknown search key";
- return (-1);
- }
- memcpy(word, start, wlen);
- word[wlen] = '\0';
- }
-
- if (strcasecmp(word, "ALL") == 0) {
- struct search_node node;
-
- memset(&node, 0, sizeof(node));
- node.op = SEARCH_OP_ALL;
- if (search_push(ctx, &node, errmsg) == -1)
- return (-1);
- *pp = p;
- return (0);
- }
-
- {
- static const struct {
- const char *name;
- int op;
- } boolkeys[] = {
- { "ANSWERED", SEARCH_OP_ANSWERED },
- { "UNANSWERED", SEARCH_OP_UNANSWERED },
- { "DELETED", SEARCH_OP_DELETED },
- { "UNDELETED", SEARCH_OP_UNDELETED },
- { "DRAFT", SEARCH_OP_DRAFT },
- { "UNDRAFT", SEARCH_OP_UNDRAFT },
- { "FLAGGED", SEARCH_OP_FLAGGED },
- { "UNFLAGGED", SEARCH_OP_UNFLAGGED },
- { "SEEN", SEARCH_OP_SEEN },
- { "UNSEEN", SEARCH_OP_UNSEEN },
- };
- size_t i;
-
- for (i = 0; i < sizeof(boolkeys) / sizeof(boolkeys[0]); i++) {
- struct search_node node;
-
- if (strcasecmp(word, boolkeys[i].name) != 0)
- continue;
- memset(&node, 0, sizeof(node));
- node.op = boolkeys[i].op;
- if (search_push(ctx, &node, errmsg) == -1)
- return (-1);
- *pp = p;
- return (0);
- }
- }
-
- if (strcasecmp(word, "KEYWORD") == 0 ||
- strcasecmp(word, "UNKEYWORD") == 0) {
- struct search_node node;
- char *start;
-
- memset(&node, 0, sizeof(node));
- node.op = (strcasecmp(word, "KEYWORD") == 0) ?
- SEARCH_OP_KEYWORD : SEARCH_OP_UNKEYWORD;
-
- while (*p == ' ')
- p++;
- start = p;
- while (*p != '\0' && *p != ' ' && *p != ')')
- p++;
- if (p == start || (size_t)(p - start) >= sizeof(node.keyword)) {
- *errmsg = "missing or too-long KEYWORD/UNKEYWORD "
- "argument";
- return (-1);
- }
- memcpy(node.keyword, start, (size_t)(p - start));
- node.keyword[p - start] = '\0';
-
- if (search_push(ctx, &node, errmsg) == -1)
- return (-1);
- *pp = p;
- return (0);
- }
-
- if (strcasecmp(word, "BEFORE") == 0 || strcasecmp(word, "ON") == 0 ||
- strcasecmp(word, "SINCE") == 0) {
- struct search_node node;
- char datebuf[32];
-
- memset(&node, 0, sizeof(node));
- if (strcasecmp(word, "BEFORE") == 0)
- node.op = SEARCH_OP_BEFORE;
- else if (strcasecmp(word, "ON") == 0)
- node.op = SEARCH_OP_ON;
- else
- node.op = SEARCH_OP_SINCE;
-
- while (*p == ' ')
- p++;
- if (*p == '"') {
- char *start = p + 1;
- char *end = strchr(start, '"');
- size_t len;
-
- if (end == NULL) {
- *errmsg = "unterminated date string";
- return (-1);
- }
- len = (size_t)(end - start);
- if (len >= sizeof(datebuf)) {
- *errmsg = "malformed date";
- return (-1);
- }
- memcpy(datebuf, start, len);
- datebuf[len] = '\0';
- p = end + 1;
- } else {
- char *start = p;
- size_t len;
-
- while (*p != '\0' && *p != ' ' && *p != ')')
- p++;
- len = (size_t)(p - start);
- if (len == 0 || len >= sizeof(datebuf)) {
- *errmsg = "malformed date";
- return (-1);
- }
- memcpy(datebuf, start, len);
- datebuf[len] = '\0';
- }
-
- if (parse_search_date(datebuf, &node.num) == -1) {
- *errmsg = "malformed date";
- return (-1);
- }
-
- if (search_push(ctx, &node, errmsg) == -1)
- return (-1);
- *pp = p;
- return (0);
- }
-
- if (strcasecmp(word, "LARGER") == 0 ||
- strcasecmp(word, "SMALLER") == 0) {
- struct search_node node;
- char numbuf[24];
- char *start, *numend;
- unsigned long long v;
-
- memset(&node, 0, sizeof(node));
- node.op = (strcasecmp(word, "LARGER") == 0) ?
- SEARCH_OP_LARGER : SEARCH_OP_SMALLER;
-
- while (*p == ' ')
- p++;
- start = p;
- while (*p != '\0' && *p != ' ' && *p != ')')
- p++;
- if (p == start || (size_t)(p - start) >= sizeof(numbuf)) {
- *errmsg = "missing or malformed octet count";
- return (-1);
- }
- memcpy(numbuf, start, (size_t)(p - start));
- numbuf[p - start] = '\0';
-
- errno = 0;
- v = strtoull(numbuf, &numend, 10);
- if (*numend != '\0' || errno == ERANGE) {
- *errmsg = "malformed octet count";
- return (-1);
- }
- node.num = (int64_t)v;
-
- if (search_push(ctx, &node, errmsg) == -1)
- return (-1);
- *pp = p;
- return (0);
- }
-
- if (strcasecmp(word, "UID") == 0) {
- struct search_node node;
-
- memset(&node, 0, sizeof(node));
- node.op = SEARCH_OP_UIDSET;
-
- while (*p == ' ')
- p++;
- if (parse_search_seqset_token(&p, &node.seq_lo, &node.seq_hi,
- &node.lo_is_star, &node.hi_is_star, errmsg) == -1)
- return (-1);
-
- if (search_push(ctx, &node, errmsg) == -1)
- return (-1);
- *pp = p;
- return (0);
- }
-
- if (strcasecmp(word, "MODSEQ") == 0) {
- struct search_node node;
- char numbuf[24];
- char *start, *numend;
- unsigned long long v;
-
- memset(&node, 0, sizeof(node));
- node.op = SEARCH_OP_MODSEQ;
-
- while (*p == ' ')
- p++;
-
- /* RFC 7162 SS3.1.5 ABNF: MODSEQ [SP entry-name SP entry-
- * type-req] SP mod-sequence-valzer. entry-name/entry-type-
- * req come from RFC 5464 METADATA, which this server doesn't
- * implement -- SS3.1.5 itself says a server that doesn't
- * store separate mod-sequences per metadata item "MUST
- * ignore <entry-name> and <entry-type-req>", so this only
- * needs to parse past them syntactically (see imapd.h's
- * SEARCH_OP_MODSEQ comment). Detected by peeking: the
- * mod-sequence-valzer itself is always a bare digit string,
- * so anything else here must be the optional entry-name. */
- if (*p != '\0' && !isdigit((unsigned char)*p)) {
- if (*p == '"') {
- char *end = strchr(p + 1, '"');
-
- if (end == NULL) {
- *errmsg = "unterminated MODSEQ "
- "entry-name";
- return (-1);
- }
- p = end + 1;
- } else {
- while (*p != '\0' && *p != ' ' && *p != ')')
- p++;
- }
- while (*p == ' ')
- p++;
-
- if (strncasecmp(p, "priv", 4) == 0 &&
- (p[4] == ' ' || p[4] == '\0' || p[4] == ')'))
- p += 4;
- else if (strncasecmp(p, "shared", 6) == 0 &&
- (p[6] == ' ' || p[6] == '\0' || p[6] == ')'))
- p += 6;
- else if (strncasecmp(p, "all", 3) == 0 &&
- (p[3] == ' ' || p[3] == '\0' || p[3] == ')'))
- p += 3;
- else {
- *errmsg = "expected priv/shared/all after "
- "MODSEQ entry-name";
- return (-1);
- }
- while (*p == ' ')
- p++;
- }
-
- start = p;
- while (*p != '\0' && *p != ' ' && *p != ')')
- p++;
- if (p == start || (size_t)(p - start) >= sizeof(numbuf)) {
- *errmsg = "missing or malformed MODSEQ value";
- return (-1);
- }
- memcpy(numbuf, start, (size_t)(p - start));
- numbuf[p - start] = '\0';
-
- errno = 0;
- v = strtoull(numbuf, &numend, 10);
- if (*numend != '\0' || errno == ERANGE) {
- *errmsg = "malformed MODSEQ value";
- return (-1);
- }
- node.num = (int64_t)v;
-
- if (search_push(ctx, &node, errmsg) == -1)
- return (-1);
- ctx->uses_modseq = 1;
- *pp = p;
- return (0);
- }
-
- if (strcasecmp(word, "NOT") == 0) {
- struct search_node node;
- int rc;
-
- rc = parse_search_key(&p, ctx, errmsg);
- if (rc != 0)
- return (rc);
-
- memset(&node, 0, sizeof(node));
- node.op = SEARCH_OP_NOT;
- if (search_push(ctx, &node, errmsg) == -1)
- return (-1);
- *pp = p;
- return (0);
- }
-
- if (strcasecmp(word, "OR") == 0) {
- struct search_node node;
- int rc;
-
- rc = parse_search_key(&p, ctx, errmsg);
- if (rc != 0)
- return (rc);
- rc = parse_search_key(&p, ctx, errmsg);
- if (rc != 0)
- return (rc);
-
- memset(&node, 0, sizeof(node));
- node.op = SEARCH_OP_OR;
- if (search_push(ctx, &node, errmsg) == -1)
- return (-1);
- *pp = p;
- return (0);
- }
-
- {
- static const char *content_keys[] = {
- "BCC", "BODY", "CC", "FROM", "HEADER", "SENTBEFORE",
- "SENTON", "SENTSINCE", "SUBJECT", "TEXT", "TO",
- };
- size_t i;
-
- for (i = 0; i < sizeof(content_keys) / sizeof(content_keys[0]);
- i++) {
- if (strcasecmp(word, content_keys[i]) == 0) {
- *errmsg = "search keys that require message "
- "content/header access are not "
- "supported in this pass";
- return (-2);
- }
- }
- }
-
- *errmsg = "unknown search key";
- return (-1);
-}
-
-/*
- * `search-key *(SP search-key)`, ANDed together left to right -- shared
- * by the top-level search-program (in_parens == 0, stops at end of
- * string) and a parenthesized `patterns`-style list (in_parens == 1,
- * stops at, but does not consume, the closing ')') -- see parse_search_
- * key()'s own "(" branch, which consumes the parens themselves.
- */
-static int
-parse_search_key_list(char **pp, struct search_parse_ctx *ctx,
- const char **errmsg, int in_parens)
-{
- int rc;
-
- rc = parse_search_key(pp, ctx, errmsg);
- if (rc != 0)
- return (rc);
-
- for (;;) {
- char *p = *pp;
-
- while (*p == ' ')
- p++;
-
- if (in_parens && *p == ')') {
- *pp = p;
- return (0);
- }
- if (*p == '\0') {
- if (in_parens) {
- *errmsg = "unterminated parenthesized search "
- "key list";
- return (-1);
- }
- *pp = p;
- return (0);
- }
- if (!in_parens && *p == ')') {
- *errmsg = "unexpected ')'";
- return (-1);
- }
-
- *pp = p;
- rc = parse_search_key(pp, ctx, errmsg);
- if (rc != 0)
- return (rc);
-
- {
- struct search_node combine;
-
- memset(&combine, 0, sizeof(combine));
- combine.op = SEARCH_OP_AND;
- if (search_push(ctx, &combine, errmsg) == -1)
- return (-1);
- }
- }
-}
-
-/*
- * RFC 9051 SS6.4.4: `search-return-opts = SP "RETURN" SP "(" [search-
- * return-opt *(SP search-return-opt)] ")"`. *pp must already point at
- * the opening "(" (caller peeked for it to decide whether a RETURN
- * clause is present at all). An empty "()" is valid ABNF and leaves
- * *opts_out at 0 -- cmd_search() treats that identically to "no RETURN
- * clause at all" (SS6.4.4: "If no result option is specified or empty
- * list of options is specified as '()', ALL is assumed").
- *
- * SAVE (SS6.4.4.1's "$" search result variable) is recognized but
- * rejected with -2/NO: implementing it correctly means resetting the
- * variable on SELECT/EXAMINE, adjusting it on EXPUNGE, and teaching
- * every command that accepts a sequence-set (FETCH, STORE, COPY, MOVE,
- * a future UID SEARCH) to also accept "$" -- real, cross-cutting design
- * work spanning multiple already-implemented commands, not a small
- * addition to SEARCH alone. Deferred, same category of scope cut as
- * APPEND's size cap or LIST's extended syntax. Any other, genuinely
- * unrecognized token (not one of MIN/MAX/ALL/COUNT/SAVE) gets -1/BAD --
- * SS6.3.9's own words for LIST options apply equally here: "Any options
- * not defined by extensions that the server supports MUST be rejected
- * with a BAD response."
- */
-static int
-parse_search_return_opts(char **pp, uint32_t *opts_out, const char **errmsg)
-{
- char *p = *pp;
-
- *opts_out = 0;
- p++; /* skip the '(' the caller already confirmed is there */
-
- while (*p == ' ')
- p++;
- if (*p == ')') {
- *pp = p + 1;
- return (0);
- }
-
- for (;;) {
- char *start = p;
- char word[16];
- size_t len;
-
- while (*p != '\0' && *p != ' ' && *p != ')')
- p++;
- len = (size_t)(p - start);
- if (len == 0 || len >= sizeof(word)) {
- *errmsg = "malformed SEARCH RETURN option";
- return (-1);
- }
- memcpy(word, start, len);
- word[len] = '\0';
-
- if (strcasecmp(word, "MIN") == 0)
- *opts_out |= SEARCH_RETURN_MIN;
- else if (strcasecmp(word, "MAX") == 0)
- *opts_out |= SEARCH_RETURN_MAX;
- else if (strcasecmp(word, "ALL") == 0)
- *opts_out |= SEARCH_RETURN_ALL;
- else if (strcasecmp(word, "COUNT") == 0)
- *opts_out |= SEARCH_RETURN_COUNT;
- else if (strcasecmp(word, "SAVE") == 0) {
- *errmsg = "SEARCH RETURN (SAVE) -- the \"$\" search "
- "result variable -- is not supported in this "
- "pass";
- return (-2);
- } else {
- *errmsg = "unsupported SEARCH RETURN option";
- return (-1);
- }
-
- while (*p == ' ')
- p++;
- if (*p == ')') {
- *pp = p + 1;
- return (0);
- }
- if (*p == '\0') {
- *errmsg = "unterminated SEARCH RETURN option list";
- return (-1);
- }
- }
-}
-
-/*
- * RFC 9051 SS6.4.4: `search = "SEARCH" [search-return-opts] SP search-
- * program`, `search-program = ["CHARSET" SP charset SP] search-key
- * *(SP search-key)`.
- *
- * v1 scope, summarized (each piece's own comment has the full reasoning):
- * basic MIN/MAX/ALL/COUNT result options (SAVE deferred); CHARSET
- * accepted only as US-ASCII or UTF-8 (SS6.4.4: "Servers MUST support
- * US-ASCII and UTF-8 charsets"), anything else gets NO [BADCHARSET]; the
- * full flag/date/size/sequence-number/UID-range/NOT/OR/parenthesized-
- * list search-key grammar, except the content-and-header-based keys
- * (BCC/BODY/CC/FROM/HEADER/SENTBEFORE/SENTON/SENTSINCE/SUBJECT/TEXT/TO),
- * which need message content access this codebase doesn't have yet.
- *
- * RFC 9051 SS6.4.9 (UID command) is now wired up via cmd_uid()'s SEARCH
- * branch calling search_dispatch() below with by_uid=1: "the numbers
- * returned in an ESEARCH response for a UID SEARCH command are unique
- * identifiers instead of message sequence numbers... the corresponding
- * ESEARCH response MUST include the UID indicator" -- see search_
- * dispatch()'s own comment, session_handle_mbox_search_match(), and
- * session_finish_search() for how.
- */
-static int
-cmd_search(struct session *s, const char *tag, char *args)
-{
- return search_dispatch(s, tag, args, 0);
-}
-
-/*
- * Shared body for cmd_search() (by_uid=0) and cmd_uid()'s SEARCH branch
- * (by_uid=1). RFC 9051 SS6.4.9: "the interpretation of the [SEARCH]
- * arguments is the same as with SEARCH" -- by_uid does NOT change how a
- * bare sequence-set or "UID <sequence-set>" search-key is parsed or
- * matched (those already go through SEARCH_OP_SEQSET/SEARCH_OP_UIDSET
- * exactly as before); it only changes what value session_handle_mbox_
- * search_match()/session_finish_search() report for each match (UID
- * instead of sequence number) and adds the "UID" ESEARCH correlator
- * token -- see both functions' own comments. No store.c wire-format
- * change was needed for this at all: store.c has always sent both seqno
- * and uid per match (see imapd.h's imsg_mbox_search_match comment).
- */
-static int
-search_dispatch(struct session *s, const char *tag, char *args, int by_uid)
-{
- struct search_parse_ctx ctx;
- char *p;
- uint32_t return_opts = 0;
- int rc;
- const char *errmsg;
-
- if (args == NULL) {
- session_reply(s, tag, "BAD", "SEARCH requires search criteria");
- return (1);
- }
-
- p = args;
- while (*p == ' ')
- p++;
-
- if (strncasecmp(p, "RETURN", 6) == 0 &&
- (p[6] == ' ' || p[6] == '\0')) {
- p += 6;
- while (*p == ' ')
- p++;
- if (*p != '(') {
- session_reply(s, tag, "BAD",
- "malformed SEARCH RETURN option list");
- return (1);
- }
- rc = parse_search_return_opts(&p, &return_opts, &errmsg);
- if (rc == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
- if (rc == -2) {
- session_reply(s, tag, "NO", errmsg);
- return (1);
- }
- while (*p == ' ')
- p++;
- }
-
- if (return_opts == 0)
- return_opts = SEARCH_RETURN_ALL; /* SS6.4.4's default */
-
- if (strncasecmp(p, "CHARSET", 7) == 0 &&
- (p[7] == ' ' || p[7] == '\0')) {
- char *start;
- char charset[64];
- size_t len;
-
- p += 7;
- while (*p == ' ')
- p++;
- start = p;
- while (*p != '\0' && *p != ' ')
- p++;
- len = (size_t)(p - start);
- if (len == 0 || len >= sizeof(charset)) {
- session_reply(s, tag, "BAD", "malformed CHARSET");
- return (1);
- }
- memcpy(charset, start, len);
- charset[len] = '\0';
-
- if (strcasecmp(charset, "US-ASCII") != 0 &&
- strcasecmp(charset, "UTF-8") != 0) {
- /* RFC 9051 SS9 resp-text-code: `"BADCHARSET" [SP "("
- * charset *(SP charset) ")"]` -- the parens are
- * required by the formal ABNF; SS6.4.4.4's own prose
- * example ("NO [BADCHARSET UTF-8] KOI8-R is not
- * supported") omits them, an inconsistency in the
- * RFC's own text between its worked example and its
- * Section 9 grammar -- the ABNF is followed here as
- * the normative definition. */
- session_reply(s, tag, "NO",
- "[BADCHARSET (US-ASCII UTF-8)] unsupported "
- "CHARSET");
- return (1);
- }
-
- while (*p == ' ')
- p++;
- }
-
- memset(&ctx, 0, sizeof(ctx));
- rc = parse_search_key_list(&p, &ctx, &errmsg, 0);
- if (rc == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
- if (rc == -2) {
- session_reply(s, tag, "NO", errmsg);
- return (1);
- }
-
- if (ctx.n == 0) {
- session_reply(s, tag, "BAD", "SEARCH requires search criteria");
- return (1);
- }
-
- if (s->store_iev == NULL) {
- /* Same internal-invariant check as cmd_fetch()/cmd_store_
- * cmd()/session_request_expunge() -- ST_SELECTED requires
- * store_iev to already be wired. */
- log_warnx("session %u: %s with no store channel wired",
- s->id, by_uid ? "UID SEARCH" : "SEARCH");
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
-
- /* Defensive cleanup of a previous SEARCH's leftovers, matching the
- * ST_SELECTED-exclusion guarantee above (SESSION_SEARCHING can't
- * still be in flight when a new SEARCH is dispatched) -- shouldn't
- * ever actually find anything here, same belt-and-suspenders
- * posture as other cleanup in this file. */
- free(s->search_matches);
- s->search_matches = NULL;
- s->search_nmatches = 0;
- s->search_matches_cap = 0;
- s->search_alloc_failed = 0;
- s->search_return_opts = return_opts;
- s->search_used_modseq = ctx.uses_modseq;
- s->search_max_modseq = 0;
- s->cmd_by_uid = by_uid;
-
- /* RFC 7162 SS3.1: "a FETCH or SEARCH command that includes the
- * MODSEQ message data item" is a CONDSTORE enabling command. */
- if (ctx.uses_modseq)
- session_condstore_enable(s);
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- s->state = SESSION_SEARCHING;
-
- {
- struct imsg_mbox_search req;
- size_t bodylen = (size_t)ctx.n *
- sizeof(struct search_node);
- char *combined;
-
- memset(&req, 0, sizeof(req));
- req.nnodes = ctx.n;
-
- if ((combined = malloc(sizeof(req) + bodylen)) == NULL) {
- log_warn("session %u: malloc SEARCH imsg buffer",
- s->id);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- s->state = SESSION_SELECTED;
- return (1);
- }
- memcpy(combined, &req, sizeof(req));
- if (bodylen > 0)
- memcpy(combined + sizeof(req), ctx.nodes, bodylen);
-
- if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_SEARCH, 0, 0,
- -1, combined, sizeof(req) + bodylen) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_SEARCH",
- s->id);
- free(combined);
- imsgev_add(s->store_iev);
- }
-
- return (1);
-}
-
-/*
- * RFC 9051 SS9: `nz-number = digit-nz *DIGIT` -- a non-zero unsigned
- * decimal number, the ABNF base type behind sequence numbers and UIDs.
- * Rejects a leading zero (matches "digit-nz *DIGIT", not the more general
- * "number"), non-digit characters, an empty string, and overflow past
- * UINT32_MAX (see imapd.h's imsg_mbox_fetch_meta comment and RFC 9051
- * SS2.3.1.1's own "unsigned 32-bit" language for why UINT32_MAX, not
- * ULONG_MAX, is the ceiling here).
- */
-static int
-parse_nz_number(const char *str, uint32_t *out)
-{
- unsigned long v;
- char *end;
-
- if (str == NULL || *str == '\0' || *str == '0')
- return (-1);
-
- errno = 0;
- v = strtoul(str, &end, 10);
- if (*end != '\0' || errno == ERANGE || v == 0 || v > UINT32_MAX)
- return (-1);
-
- *out = (uint32_t)v;
- return (0);
-}
-
-/*
- * RFC 9051 SS9: `sequence-set = (seq-number / seq-range) *("," sequence-
- * set)`, `seq-range = seq-number ":" seq-number`, `seq-number = nz-number /
- * "*"`. v1 supports exactly one seq-number or seq-range per FETCH --
- * cmd_fetch() rejects a comma-separated sequence-set with a BAD before this
- * function is ever called, so a comma reaching here would be a caller bug,
- * not client input this function itself needs to detect.
- *
- * "*" (SS9: "the largest number in use") can't be resolved here: listener
- * doesn't reliably know the live message count (mail can arrive between
- * SELECT and this FETCH). lo_star and hi_star instead carry the "this side
- * was a star" fact through IMSG_MBOX_FETCH so store.c can resolve it
- * against its own up-to-the-moment index length -- see imapd.h's
- * imsg_mbox_fetch comment.
- *
- * A bare seq-number (no ":") normalizes to lo == hi. A backwards range
- * (e.g. "4:2") is swapped so lo <= hi, matching SS9's own "it is possible
- * to specify a decreasing range (e.g., '4:2')" note -- store.c's
- * handle_mbox_fetch() iterates lo..hi ascending and has no other way to
- * handle a decreasing range. A star on one side of a range with a literal
- * number on the other (e.g. "4:*") is left unswapped -- whether that ends
- * up lo <= hi is something only store.c, once it resolves the star, can
- * know.
- */
-static int
-parse_seq_range(const char *tok, uint32_t *lo, uint32_t *hi, int *lo_star,
- int *hi_star)
-{
- char buf[32];
- char *colon;
- const char *loside, *hiside;
-
- if (tok == NULL || *tok == '\0' || strlen(tok) >= sizeof(buf))
- return (-1);
- strlcpy(buf, tok, sizeof(buf));
-
- *lo_star = *hi_star = 0;
- *lo = *hi = 0;
-
- if ((colon = strchr(buf, ':')) != NULL) {
- *colon = '\0';
- loside = buf;
- hiside = colon + 1;
- } else {
- loside = buf;
- hiside = buf;
- }
-
- if (strcmp(loside, "*") == 0)
- *lo_star = 1;
- else if (parse_nz_number(loside, lo) == -1)
- return (-1);
-
- if (strcmp(hiside, "*") == 0)
- *hi_star = 1;
- else if (parse_nz_number(hiside, hi) == -1)
- return (-1);
-
- if (!*lo_star && !*hi_star && *lo > *hi) {
- uint32_t tmp = *lo;
-
- *lo = *hi;
- *hi = tmp;
- }
-
- return (0);
-}
-
-/*
- * Same calling convention as strtok_r(str, " ", &savep) (pass str on the
- * first call, NULL thereafter, using the same savep each time), but a
- * space is not treated as a delimiter while inside an unclosed '[' or
- * '(' -- needed because a single fetch-att can itself contain a
- * mandatory embedded space: RFC 9051 SS9's header-list production,
- * "(" header-fld-name *(SP header-fld-name) ")", sits inside a bracketed
- * section-spec, e.g. the RFC's own example, "BODY[HEADER.FIELDS (DATE
- * FROM)]" (SS6.4.5). Plain strtok_r(spec, " ", &save) -- what parse_
- * fetch_atts() used before this pass -- would split that into three
- * garbage tokens ("BODY[HEADER.FIELDS", "(DATE", "FROM)]"), the second
- * and third of which match nothing and would fail the whole FETCH as
- * BAD. This is a real, previously undiscovered bug: a comment in this
- * file claimed the generic BODY-prefix catch-all already "recognizes and
- * skips the whole item" for a multi-token split like that, but nothing
- * actually reassembles tokens 2 and 3 into anything recognizable --
- * found only because implementing HEADER.FIELDS required tracing exactly
- * how a bracketed, space-containing fetch-att reaches parse_fetch_atts()
- * in the first place, not by inspection of the (incorrect) comment
- * alone. No real client request had exercised this path before now.
- *
- * '[' and '(' share one depth counter since section-spec's own brackets
- * and header-list's parens are always properly nested relative to each
- * other in this grammar -- nothing here needs to distinguish which kind
- * of bracket is currently open, only whether depth is zero. An unclosed
- * bracket/paren at end of string is left as trailing unbalanced depth;
- * the caller's own field-list parsing (not this function) is what
- * rejects that as a syntax error, same "let the specific parser catch
- * the specific mistake" split this file already uses elsewhere (e.g.
- * split_trailing_modifiers() vs. its own caller).
- */
-static char *
-fetch_att_tok(char *str, char **savep)
-{
- char *p, *start;
- int depth = 0;
-
- p = (str != NULL) ? str : *savep;
-
- while (*p == ' ')
- p++;
- if (*p == '\0') {
- *savep = p;
- return (NULL);
- }
-
- for (start = p; *p != '\0'; p++) {
- if (*p == '[' || *p == '(')
- depth++;
- else if (*p == ']' || *p == ')') {
- if (depth > 0)
- depth--;
- } else if (*p == ' ' && depth == 0)
- break;
- }
-
- if (*p != '\0') {
- *p = '\0';
- p++;
- }
- *savep = p;
- return (start);
-}
-
-/*
- * Parses one "HEADER.FIELDS (name ...)" or "HEADER.FIELDS.NOT (name ...)"
- * bracket body -- inner is everything between BODY.PEEK[...]'s brackets,
- * e.g. "HEADER.FIELDS (DATE FROM)" (RFC 9051 SS9: section-msgtext = ... /
- * "HEADER.FIELDS" [".NOT"] SP header-list, header-list = "(" header-fld-
- * name *(SP header-fld-name) ")"). Only called once fetch_att_tok() (see
- * its own comment for why a plain strtok_r() split can't handle this
- * token's embedded space) has already isolated the whole "BODY.PEEK[...]"
- * atom, and parse_fetch_atts() has confirmed it starts with "BODY.PEEK
- * [HEADER.FIELDS" case-insensitively.
- *
- * Returns 0 and fills *not_out, fields_out (space-joined field names --
- * see struct imsg_mbox_fetch's header_fields comment in imapd.h for
- * why store.c gets this pre-extracted form rather than the raw bracket
- * text) on success. Returns -1 on a syntax error (missing SP, missing/
- * unbalanced parens, empty list, a field name containing a double quote,
- * or a field-name list too long for HEADER_FIELDS_MAX) -- unlike an
- * *unsupported* item, which parse_fetch_atts() silently degrades, a
- * fetch-att that announces itself as HEADER.FIELDS but is malformed is a
- * real client error (this implementation does support the item), so the
- * caller sends BAD rather than silently dropping it.
- *
- * Field names are required to be bare atoms (no quoted-string or literal
- * form, checked only by rejecting an embedded '"' -- RFC 5322's own
- * field-name syntax, 1*ftext, already excludes space/colon/control
- * characters, so a bare atom is the only shape any legitimate field name
- * can ever take; this implementation simply doesn't bother recognizing
- * the quoted-string astring alternative nothing real would need).
- */
-static int
-parse_header_fields_att(const char *inner, int *not_out, char *fields_out,
- size_t fields_outsize)
-{
- char listbuf[HEADER_FIELDS_LABEL_MAX];
- char *p = listbuf, *listp, *end;
- char *name, *save;
- int first = 1;
-
- *not_out = 0;
- fields_out[0] = '\0';
-
- if (strlcpy(listbuf, inner, sizeof(listbuf)) >= sizeof(listbuf))
- return (-1);
-
- if (strncasecmp(p, "HEADER.FIELDS", 13) != 0)
- return (-1);
- p += 13;
-
- if (strncasecmp(p, ".NOT", 4) == 0) {
- *not_out = 1;
- p += 4;
- }
-
- if (*p != ' ')
- return (-1);
- p++;
-
- listp = p;
- if (*listp != '(')
- return (-1);
- listp++;
-
- end = strchr(listp, ')');
- if (end == NULL || end[1] != '\0')
- return (-1);
- *end = '\0';
-
- if (*listp == '\0')
- return (-1); /* header-list requires at least one name */
-
- for (name = strtok_r(listp, " ", &save); name != NULL;
- name = strtok_r(NULL, " ", &save)) {
- if (strchr(name, '"') != NULL)
- return (-1);
- if (!first &&
- strlcat(fields_out, " ", fields_outsize) >= fields_outsize)
- return (-1);
- if (strlcat(fields_out, name, fields_outsize) >= fields_outsize)
- return (-1);
- first = 0;
- }
-
- return (0);
-}
-
-/*
- * RFC 9051 SS6.4.5.1: section-part = nz-number *("." nz-number), e.g.
- * "3.1". Validates the grammar only -- doesn't parse it into a path
- * array, since listener.c never needs the individual part numbers
- * itself; it just needs to know whether the bracket body it just
- * isolated is a legal section-part before setting MBOX_FETCH_BODY_PART
- * and handing the verbatim string on to store.c's own parse_section_
- * part() (store.c and listener.c are separate processes -- this
- * validation exists so an *invalid* string is caught here and silently
- * degraded like any other unsupported BODY.PEEK[...] shape, rather than
- * crossing the imsg boundary and only failing there). Bounded at MIME_
- * MAX_DEPTH components, same as store.c's own parser and for the same
- * reason: a path deeper than that could never match anything build_
- * bodystructure() would ever describe.
- */
-static int
-section_part_valid(const char *s)
-{
- int n = 0;
-
- if (s == NULL || *s == '\0')
- return (0);
-
- while (*s != '\0') {
- if (*s < '1' || *s > '9') /* nz-number: digit-nz first */
- return (0);
- if (n >= MIME_MAX_DEPTH)
- return (0);
- n++;
- while (*s >= '0' && *s <= '9')
- s++;
- if (*s == '\0')
- return (1);
- if (*s != '.')
- return (0);
- s++;
- if (*s == '\0')
- return (0); /* trailing dot */
- }
- return (1);
-}
-
-/*
- * RFC 9051 SS6.4.5's origin-octet-then-count partial-range suffix,
- * `"<" number "." nz-number ">"` (a BODY[<section>]<<partial>> request's
- * trailing "<start.count>", e.g. "<0.16384>"). s is whatever followed a
- * BODY.PEEK[...] token's closing "]" -- empty (no partial range: *has_
- * partial_out = 0, success) or exactly one "<...>" as above. count is
- * only required to be numeric here, not strictly nz-number (a client-
- * sent "<5.0>" is a strange request, not a malformed one --
- * apply_partial_range() in store.c already handles a zero count
- * gracefully, returning an empty string, same "be liberal" precedent
- * this function's own caller uses for unsupported section shapes).
- *
- * Returns 0 on success (including the "no suffix at all" case), -1 if s
- * is non-empty but doesn't match the grammar exactly.
- */
-static int
-parse_partial_suffix(const char *s, int *has_partial_out,
- uint32_t *start_out, uint32_t *count_out)
-{
- const char *p;
- char *end;
- unsigned long start, count;
-
- *has_partial_out = 0;
- *start_out = 0;
- *count_out = 0;
-
- if (s == NULL || *s == '\0')
- return (0);
-
- if (s[0] != '<')
- return (-1);
- p = s + 1;
-
- if (*p < '0' || *p > '9')
- return (-1);
- errno = 0;
- start = strtoul(p, &end, 10);
- if (errno != 0 || start > UINT32_MAX || *end != '.')
- return (-1);
- p = end + 1;
-
- if (*p < '0' || *p > '9')
- return (-1);
- errno = 0;
- count = strtoul(p, &end, 10);
- if (errno != 0 || count > UINT32_MAX || *end != '>' || end[1] != '\0')
- return (-1);
-
- *has_partial_out = 1;
- *start_out = (uint32_t)start;
- *count_out = (uint32_t)count;
- return (0);
-}
-
-/*
- * RFC 9051 SS6.4.5: `fetch-att`, plus the "ALL" / "FULL" / "FAST" macros.
- * spec is the fetch-att portion of the command line, with or without
- * surrounding parentheses (a single un-parenthesized item, e.g. bare
- * "FLAGS", is valid ABNF too), modified in place (strtok_r()).
- *
- * Returns 0 and fills *attrs_out on success; -1 (with *errmsg set) for a
- * syntax error -- caller sends BAD.
- *
- * Real bug caught testing against Apple Mail live on premio: this used to
- * return -2 (caller sends NO) the instant it saw a single recognized-but-
- * unsupported item (BODY[...]/ENVELOPE/RFC822/etc, or the ALL/FULL macros
- * that expand to include ENVELOPE) -- rejecting the *entire* fetch-att
- * list, even when the same request also asked for several items this
- * server fully supports. Apple Mail's actual first FETCH after SELECT was
- * "FETCH 1:3 (INTERNALDATE UID RFC822.SIZE FLAGS BODY.PEEK[HEADER])" --
- * four fully-supported items bundled with one unsupported one -- so the
- * old behavior meant Mail got nothing at all for any of it, not even the
- * flags/dates/sizes it could have had, which (paired with LSUB not being
- * implemented at all -- see list_dispatch()'s header comment above) left
- * it with literally no data to render, hence a blank Inbox.
- *
- * Now: an unsupported item is silently skipped (not added to attrs) and
- * parsing continues, rather than aborting the whole request -- the same
- * "be liberal about what doesn't apply, answer what you can" leniency
- * SS6.3.1 already mandates for ENABLE's unrecognized arguments. At the
- * time this comment was first written, ALL/FULL both degraded to the FAST
- * set once their ENVELOPE component was dropped; ENVELOPE is real now (see
- * MBOX_FETCH_ENVELOPE below), so ALL is a complete macro and only FULL
- * still degrades, to everything but BODY. *degraded_out is set to 1
- * whenever anything was silently
- * dropped this way, purely so the caller can log it -- it doesn't change
- * the wire response, which is now a normal OK with whatever data items
- * *were* recognized. The -2/NO path is kept for the one case dropping
- * still can't paper over: every requested item was unsupported (or the
- * list was ALL/FULL alone, which -- unlike bundled with real items above
- * -- has nothing left to fall back to without inventing data the client
- * didn't ask for), so there would be nothing at all to answer with.
- */
-static int
-parse_fetch_atts(char *spec, uint32_t *attrs_out, int *degraded_out,
- int *header_fields_not_out, char *header_fields_out,
- size_t header_fields_outsize, char *header_fields_label_out,
- size_t header_fields_label_outsize, int *bodystructure_full_out,
- char *section_part_out, size_t section_part_outsize,
- int *has_partial_out, uint32_t *partial_start_out,
- uint32_t *partial_count_out, const char **errmsg)
-{
- char *p, *tok, *save;
- size_t len;
- uint32_t attrs = 0;
- int degraded = 0;
- int has_partial = 0;
- uint32_t partial_start = 0, partial_count = 0;
-
- *errmsg = NULL;
- *attrs_out = 0;
- *degraded_out = 0;
- *header_fields_not_out = 0;
- header_fields_out[0] = '\0';
- header_fields_label_out[0] = '\0';
- *bodystructure_full_out = 0;
- section_part_out[0] = '\0';
- *has_partial_out = 0;
- *partial_start_out = 0;
- *partial_count_out = 0;
-
- if (spec == NULL || *spec == '\0') {
- *errmsg = "missing message data item(s)";
- return (-1);
- }
-
- p = spec;
- len = strlen(p);
- if (len >= 2 && p[0] == '(' && p[len - 1] == ')') {
- p[len - 1] = '\0';
- p++;
- }
- if (*p == '\0') {
- *errmsg = "empty message data item list";
- return (-1);
- }
-
- for (tok = fetch_att_tok(p, &save); tok != NULL;
- tok = fetch_att_tok(NULL, &save)) {
- if (strcasecmp(tok, "FAST") == 0) {
- /* SS6.4.5: "Macro equivalent to: (FLAGS INTERNALDATE
- * RFC822.SIZE)" -- all three are real in v1, so FAST
- * is a complete, correct macro here, same as ALL and
- * (now that BODYSTRUCTURE is implemented too) FULL. */
- attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
- MBOX_FETCH_RFC822_SIZE;
- } else if (strcasecmp(tok, "ALL") == 0) {
- /* SS6.4.5: "Macro equivalent to: (FLAGS INTERNALDATE
- * RFC822.SIZE ENVELOPE)" -- all four are real in v1 as
- * of the ENVELOPE pass, so ALL is now a complete,
- * correct macro, not a degraded one. */
- attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
- MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_ENVELOPE;
- } else if (strcasecmp(tok, "FULL") == 0) {
- /* SS6.4.5: "Macro equivalent to: (FLAGS INTERNALDATE
- * RFC822.SIZE ENVELOPE BODY)" -- BODY here is the bare,
- * non-extensible form (MBOX_FETCH_BODYSTRUCTURE, see
- * imapd.h), real as of this pass, so FULL is now a
- * complete, correct macro too, not a degraded one.
- * bodystructure_full_out stays 0 (the "BODY" label,
- * not "BODYSTRUCTURE") since that's literally what the
- * macro's own definition expands to. */
- attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
- MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_ENVELOPE |
- MBOX_FETCH_BODYSTRUCTURE;
- } else if (strcasecmp(tok, "FLAGS") == 0) {
- attrs |= MBOX_FETCH_FLAGS;
- } else if (strcasecmp(tok, "UID") == 0) {
- attrs |= MBOX_FETCH_UID;
- } else if (strcasecmp(tok, "INTERNALDATE") == 0) {
- attrs |= MBOX_FETCH_INTERNALDATE;
- } else if (strcasecmp(tok, "RFC822.SIZE") == 0) {
- attrs |= MBOX_FETCH_RFC822_SIZE;
- } else if (strcasecmp(tok, "MODSEQ") == 0) {
- /* RFC 7162 SS3.1.4.2 fetch-mod-sequence: "MODSEQ" --
- * causes MODSEQ FETCH response data items, and is
- * itself a CONDSTORE-enabling command (SS3.1) --
- * cmd_fetch() checks this bit to decide whether to
- * call session_condstore_enable(). */
- attrs |= MBOX_FETCH_MODSEQ;
- } else if (strcasecmp(tok, "BODY.PEEK[HEADER]") == 0) {
- /*
- * The one BODY[...] variant this pass actually
- * implements -- see MBOX_FETCH_BODY_HEADER's comment
- * in imapd.h for why it's scoped to exactly this
- * token (RFC 5322 header, raw and unparsed, via
- * store.c's read_message_header()) and not plain
- * BODY[HEADER] (would need to implicitly set \Seen,
- * not implemented), HEADER.FIELDS/.NOT, TEXT, or any
- * MIME-part addressing. An exact strcasecmp() match,
- * checked before the generic BODY-prefix catch-all
- * below so this one recognized case doesn't fall into
- * it and get marked degraded instead.
- */
- attrs |= MBOX_FETCH_BODY_HEADER;
- } else if (strncasecmp(tok, "BODY.PEEK[", strlen("BODY.PEEK[")) ==
- 0 && strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS",
- strlen("BODY.PEEK[HEADER.FIELDS")) != 0) {
- /*
- * Reached for every BODY.PEEK[...] shape except
- * HEADER (exact match above) and HEADER.FIELDS[.NOT]
- * (own prefix branch just below, excluded from this
- * one by the strncasecmp() != 0 above so the two
- * don't fight over the same token): BODY.PEEK[]
- * (whole message, SS6.4.5: "If BODY[] is specified
- * ... the FETCH is requesting the [RFC5322]
- * expression of the entire message"), BODY.PEEK
- * [TEXT] (SS6.4.5.1: "the text body of the message,
- * omitting the [RFC5322] header"), or BODY.PEEK
- * [<section-part>] (MIME part-addressed content,
- * e.g. "3.1" -- see MBOX_FETCH_BODY_PART's comment
- * in imapd.h), each optionally followed by a
- * <<start.count>> partial-range suffix (SS6.4.5,
- * e.g. "BODY.PEEK[TEXT]<0.16384>" -- the exact shape
- * observed from real Apple Mail traffic; see
- * parse_partial_suffix()'s own comment). Unified into
- * one branch (replacing this pass's former separate
- * exact-match BODY.PEEK[]/BODY.PEEK[TEXT] cases) since
- * all three now share the same "parse the bracket
- * body, then the optional trailing <...>" shape.
- *
- * Same .PEEK-only, checked-before-the-generic-catch-
- * all scoping as BODY.PEEK[HEADER] above, for the
- * same reason (plain BODY[...] implicitly sets \Seen,
- * not implemented for any content item).
- */
- const char *bracket_start = tok +
- strlen("BODY.PEEK[");
- char *close;
- char inner[SECTION_PART_MAX];
- const char *suffix;
-
- close = strchr(bracket_start, ']');
- if (close == NULL) {
- degraded = 1; /* not even well-bracketed --
- * same lenient "unrecognized
- * BODY[...] shape, skip it"
- * handling every other
- * unsupported form here gets,
- * rather than a new BAD path */
- continue;
- }
- if ((size_t)(close - bracket_start) >= sizeof(inner)) {
- degraded = 1;
- continue;
- }
- memcpy(inner, bracket_start, close - bracket_start);
- inner[close - bracket_start] = '\0';
- suffix = close + 1;
-
- if (suffix[0] != '\0' &&
- parse_partial_suffix(suffix, &has_partial,
- &partial_start, &partial_count) == -1) {
- *errmsg = "malformed <partial> range";
- return (-1);
- }
-
- if (inner[0] == '\0') {
- attrs |= MBOX_FETCH_BODY_WHOLE;
- } else if (strcasecmp(inner, "TEXT") == 0) {
- attrs |= MBOX_FETCH_BODY_TEXT;
- } else if (section_part_valid(inner)) {
- attrs |= MBOX_FETCH_BODY_PART;
- if (strlcpy(section_part_out, inner,
- section_part_outsize) >=
- section_part_outsize) {
- attrs &= ~MBOX_FETCH_BODY_PART;
- degraded = 1;
- continue;
- }
- } else {
- degraded = 1; /* recognized-shape-but-
- * unsupported section, e.g.
- * nested MESSAGE/RFC822
- * numbering ("2.1.TEXT") --
- * same silent-skip precedent
- * as the generic BODY-prefix
- * catch-all below */
- continue;
- }
- } else if (strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS",
- strlen("BODY.PEEK[HEADER.FIELDS")) == 0) {
- /*
- * "BODY.PEEK[HEADER.FIELDS (...)"/"BODY.PEEK[HEADER.
- * FIELDS.NOT (...)" -- checked by prefix (not exact
- * match, unlike BODY.PEEK[HEADER]/[]/[TEXT] above)
- * since the field-name list itself varies per
- * request. tok is already the whole bracketed atom
- * here, embedded space and all, thanks to fetch_att_
- * tok() -- see that function's comment for the real
- * bug this replaced. Strips the outer "BODY.PEEK["
- * and trailing "]" here (both already confirmed
- * present by the strncasecmp() prefix match and the
- * closing-bracket check below) and hands the
- * "HEADER.FIELDS[...] (...)" interior to parse_
- * header_fields_att() for the real grammar work.
- * A second HEADER.FIELDS-shaped item in the same
- * FETCH (legal per SS6.4.5, unseen from any real
- * client) is silently ignored once one has already
- * been captured -- same "first one wins, no error"
- * simplification as MBOX_FETCH_BODY_HEADER's
- * precedence over this bit, decided below.
- */
- size_t toklen = strlen(tok);
- char inner[HEADER_FIELDS_LABEL_MAX];
-
- if (toklen < strlen("BODY.PEEK[") + 1 ||
- tok[toklen - 1] != ']') {
- *errmsg = "malformed HEADER.FIELDS section";
- return (-1);
- }
- if (attrs & MBOX_FETCH_HEADER_FIELDS)
- continue; /* already captured one -- ignore
- any further duplicates */
-
- if (toklen - strlen("BODY.PEEK[") - 1 >=
- sizeof(inner)) {
- *errmsg = "HEADER.FIELDS section too long";
- return (-1);
- }
- memcpy(inner, tok + strlen("BODY.PEEK["),
- toklen - strlen("BODY.PEEK[") - 1);
- inner[toklen - strlen("BODY.PEEK[") - 1] = '\0';
-
- if (parse_header_fields_att(inner,
- header_fields_not_out, header_fields_out,
- header_fields_outsize) == -1) {
- *errmsg = "malformed HEADER.FIELDS section";
- return (-1);
- }
- if (strlcpy(header_fields_label_out, inner,
- header_fields_label_outsize) >=
- header_fields_label_outsize) {
- *errmsg = "HEADER.FIELDS section too long";
- return (-1);
- }
- attrs |= MBOX_FETCH_HEADER_FIELDS;
- } else if (strcasecmp(tok, "ENVELOPE") == 0) {
- /*
- * RFC 9051 SS7.5.2 ENVELOPE -- see MBOX_FETCH_ENVELOPE's
- * comment in imapd.h for the full scoping story
- * (parsed RFC 5322 header fields + a deliberately
- * scoped-down address-list parser, no MIME awareness --
- * BODYSTRUCTURE, just below, is the one with that).
- * No .PEEK variant
- * and no \Seen side effect to avoid, so -- unlike the
- * BODY.PEEK[...] family above -- the bare token is
- * enough.
- */
- attrs |= MBOX_FETCH_ENVELOPE;
- } else if (strcasecmp(tok, "BODY") == 0 ||
- strcasecmp(tok, "BODYSTRUCTURE") == 0) {
- /*
- * RFC 9051 SS9's fetch-att: `"BODY" ["STRUCTURE"]` --
- * bare "BODY" (no brackets) and "BODYSTRUCTURE" are
- * both requests for the non-extensible body structure
- * (see MBOX_FETCH_BODYSTRUCTURE's comment in
- * imapd.h for why this implementation's BODY and
- * BODYSTRUCTURE produce byte-identical output -- no
- * extension data is ever emitted). Checked by exact
- * match, before the generic "BODY" prefix catch-all
- * below, so these two recognized cases don't fall
- * into it and get marked degraded instead -- same
- * precedent as BODY.PEEK[HEADER]/[]/[TEXT] above.
- * bodystructure_full_out records which literal token
- * was used, purely so session_send_fetch_response()
- * can echo the same label back (SS9's grammar: `"BODY"
- * ["STRUCTURE"] SP body` -- the response label itself
- * is "BODY" or "BODYSTRUCTURE", not a separate response
- * name). If a client somehow requests both in the same
- * FETCH (legal, redundant, unseen from any real
- * client), whichever is parsed last simply wins the
- * label -- a low-stakes, purely cosmetic difference,
- * not worth a "first wins" guard.
- */
- attrs |= MBOX_FETCH_BODYSTRUCTURE;
- *bodystructure_full_out =
- (strcasecmp(tok, "BODYSTRUCTURE") == 0);
- } else if (strncasecmp(tok, "BODY", 4) == 0 ||
- strcasecmp(tok, "RFC822") == 0 ||
- strcasecmp(tok, "RFC822.HEADER") == 0 ||
- strcasecmp(tok, "RFC822.TEXT") == 0) {
- /* BODY[...]/BODY.PEEK[...] beyond the four items
- * already implemented above (whole message, TEXT,
- * HEADER, HEADER.FIELDS[.NOT]) -- specifically MIME
- * part-addressed content, e.g. BODY[1.2] or BODY.PEEK
- * [2.TEXT], which BODYSTRUCTURE (just above) only
- * *describes* the existence of, never returns the
- * content of -- plus the RFC822(.HEADER/.TEXT) content
- * shorthands, need actual per-part content extraction
- * beyond what's implemented, not designed this pass.
- * strncasecmp() (not strcasecmp()) for the BODY
- * prefix specifically catches BODY[...]/BODY.PEEK[...]
- * tokens too, even though strtok_r() has already
- * split a bracketed fetch-att with embedded spaces
- * (e.g. "BODY[HEADER.FIELDS (DATE FROM)]") into
- * multiple tokens -- the first such token alone is
- * enough to recognize and skip the whole item. Note
- * this prefix match would also catch bare "BODY"/
- * "BODYSTRUCTURE" if they ever reached here, but the
- * exact-match branch just above already claims both
- * first. Silently dropped now (see header comment)
- * rather than aborting the whole FETCH. */
- degraded = 1;
- } else {
- *errmsg = "unknown message data item";
- return (-1);
- }
- }
-
- if (attrs == 0) {
- /* Every requested item was unsupported -- nothing left to
- * answer with, unlike the bundled case this function now
- * handles gracefully. ALL/FAST/FULL are all complete macros
- * as of the BODYSTRUCTURE pass, so this is only reached by a
- * request naming exclusively still-unsupported items, e.g.
- * BODY[<part>]/BODY.PEEK[<part>] (MIME part-addressed
- * content) or RFC822/RFC822.HEADER/RFC822.TEXT alone. */
- *errmsg = "cannot fetch that message content yet -- "
- "supported: FLAGS/UID/INTERNALDATE/RFC822.SIZE/MODSEQ/"
- "ENVELOPE/(BODY|BODYSTRUCTURE)/BODY.PEEK[...]";
- return (-2);
- }
-
- /*
- * If a client somehow requested both plain BODY.PEEK[HEADER] and a
- * BODY.PEEK[HEADER.FIELDS...] variant in the same FETCH (legal per
- * SS6.4.5, unseen from any real client so far), HEADER wins --
- * there's only one pending_header_* slot on struct session, and the
- * whole header is a strict superset of any subset of it, same "more
- * general variant wins" precedent MBOX_FETCH_BODY_WHOLE already
- * uses over MBOX_FETCH_BODY_TEXT.
- */
- if ((attrs & MBOX_FETCH_BODY_HEADER) &&
- (attrs & MBOX_FETCH_HEADER_FIELDS))
- attrs &= ~MBOX_FETCH_HEADER_FIELDS;
-
- *attrs_out = attrs;
- *degraded_out = degraded;
- /*
- * has_partial/partial_start/partial_count were accumulated into
- * local variables (not written straight to the out-params) by
- * whichever BODY.PEEK[...] branch matched above, mirroring how attrs
- * itself is built up in a local before this one final copy-out --
- * done here, not per-branch, so the "if a client requests both
- * BODY.PEEK[HEADER] and BODY.PEEK[HEADER.FIELDS...], HEADER wins"
- * resolution just above stays the single place attrs gets adjusted
- * after parsing, without also needing a matching adjustment to which
- * item's partial range should apply.
- */
- *has_partial_out = has_partial;
- *partial_start_out = partial_start;
- *partial_count_out = partial_count;
- return (0);
-}
-
-static const char *fetch_month_names[12] = {
- "Jan", "Feb", "Mar", "Apr", "May", "Jun",
- "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
-};
-
-/*
- * RFC 9051 SS9: `date-time = DQUOTE date-day-fixed "-" date-month "-"
- * date-year SP time SP zone DQUOTE`, `date-day-fixed = (SP DIGIT) /
- * 2DIGIT` (space-padded, not zero-padded, below 10), `zone = ("+" / "-")
- * 4DIGIT`. Always formats in UTC ("+0000"): ts is a bare Unix timestamp
- * (store.c's parse_maildir_timestamp()) with no timezone information
- * attached at all, a chroot'd store child can't be assumed to have tzdata
- * unveiled, and "+0000" is unambiguous -- a deliberate implementation
- * simplification, not something the RFC itself requires (it permits any
- * valid zone offset).
- */
-static void
-format_internaldate(int64_t ts, char *out, size_t outsize)
-{
- struct tm tm;
- time_t t = (time_t)ts;
-
- if (gmtime_r(&t, &tm) == NULL) {
- strlcpy(out, "01-Jan-1970 00:00:00 +0000", outsize);
- return;
- }
-
- snprintf(out, outsize, "%2d-%s-%04d %02d:%02d:%02d +0000",
- tm.tm_mday, fetch_month_names[tm.tm_mon], tm.tm_year + 1900,
- tm.tm_hour, tm.tm_min, tm.tm_sec);
-}
-
-/*
- * snprintf(3)-into-a-growing-buffer helper for session_send_fetch_
- * response(): appends at buf + *len, then advances *len by however much
- * was (or would have been) written. Guards against the exact bug flagged
- * while this function was being designed -- naively chaining
- * `snprintf(buf + len, sizeof(buf) - len, ...)` calls is only safe as long
- * as len never exceeds sizeof(buf); if an earlier call were ever truncated,
- * snprintf(3)'s return value (the length that *would* have been written)
- * can push len past sizeof(buf), and the next call's `sizeof(buf) - len`
- * would underflow (size_t is unsigned) into a huge value. Clamping *len to
- * bufsize here, plus the "*len >= bufsize" early return, means every
- * subsequent call sees a valid, non-negative remaining size instead.
- */
-static void
-fetch_append(char *buf, size_t bufsize, size_t *len, const char *fmt, ...)
-{
- va_list ap;
- int n;
-
- if (*len >= bufsize)
- return;
-
- va_start(ap, fmt);
- n = vsnprintf(buf + *len, bufsize - *len, fmt, ap);
- va_end(ap);
-
- if (n < 0)
- return;
-
- *len += (size_t)n;
- if (*len > bufsize)
- *len = bufsize;
-}
-
-/*
- * Formats and sends one untagged "* <seqno> FETCH (...)" response (RFC
- * 9051 SS7.5.2) for a single IMSG_MBOX_FETCH_META reply. Only prints the
- * data items s->fetch_attrs actually requested: store.c's handle_mbox_
- * fetch() always populates every field of struct imsg_mbox_fetch_meta it
- * can regardless of what was asked for (see that struct's comment in
- * imapd.h), so this function is what actually enforces "don't show the
- * client attributes it didn't ask for".
- *
- * Field order (FLAGS, UID, INTERNALDATE, RFC822.SIZE) matches the bit
- * order of the MBOX_FETCH_* constants, not anything RFC 9051 requires --
- * SS7.5.2 doesn't mandate a msg-att ordering.
- *
- * buf is sized generously (MBOX_FLAGS_MAX plus comfortable room for the
- * other three items' text) but, like every other fixed buffer in this
- * file, truncates rather than overflows if somehow exceeded; session_
- * untagged()'s own 512-byte buffer is the tighter, and ultimately
- * governing, bound in that unlikely case -- same "truncate rather than
- * overflow" style as session_reply()/session_untagged() themselves.
- *
- * BODY.PEEK[HEADER]/BODY.PEEK[]/BODY.PEEK[TEXT] addition: when any of these
- * were requested and store.c actually found something for this message
- * (s->pending_header_found / s->pending_body_found, stashed by the IMSG_
- * MBOX_FETCH_HEADER / IMSG_MBOX_FETCH_BODY cases just before this IMSG_
- * MBOX_FETCH_META arrived -- see struct session's comment), the response
- * can't be built as one NUL-terminated string handed to session_untagged()
- * the way every other item above is: RFC 9051 SS9's literal syntax puts a
- * "{n}\r\n" marker followed by exactly n raw octets (which may contain any
- * byte except NUL -- store.c's read_message_header()/read_message_body()
- * already reject content containing one) directly in the middle of the
- * response, with the closing ")" continuing right after those n octets,
- * not on a fresh "line" in the usual CRLF-delimited sense. A client can
- * legally request both BODY.PEEK[HEADER] and one of BODY.PEEK[]/BODY.PEEK
- * [TEXT] in the same FETCH (SS6.4.5 doesn't forbid combining section
- * specs), so this function has to be able to splice in zero, one, or two
- * such literal blocks -- not just the single hardcoded case the header-
- * only version of this function had. Each literal block gets its own
- * "flush buf so far as one raw write, then write the raw payload bytes"
- * cycle; buf/len are reused (reset) between blocks. Header is always
- * spliced in before body when both are present, matching MBOX_FETCH_*
- * bit order.
- *
- * ENVELOPE addition: s->pending_envelope_buf holds build_envelope()'s
- * *already-formatted* "(...)" text (see struct imsg_mbox_fetch_envelope's
- * comment in imapd.h), not raw message bytes needing a `{n}` literal
- * wrapper -- but it still can't be handed to fetch_append() into buf[768]
- * above, since a formatted envelope can be far larger than that (up to
- * ENVELOPE_MAX, 8192 bytes) and fetch_append() truncates rather than
- * overflows. So ENVELOPE reuses the same "flush buf, then a second raw
- * session_write() for the oversized part" mechanism the header/body
- * literals use, just without a `{n}\r\n` marker in front of it -- it's
- * written as "ENVELOPE " followed directly by the pre-formatted text, no
- * literal syntax involved at all. This also means an ENVELOPE-only FETCH
- * (no BODY.PEEK[...] items at all) now takes this branch too, where
- * before this addition only BODY.PEEK[...] requests ever did -- see
- * have_envelope below.
- *
- * BODYSTRUCTURE addition: same "already-formatted text, no literal, flush
- * buf first" shape as ENVELOPE, just written as "BODY " or "BODYSTRUCTURE "
- * (s->pending_bodystructure_label -- see struct session's comment on that
- * field for why the label has to echo whichever bare token the client
- * used) followed by build_bodystructure()'s text.
- */
-static void
-session_send_fetch_response(struct session *s,
- struct imsg_mbox_fetch_meta *meta)
-{
- char buf[768];
- char date[40];
- size_t len = 0;
- int need_sp = 0;
- int have_header = (s->fetch_attrs & (MBOX_FETCH_BODY_HEADER |
- MBOX_FETCH_HEADER_FIELDS)) && s->pending_header_found;
- int have_body = (s->fetch_attrs &
- (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT |
- MBOX_FETCH_BODY_PART)) && s->pending_body_found;
- int have_envelope = (s->fetch_attrs & MBOX_FETCH_ENVELOPE) &&
- s->pending_envelope_found;
- int have_bodystructure = (s->fetch_attrs & MBOX_FETCH_BODYSTRUCTURE) &&
- s->pending_bodystructure_found;
-
- fetch_append(buf, sizeof(buf), &len, "%u FETCH (", meta->seqno);
-
- if (s->fetch_attrs & MBOX_FETCH_FLAGS) {
- fetch_append(buf, sizeof(buf), &len, "FLAGS (%s)", meta->flags);
- need_sp = 1;
- }
- if (s->fetch_attrs & MBOX_FETCH_UID) {
- fetch_append(buf, sizeof(buf), &len, "%sUID %u",
- need_sp ? " " : "", meta->uid);
- need_sp = 1;
- }
- if (s->fetch_attrs & MBOX_FETCH_INTERNALDATE) {
- format_internaldate(meta->internaldate, date, sizeof(date));
- fetch_append(buf, sizeof(buf), &len, "%sINTERNALDATE \"%s\"",
- need_sp ? " " : "", date);
- need_sp = 1;
- }
- if (s->fetch_attrs & MBOX_FETCH_RFC822_SIZE) {
- fetch_append(buf, sizeof(buf), &len, "%sRFC822.SIZE %llu",
- need_sp ? " " : "", (unsigned long long)meta->size);
- need_sp = 1;
- }
- if (s->fetch_attrs & MBOX_FETCH_MODSEQ) {
- /* RFC 7162 SS3.1.4.2 fetch-mod-resp: "MODSEQ" SP "("
- * permsg-modsequence ")". */
- fetch_append(buf, sizeof(buf), &len, "%sMODSEQ (%llu)",
- need_sp ? " " : "", (unsigned long long)meta->modseq);
- need_sp = 1;
- }
-
- if (have_header || have_body || have_envelope || have_bodystructure) {
- session_write(s, "* ", 2);
- session_write(s, buf, len);
-
- if (have_envelope) {
- len = 0;
- fetch_append(buf, sizeof(buf), &len, "%sENVELOPE ",
- need_sp ? " " : "");
- session_write(s, buf, len);
- if (s->pending_envelope_len > 0)
- session_write(s, s->pending_envelope_buf,
- s->pending_envelope_len);
- need_sp = 1;
- }
- if (have_bodystructure) {
- len = 0;
- fetch_append(buf, sizeof(buf), &len, "%s%s ",
- need_sp ? " " : "", s->pending_bodystructure_label);
- session_write(s, buf, len);
- if (s->pending_bodystructure_len > 0)
- session_write(s, s->pending_bodystructure_buf,
- s->pending_bodystructure_len);
- need_sp = 1;
- }
- if (have_header) {
- len = 0;
- fetch_append(buf, sizeof(buf), &len,
- "%sBODY[%s] {%u}\r\n", need_sp ? " " : "",
- s->pending_header_label, s->pending_header_len);
- session_write(s, buf, len);
- if (s->pending_header_len > 0)
- session_write(s, s->pending_header_buf,
- s->pending_header_len);
- need_sp = 1;
- }
- if (have_body) {
- /*
- * RFC 9051 SS6.4.5: "The origin octet facility MUST
- * NOT be used by a server in a FETCH response unless
- * the client specifically requested it" -- and even
- * then, only the *origin* (pending_body_partial_
- * origin, the requested start octet) is echoed, never
- * the count store.c actually returned. pending_body_
- * label already holds the right text regardless of
- * which of WHOLE/TEXT/PART was selected -- see that
- * field's own comment.
- */
- len = 0;
- if (s->pending_body_has_partial)
- fetch_append(buf, sizeof(buf), &len,
- "%sBODY[%s]<%u> {%u}\r\n",
- need_sp ? " " : "", s->pending_body_label,
- s->pending_body_partial_origin,
- s->pending_body_len);
- else
- fetch_append(buf, sizeof(buf), &len,
- "%sBODY[%s] {%u}\r\n", need_sp ? " " : "",
- s->pending_body_label, s->pending_body_len);
- session_write(s, buf, len);
- if (s->pending_body_len > 0)
- session_write(s, s->pending_body_buf,
- s->pending_body_len);
- }
- session_write(s, ")\r\n", 3);
- } else {
- fetch_append(buf, sizeof(buf), &len, ")");
-
- if (len >= sizeof(buf))
- log_warnx("session %u: FETCH response for seq %u "
- "truncated", s->id, meta->seqno);
-
- session_untagged(s, buf);
- }
-
- /*
- * Reset pending_header_* and pending_body_* regardless of whether
- * this particular message had anything to give (have_header/have_body
- * false just means store.c found nothing -- pending_*_found itself
- * still needs resetting so it doesn't leak into the next message's
- * response, same reasoning the header-only version of this function
- * already documented).
- */
- if (have_header) {
- free(s->pending_header_buf);
- s->pending_header_buf = NULL;
- s->pending_header_len = 0;
- }
- if (s->fetch_attrs & (MBOX_FETCH_BODY_HEADER | MBOX_FETCH_HEADER_FIELDS))
- s->pending_header_found = 0;
-
- if (have_body) {
- free(s->pending_body_buf);
- s->pending_body_buf = NULL;
- s->pending_body_len = 0;
- }
- if (s->fetch_attrs & (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT |
- MBOX_FETCH_BODY_PART))
- s->pending_body_found = 0;
-
- if (have_envelope) {
- free(s->pending_envelope_buf);
- s->pending_envelope_buf = NULL;
- s->pending_envelope_len = 0;
- }
- if (s->fetch_attrs & MBOX_FETCH_ENVELOPE)
- s->pending_envelope_found = 0;
-
- if (have_bodystructure) {
- free(s->pending_bodystructure_buf);
- s->pending_bodystructure_buf = NULL;
- s->pending_bodystructure_len = 0;
- }
- if (s->fetch_attrs & MBOX_FETCH_BODYSTRUCTURE)
- s->pending_bodystructure_found = 0;
-}
-
-/*
- * STORE's untagged FETCH response (RFC 9051 SS6.4.6's own example: "* 2
- * FETCH (FLAGS (\Deleted \Seen))") shows FLAGS, regardless of what the
- * original STORE actually changed -- unlike FETCH's own response, there's
- * no s->fetch_attrs-style bitmask to consult here because STORE only ever
- * has one client-requested data item to report. meta->flags is store.c's
- * handle_mbox_store()-computed post-STORE value, not the client-supplied
- * delta.
- *
- * RFC 7162 addition this pass: also shows MODSEQ whenever this session is
- * CONDSTORE-aware (s->condstore_enabled), independent of whether *this*
- * particular STORE used UNCHANGEDSINCE -- SS3.1.3's own Examples 3-10 all
- * show MODSEQ in the STORE echo purely because CONDSTORE was already
- * enabled for the session, not because each individual STORE requested it
- * (STORE has no fetch-att list to request it with in the first place).
- * meta->modseq is always populated by store.c regardless (see imapd.h's
- * imsg_mbox_fetch_meta comment), so this is purely a "whether to print it"
- * decision, same split every other field in this struct already uses.
- */
-static void
-session_send_store_fetch_response(struct session *s,
- struct imsg_mbox_fetch_meta *meta)
-{
- char buf[MBOX_FLAGS_MAX + 96];
- size_t len;
-
- /*
- * RFC 9051 SS6.4.9 addition: a UID STORE's echo must include UID too
- * (see struct session's cmd_by_uid comment) -- inserted right after
- * FLAGS, matching SS6.4.9's own UID FETCH example's FLAGS-then-UID
- * order ("FLAGS (\Seen) UID 4827313"); element order within a
- * msg-att's parenthesized list isn't itself semantically significant
- * (RFC 9051 SS9's msg-att grammar is an unordered set of
- * alternatives), so there's no conflict with RFC 7162 SS3.1.3's own
- * example showing UID before MODSEQ in a *different* (FLAGS-omitted)
- * echo shape.
- */
- len = (size_t)snprintf(buf, sizeof(buf), "%u FETCH (FLAGS (%s)",
- meta->seqno, meta->flags);
- if (s->cmd_by_uid && len < sizeof(buf))
- len += (size_t)snprintf(buf + len, sizeof(buf) - len,
- " UID %u", meta->uid);
- if (s->condstore_enabled && len < sizeof(buf))
- len += (size_t)snprintf(buf + len, sizeof(buf) - len,
- " MODSEQ (%llu)", (unsigned long long)meta->modseq);
- if (len < sizeof(buf))
- snprintf(buf + len, sizeof(buf) - len, ")");
-
- session_untagged(s, buf);
-}
-
-/*
- * Splits a trailing RFC 4466 modifier list -- "[SP '(' modifier *(SP
- * modifier) ')']" -- off of spec (a fetch-att or store-att-flags spec that
- * may be followed by one), used by both cmd_fetch() (modifiers trail the
- * fetch-att list) and cmd_store_cmd() (modifiers instead lead, before
- * store-att-flags -- see that function's own comment on why it calls this
- * on a different substring). spec is modified in place: NUL-terminated
- * right after its own portion, with the returned pointer (or NULL, if
- * nothing trails) pointing at the still-parenthesized modifier text for
- * the caller's own parser to strip and tokenize.
- *
- * Depth-counts through spec's own parens (rather than assuming it's never
- * parenthesized) so this works whether spec itself is a bare token
- * ("FLAGS") or a parenthesized list ("(FLAGS UID)") -- v1 doesn't need to
- * handle nested parens *within* spec (no fetch-att or flag needs them),
- * but counting depth anyway costs nothing and avoids assuming that stays
- * true forever.
- */
-static char *
-split_trailing_modifiers(char *spec)
-{
- char *p = spec;
-
- if (*p == '(') {
- int depth = 0;
-
- for (;;) {
- if (*p == '(')
- depth++;
- else if (*p == ')') {
- depth--;
- if (depth == 0) {
- p++;
- break;
- }
- } else if (*p == '\0')
- return (NULL); /* unterminated -- let the
- * caller's own parser produce
- * the BAD for this */
- p++;
- }
- } else {
- while (*p != '\0' && *p != ' ')
- p++;
- }
-
- if (*p == '\0')
- return (NULL);
- *p++ = '\0';
- while (*p == ' ')
- p++;
- if (*p == '\0')
- return (NULL);
- return (p);
-}
-
-/*
- * Parses a FETCH command's trailing fetch-modifier list (RFC 4466's generic
- * syntax, extended by RFC 7162 SS3.1.4.1/SS3.2.6). modspec is the still-
- * parenthesized text split off by split_trailing_modifiers() above,
- * modified in place.
- *
- * CHANGEDSINCE <mod-sequence-value> is the only fetch-modifier this server
- * implements; it implicitly sets MBOX_FETCH_MODSEQ (SS3.1.4.1: "implicitly
- * adds the MODSEQ FETCH message data item"). VANISHED (RFC 7162 SS3.2.6) is
- * only legal on UID FETCH (by_uid) and only once this session has "ENABLE
- * QRESYNC"'d -- both a tagged BAD otherwise, matching parse_select_params()'s
- * existing pattern for QRESYNC select-params' own "requires ENABLE QRESYNC
- * first" check. VANISHED's *other* restriction -- "MUST only be specified
- * together with the CHANGEDSINCE UID FETCH modifier" -- can't be checked
- * here: CHANGEDSINCE might appear later in the same modifier list (order
- * isn't fixed), so fetch_dispatch() checks that combination itself, once
- * the whole list has been parsed; *want_vanished just carries "VANISHED was
- * present, syntax was fine" out to it. Anything else is an unrecognized
- * modifier -- BAD, since v1 defines no other fetch-modifier at all for this
- * server to legitimately ignore the way ENABLE ignores unknown capabilities.
- */
-static int
-parse_fetch_modifiers(char *modspec, struct imsg_mbox_fetch *req,
- struct session *s, int by_uid, int *want_vanished, const char **errmsg)
-{
- char *p, *tok, *save;
- size_t len;
-
- *errmsg = NULL;
- *want_vanished = 0;
- len = strlen(modspec);
- if (len < 2 || modspec[0] != '(' || modspec[len - 1] != ')') {
- *errmsg = "malformed fetch-modifier list";
- return (-1);
- }
- modspec[len - 1] = '\0';
- p = modspec + 1;
-
- for (tok = strtok_r(p, " ", &save); tok != NULL;
- tok = strtok_r(NULL, " ", &save)) {
- if (strcasecmp(tok, "CHANGEDSINCE") == 0) {
- char *valtok = strtok_r(NULL, " ", &save);
- char *ep;
-
- if (valtok == NULL) {
- *errmsg = "CHANGEDSINCE requires a "
- "mod-sequence value";
- return (-1);
- }
- errno = 0;
- req->changedsince = strtoull(valtok, &ep, 10);
- if (*ep != '\0' || errno != 0) {
- *errmsg = "invalid CHANGEDSINCE mod-sequence";
- return (-1);
- }
- req->has_changedsince = 1;
- req->attrs |= MBOX_FETCH_MODSEQ;
- } else if (strcasecmp(tok, "VANISHED") == 0) {
- if (!by_uid) {
- /* RFC 7162 SS3.2.6: "the VANISHED UID FETCH
- * modifier is NOT allowed with a FETCH
- * command. The server MUST return a tagged
- * BAD response..." */
- *errmsg = "VANISHED is only valid as a UID "
- "FETCH modifier (RFC 7162 SS3.2.6)";
- return (-1);
- }
- if (!s->qresync_enabled) {
- *errmsg = "VANISHED requires ENABLE QRESYNC "
- "first (RFC 7162 SS3.2.6)";
- return (-1);
- }
- *want_vanished = 1;
- } else {
- *errmsg = "unrecognized fetch modifier";
- return (-1);
- }
- }
-
- return (0);
-}
-
-/*
- * RFC 9051 SS6.4.5: `fetch = "FETCH" SP sequence-set SP ("ALL" / "FULL" /
- * "FAST" / fetch-att / "(" fetch-att *(SP fetch-att) ")")`, extended by RFC
- * 4466/RFC 7162 with an optional trailing fetch-modifier list: `[SP "("
- * fetch-modifier *(SP fetch-modifier) ")"]`.
- *
- * v1 scope: message METADATA (FLAGS, UID, INTERNALDATE, RFC822.SIZE,
- * MODSEQ), plus, across six real-client/hand-built-testing passes,
- * BODY.PEEK[HEADER], BODY.PEEK[]/BODY.PEEK[TEXT], BODY.PEEK[HEADER.FIELDS
- * (...)]/BODY.PEEK[HEADER.FIELDS.NOT (...)] (see imapd.h's MBOX_FETCH_
- * BODY_* / MBOX_FETCH_HEADER_FIELDS comments), ENVELOPE (see MBOX_FETCH_
- * ENVELOPE's comment) -- the parsed, structured RFC 5322 header summary
- * most clients need for a message list view -- and BODY/BODYSTRUCTURE (see
- * MBOX_FETCH_BODYSTRUCTURE's comment), full recursive MIME structure
- * parsing bounded by MIME_MAX_DEPTH/MIME_MAX_PARTS, without RFC 9051's
- * optional extension data. Everything else content-related -- plain
- * BODY[HEADER]/BODY[]/BODY[TEXT]/BODY[HEADER.FIELDS...] without .PEEK
- * (would implicitly set \Seen, a real design decision about flag-mutation-
- * during-FETCH not taken any of these passes), and BODY[<part>]/BODY.PEEK
- * [<part>] (MIME part-*addressed content*, i.e. actually returning one
- * specific part's bytes -- distinct from BODYSTRUCTURE, which only
- * describes the part tree's existence) -- remains a deliberate, flagged
- * scope cut, not full FETCH. parse_fetch_atts() silently drops each of
- * those (see its own header comment) rather than rejecting the whole
- * request.
- *
- * Also v1-scoped: exactly one sequence-set range or number per command,
- * never a comma-separated list -- rejected below with BAD rather than
- * silently fetching only the first sub-range (avoids needing a multi-range
- * queue in struct session, whose pending_tag/fetch_attrs fields already
- * only track a single in-flight async operation at a time).
- */
-static int
-cmd_fetch(struct session *s, const char *tag, char *args)
-{
- return fetch_dispatch(s, tag, args, 0);
-}
-
-/*
- * Shared body for cmd_fetch() (by_uid=0) and cmd_uid()'s FETCH branch
- * (by_uid=1) -- see cmd_fetch()'s own comment for the grammar/scope this
- * parses. RFC 9051 SS6.4.9 additions this pass, all gated on by_uid:
- *
- * - The sequence-set argument is resolved as UIDs, not sequence numbers
- * (req.by_uid, threaded to store.c -- see handle_mbox_fetch()'s comment
- * in store.c for the actual resolution).
- * - MBOX_FETCH_UID is forced into attrs regardless of what the client's
- * fetch-att list asked for (SS6.4.9: "server implementations MUST
- * implicitly include the UID message data item as part of any FETCH
- * response caused by a UID command") -- session_send_fetch_response()
- * needs no changes at all for this, since it already prints UID whenever
- * that bit is set.
- * - VANISHED is legal as a fetch-modifier (RFC 7162 SS3.2.6), checked by
- * parse_fetch_modifiers() itself for the by_uid/ENABLE QRESYNC
- * restrictions; the remaining restriction -- VANISHED requires
- * CHANGEDSINCE also be present -- is checked here, after the full
- * modifier list has been parsed (order-independent).
- * - s->cmd_by_uid is set unconditionally (to by_uid itself) before
- * dispatch, same as store_do()/search_dispatch()/session_request_
- * expunge() -- see that field's own comment in struct session. FETCH's
- * own response formatting doesn't consult it (see above), but it's set
- * anyway for consistency and because a later command in the same
- * session must never see a stale value from this one.
- */
-static int
-fetch_dispatch(struct session *s, const char *tag, char *args, int by_uid)
-{
- struct imsg_mbox_fetch req;
- char *seqtok, *attspec, *modspec;
- uint32_t lo, hi, attrs;
- int lo_star, hi_star, rc, want_vanished = 0, degraded;
- int header_fields_not = 0;
- char header_fields[HEADER_FIELDS_MAX];
- char header_fields_label[HEADER_FIELDS_LABEL_MAX];
- int bodystructure_full = 0;
- char section_part[SECTION_PART_MAX];
- int has_partial = 0;
- uint32_t partial_start = 0, partial_count = 0;
- const char *errmsg;
- const char *cmdname = by_uid ? "UID FETCH" : "FETCH";
-
- if (args == NULL) {
- session_reply(s, tag, "BAD",
- "FETCH requires a sequence set and message data item(s)");
- return (1);
- }
-
- seqtok = args;
- while (*args != '\0' && *args != ' ')
- args++;
- if (*args == '\0') {
- session_reply(s, tag, "BAD",
- "FETCH requires message data item(s)");
- return (1);
- }
- *args++ = '\0';
- while (*args == ' ')
- args++;
- attspec = args;
-
- if (strchr(seqtok, ',') != NULL) {
- session_reply(s, tag, "BAD",
- "comma-separated sequence sets not supported in v1 -- "
- "issue separate FETCH commands");
- return (1);
- }
-
- if (parse_seq_range(seqtok, &lo, &hi, &lo_star, &hi_star) == -1) {
- session_reply(s, tag, "BAD", "invalid sequence set");
- return (1);
- }
-
- modspec = split_trailing_modifiers(attspec);
-
- rc = parse_fetch_atts(attspec, &attrs, °raded, &header_fields_not,
- header_fields, sizeof(header_fields), header_fields_label,
- sizeof(header_fields_label), &bodystructure_full, section_part,
- sizeof(section_part), &has_partial, &partial_start,
- &partial_count, &errmsg);
- if (rc == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
- if (rc == -2) {
- session_reply(s, tag, "NO", errmsg);
- return (1);
- }
- if (degraded)
- log_debug("session %u: %s: one or more unsupported message "
- "data items silently dropped (plain BODY[...]/BODY[<part>]"
- "/BODY.PEEK[<part>] with MESSAGE/RFC822|GLOBAL or MULTIPART"
- " nested numbering/RFC822[.HEADER/.TEXT]) -- answering "
- "with whatever was recognized", s->id, cmdname);
-
- memset(&req, 0, sizeof(req));
- req.attrs = attrs;
- req.by_uid = by_uid;
- req.header_fields_not = header_fields_not;
- strlcpy(req.header_fields, header_fields, sizeof(req.header_fields));
-
- /*
- * has_partial/partial_start/partial_count apply uniformly to
- * whichever of WHOLE/TEXT/PART attrs ends up selecting (see store.c's
- * handle_mbox_fetch() comment) -- copied through to store.c
- * unconditionally here rather than gated on a specific bit, since
- * store.c is the one place that already knows the final WHOLE > TEXT
- * > PART precedence and applies the range to whichever it picks.
- * section_part is similarly always copied through; store.c only
- * consults it when MBOX_FETCH_BODY_PART actually wins.
- */
- strlcpy(req.section_part, section_part, sizeof(req.section_part));
- req.has_partial = has_partial;
- req.partial_start = partial_start;
- req.partial_count = partial_count;
-
- /*
- * The verbatim client-typed label ("HEADER" for plain BODY.PEEK
- * [HEADER], or e.g. "HEADER.FIELDS (DATE FROM)" for the fields
- * variant) never crosses the imsg boundary to store.c -- it's
- * purely a listener.c-side echo concern, stashed on the session now
- * so session_send_fetch_response() can use it once the matching
- * IMSG_MBOX_FETCH_HEADER/IMSG_MBOX_FETCH_META pair arrives per
- * message. See struct session's pending_header_label comment.
- */
- if (attrs & MBOX_FETCH_BODY_HEADER)
- strlcpy(s->pending_header_label, "HEADER",
- sizeof(s->pending_header_label));
- else if (attrs & MBOX_FETCH_HEADER_FIELDS)
- strlcpy(s->pending_header_label, header_fields_label,
- sizeof(s->pending_header_label));
-
- /*
- * Same idea as pending_header_label just above, for BODY.PEEK[]/
- * BODY.PEEK[TEXT]/BODY.PEEK[<section-part>] -- WHOLE > TEXT > PART
- * precedence matches store.c's handle_mbox_fetch() exactly (see that
- * function's comment), since both sides have to agree on which one a
- * client that somehow requested more than one of the three actually
- * gets. pending_body_has_partial/pending_body_partial_origin are only
- * set when the request has_partial applies to *this* winning variant
- * -- store.c already ties has_partial/partial_start/partial_count to
- * whichever of WHOLE/TEXT/PART it picks with the same precedence, so
- * there's nothing further to disambiguate here.
- */
- if (attrs & MBOX_FETCH_BODY_WHOLE)
- s->pending_body_label[0] = '\0';
- else if (attrs & MBOX_FETCH_BODY_TEXT)
- strlcpy(s->pending_body_label, "TEXT",
- sizeof(s->pending_body_label));
- else if (attrs & MBOX_FETCH_BODY_PART)
- strlcpy(s->pending_body_label, section_part,
- sizeof(s->pending_body_label));
- s->pending_body_has_partial = has_partial;
- s->pending_body_partial_origin = partial_start;
-
- /*
- * Same idea as pending_header_label just above, for BODYSTRUCTURE:
- * RFC 9051 SS9's `"BODY" ["STRUCTURE"] SP body` means the response
- * label itself has to echo whichever bare token the client used --
- * see struct session's pending_bodystructure_label comment.
- */
- if (attrs & MBOX_FETCH_BODYSTRUCTURE)
- strlcpy(s->pending_bodystructure_label,
- bodystructure_full ? "BODYSTRUCTURE" : "BODY",
- sizeof(s->pending_bodystructure_label));
-
- if (modspec != NULL) {
- if (parse_fetch_modifiers(modspec, &req, s, by_uid,
- &want_vanished, &errmsg) == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
- }
-
- if (want_vanished && !req.has_changedsince) {
- /* RFC 7162 SS3.2.6: "The VANISHED UID FETCH modifier MUST
- * only be specified together with the CHANGEDSINCE UID
- * FETCH modifier... the server MUST respond with a tagged
- * BAD response." */
- session_reply(s, tag, "BAD",
- "VANISHED requires CHANGEDSINCE also be specified "
- "(RFC 7162 SS3.2.6)");
- return (1);
- }
- req.want_vanished = want_vanished;
-
- if (by_uid)
- req.attrs |= MBOX_FETCH_UID;
-
- if (s->store_iev == NULL) {
- /* Same internal-invariant check as cmd_select() -- ST_SELECTED
- * requires store_iev to already be wired. */
- log_warnx("session %u: %s with no store channel wired",
- s->id, cmdname);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
-
- req.seq_lo = lo;
- req.seq_hi = hi;
- req.lo_is_star = lo_star;
- req.hi_is_star = hi_star;
-
- /* RFC 7162 SS3.1: the MODSEQ fetch-att and CHANGEDSINCE modifier are
- * both CONDSTORE-enabling commands. */
- if (req.attrs & MBOX_FETCH_MODSEQ)
- session_condstore_enable(s);
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- s->fetch_attrs = req.attrs;
- s->cmd_by_uid = by_uid;
- s->state = SESSION_FETCHING;
-
- if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_FETCH, 0, 0, -1,
- &req, sizeof(req)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_FETCH", s->id);
- imsgev_add(s->store_iev);
-
- return (1);
-}
-
-/*
- * RFC 9051 SS6.4.6: `store-att-flags = (["+" / "-"] "FLAGS" [".SILENT"]) SP
- * (flag-list / (flag *(SP flag)))`, `flag = "\Answered" / "\Flagged" /
- * "\Deleted" / "\Seen" / "\Draft" / flag-keyword / flag-extension ; Does
- * not include "\Recent"`, `flag-keyword = "$MDNSent" / "$Forwarded" /
- * "$Junk" / "$NotJunk" / "$Phishing" / atom`, `flag-extension = "\" atom`.
- *
- * modetok is the "(["+"/"-"] "FLAGS" [".SILENT"])" token (e.g. "+FLAGS",
- * "FLAGS.SILENT", "-FLAGS"); flagspec is everything after it, with or
- * without surrounding parentheses, modified in place (strtok_r()) --
- * same shape parse_fetch_atts() already accepts for fetch-att lists.
- *
- * *sysflags_out collects the five supported system flags as a MBOX_FLAG_*
- * bitmask. Any other "\"-prefixed token is either "\Recent" (explicitly
- * excluded from the `flag` production itself -- not a client error
- * exactly, but not something STORE can ever legitimately be asked to set)
- * or a flag-extension this server doesn't define (SS2.3.2: system flags
- * are "predefined in this specification" -- there is no mechanism here
- * for a client to invent a new one), so both are rejected the same way
- * FETCH rejects BODY[...]: -2/NO, "recognized syntax, not something this
- * server supports" rather than a syntax error. A bare (non-"\") token is
- * a keyword and is appended to keywords_out as-is, EXCEPT that a keyword
- * containing ':' or ',' is also rejected with -2/NO: both characters are
- * valid in IMAP's `atom` grammar, but the index format
- * (openimap-storage-backend.md) uses them as its own field/record
- * delimiters with no escaping mechanism, so storing such a keyword
- * verbatim would corrupt the index -- a v1 storage-format limitation,
- * not an IMAP protocol restriction, and treated as "unsupported" rather
- * than "invalid" for exactly that reason.
- *
- * System-flag name matching is case-insensitive (`strcasecmp()`) as a
- * deliberate implementation choice for interoperability -- RFC 9051's own
- * ABNF for `flag` doesn't state a case-sensitivity rule one way or the
- * other, so this isn't a sourced requirement, just this server being
- * lenient the same way it already is about repeated spaces in
- * parse_command_line(). mode/silent themselves are parsed separately by
- * the caller (the store-att-flags prefix, e.g. "+FLAGS.SILENT") -- this
- * function only ever sees the flag-list half.
- */
-static int
-parse_store_flags(char *flagspec, uint32_t *sysflags_out, char *keywords_out,
- size_t keywords_out_size, const char **errmsg)
-{
- char *p, *tok, *save;
- size_t len;
- uint32_t sysflags = 0;
- int first = 1;
-
- *errmsg = NULL;
- *sysflags_out = 0;
- keywords_out[0] = '\0';
-
- if (flagspec == NULL || *flagspec == '\0') {
- *errmsg = "missing flag list";
- return (-1);
- }
-
- p = flagspec;
- len = strlen(p);
- if (len >= 2 && p[0] == '(' && p[len - 1] == ')') {
- p[len - 1] = '\0';
- p++;
- }
- /* An empty flag-list, "()" or "", is valid ABNF-wise (flag-list =
- * "(" [flag *(SP flag)] ")") but pointless for STORE -- SET with no
- * flags would clear everything, which is a real (if unusual)
- * request, so this is only rejected when the caller can't tell SET
- * from ADD/REMOVE apart at this layer; see cmd_store_cmd(), which
- * allows an empty list only for a bare "FLAGS"/"FLAGS.SILENT". */
- if (*p == '\0')
- return (0);
-
- for (tok = strtok_r(p, " ", &save); tok != NULL;
- tok = strtok_r(NULL, " ", &save)) {
- if (tok[0] == '\\') {
- if (strcasecmp(tok, "\\Answered") == 0)
- sysflags |= MBOX_FLAG_ANSWERED;
- else if (strcasecmp(tok, "\\Flagged") == 0)
- sysflags |= MBOX_FLAG_FLAGGED;
- else if (strcasecmp(tok, "\\Deleted") == 0)
- sysflags |= MBOX_FLAG_DELETED;
- else if (strcasecmp(tok, "\\Seen") == 0)
- sysflags |= MBOX_FLAG_SEEN;
- else if (strcasecmp(tok, "\\Draft") == 0)
- sysflags |= MBOX_FLAG_DRAFT;
- else if (strcasecmp(tok, "\\Recent") == 0) {
- *errmsg = "\\Recent cannot be set -- RFC 9051 "
- "deprecates it and excludes it from the "
- "flag grammar entirely";
- return (-2);
- } else {
- *errmsg = "unsupported system flag -- v1 only "
- "supports \\Answered/\\Flagged/\\Deleted/"
- "\\Seen/\\Draft";
- return (-2);
- }
- continue;
- }
-
- if (strchr(tok, ':') != NULL || strchr(tok, ',') != NULL) {
- *errmsg = "keyword contains ':' or ',' -- not "
- "representable in this server's index format";
- return (-2);
- }
-
- if (!first)
- strlcat(keywords_out, ",", keywords_out_size);
- strlcat(keywords_out, tok, keywords_out_size);
- first = 0;
- }
-
- *sysflags_out = sysflags;
- return (0);
-}
-
-/*
- * Parses a STORE command's leading store-modifier list (RFC 4466's generic
- * syntax, extended by RFC 7162 SS3.1.3). modspec is the still-parenthesized
- * text cmd_store_cmd() split off before store-att-flags, modified in place
- * -- see that function's comment for why store-modifiers lead rather than
- * trail here, unlike FETCH's fetch-modifiers.
- *
- * UNCHANGEDSINCE <mod-sequence-valzer> is the only store-modifier this
- * server implements. Unlike parse_fetch_modifiers()'s CHANGEDSINCE, the
- * value here is explicitly allowed to be 0 (SS3.1.3 Example 8: "Use of
- * UNCHANGEDSINCE with a modification sequence of 0 always fails if the
- * metadata item exists" -- a real, distinct case from "not specified",
- * hence has_unchangedsince rather than testing unchangedsince != 0).
- */
-static int
-parse_store_modifiers(char *modspec, struct imsg_mbox_store *req,
- const char **errmsg)
-{
- char *p, *tok, *save;
- size_t len;
-
- *errmsg = NULL;
- len = strlen(modspec);
- if (len < 2 || modspec[0] != '(' || modspec[len - 1] != ')') {
- *errmsg = "malformed store-modifier list";
- return (-1);
- }
- modspec[len - 1] = '\0';
- p = modspec + 1;
-
- for (tok = strtok_r(p, " ", &save); tok != NULL;
- tok = strtok_r(NULL, " ", &save)) {
- if (strcasecmp(tok, "UNCHANGEDSINCE") == 0) {
- char *valtok = strtok_r(NULL, " ", &save);
- char *ep;
-
- if (valtok == NULL) {
- *errmsg = "UNCHANGEDSINCE requires a "
- "mod-sequence value";
- return (-1);
- }
- errno = 0;
- req->unchangedsince = strtoull(valtok, &ep, 10);
- if (*ep != '\0' || errno != 0) {
- *errmsg = "invalid UNCHANGEDSINCE mod-sequence";
- return (-1);
- }
- req->has_unchangedsince = 1;
- } else {
- *errmsg = "unrecognized store modifier";
- return (-1);
- }
- }
-
- return (0);
-}
-
-/* Named cmd_store_cmd(), not cmd_store(), to avoid reading as though it
- * belongs to -- or calls into -- the STORE *role* (store.c, s->store_iev,
- * session_store_dispatch() etc. elsewhere in this file): same command
- * name, unrelated concept.
- *
- * RFC 9051 SS6.4.6: `store = "STORE" SP sequence-set SP store-att-flags`,
- * extended by RFC 4466/RFC 7162 with an optional store-modifier list
- * between the sequence-set and store-att-flags: `"STORE" SP sequence-set
- * [SP "(" store-modifier *(SP store-modifier) ")"] SP store-att-flags` --
- * store-modifiers *lead*, unlike FETCH's fetch-modifiers, which trail (see
- * cmd_fetch()'s comment and RFC 7162 SS3.1.3's own examples, e.g. "STORE *
- * (UNCHANGEDSINCE 12121230045) +FLAGS.SILENT (...)"), so this function
- * peeks for a leading "(" right after the sequence-set rather than reusing
- * split_trailing_modifiers().
- *
- * v1 scope matches FETCH's: exactly one sequence-set range or number,
- * never a comma-separated list, for the same struct-session-only-tracks-
- * one-async-operation reason cmd_fetch()'s comment explains. Replies
- * reuse IMSG_MBOX_FETCH_META/IMSG_MBOX_RESULT (see imapd.h's imsg_
- * mbox_store comment) since RFC 9051 SS6.4.6 itself says STORE's only
- * response is "untagged responses: FETCH" -- the exact same wire shape
- * FETCH already produces, so store.c and this function are what decide
- * it's a STORE in flight (s->state == SESSION_STORING), not a different
- * imsg type.
- */
-static int
-cmd_store_cmd(struct session *s, const char *tag, char *args)
-{
- return store_do(s, tag, args, 0);
-}
-
-/*
- * Shared body for cmd_store_cmd() (by_uid=0) and cmd_uid()'s STORE branch
- * (by_uid=1) -- see cmd_store_cmd()'s own comment for the grammar/scope
- * this parses. RFC 9051 SS6.4.9 addition this pass: req.by_uid threads the
- * UID-vs-sequence-number resolution to store.c (see handle_mbox_store()'s
- * comment there); s->cmd_by_uid is set unconditionally before dispatch so
- * session_send_store_fetch_response() knows to include UID in the STORE
- * echo (SS6.4.9's "MUST implicitly include the UID message data item...
- * primarily applies to the UID FETCH and UID STORE commands").
- */
-static int
-store_do(struct session *s, const char *tag, char *args, int by_uid)
-{
- struct imsg_mbox_store req;
- char *seqtok, *modetok, *flagspec, *modspec = NULL;
- uint32_t lo, hi, sysflags;
- int lo_star, hi_star, mode, silent, rc;
- char keywords[MBOX_FLAGS_MAX];
- const char *errmsg;
- const char *cmdname = by_uid ? "UID STORE" : "STORE";
-
- if (args == NULL) {
- session_reply(s, tag, "BAD",
- "STORE requires a sequence set and store-att-flags");
- return (1);
- }
-
- seqtok = args;
- while (*args != '\0' && *args != ' ')
- args++;
- if (*args == '\0') {
- session_reply(s, tag, "BAD", "STORE requires store-att-flags");
- return (1);
- }
- *args++ = '\0';
- while (*args == ' ')
- args++;
-
- if (*args == '(') {
- char *p = args;
- int depth = 0;
-
- for (;;) {
- if (*p == '(')
- depth++;
- else if (*p == ')') {
- depth--;
- if (depth == 0)
- break;
- } else if (*p == '\0') {
- session_reply(s, tag, "BAD",
- "unterminated store-modifier list");
- return (1);
- }
- p++;
- }
- /* p points at the matching ')' for modspec (== args) */
- modspec = args;
- p++; /* just past ')' */
- if (*p == '\0') {
- session_reply(s, tag, "BAD",
- "STORE requires store-att-flags");
- return (1);
- }
- if (*p != ' ') {
- session_reply(s, tag, "BAD",
- "expected a space after store-modifier list");
- return (1);
- }
- *p = '\0'; /* terminate modspec right after ')' */
- p++;
- while (*p == ' ')
- p++;
- args = p;
- }
-
- modetok = args;
- while (*args != '\0' && *args != ' ')
- args++;
- if (*args == '\0') {
- session_reply(s, tag, "BAD", "STORE requires a flag list");
- return (1);
- }
- *args++ = '\0';
- while (*args == ' ')
- args++;
- flagspec = args;
-
- if (strchr(seqtok, ',') != NULL) {
- session_reply(s, tag, "BAD",
- "comma-separated sequence sets not supported in v1 -- "
- "issue separate STORE commands");
- return (1);
- }
- if (parse_seq_range(seqtok, &lo, &hi, &lo_star, &hi_star) == -1) {
- session_reply(s, tag, "BAD", "invalid sequence set");
- return (1);
- }
-
- memset(&req, 0, sizeof(req));
- if (modspec != NULL) {
- if (parse_store_modifiers(modspec, &req, &errmsg) == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
- }
-
- if (modetok[0] == '+') {
- mode = MBOX_STORE_ADD;
- modetok++;
- } else if (modetok[0] == '-') {
- mode = MBOX_STORE_REMOVE;
- modetok++;
- } else
- mode = MBOX_STORE_SET;
-
- if (strcasecmp(modetok, "FLAGS") == 0)
- silent = 0;
- else if (strcasecmp(modetok, "FLAGS.SILENT") == 0)
- silent = 1;
- else {
- session_reply(s, tag, "BAD",
- "expected FLAGS, FLAGS.SILENT, +FLAGS, +FLAGS.SILENT, "
- "-FLAGS, or -FLAGS.SILENT");
- return (1);
- }
-
- rc = parse_store_flags(flagspec, &sysflags, keywords, sizeof(keywords),
- &errmsg);
- if (rc == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
- if (rc == -2) {
- session_reply(s, tag, "NO", errmsg);
- return (1);
- }
-
- if (s->mbox_readonly) {
- /*
- * RFC 9051 SS6.3.3: "No changes to the permanent state of
- * the mailbox, including per-user state, are permitted" for
- * an EXAMINE'd mailbox -- STORE's entire purpose is exactly
- * that kind of change. RFC 5530 CANNOT, same reasoning as
- * session_request_expunge()'s identical check.
- */
- session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
- "(selected via EXAMINE)");
- return (1);
- }
-
- if (s->store_iev == NULL) {
- /* Same internal-invariant check as cmd_select()/cmd_fetch()
- * -- ST_SELECTED requires store_iev to already be wired. */
- log_warnx("session %u: %s with no store channel wired",
- s->id, cmdname);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
-
- /* req was already memset(3)'d and populated with has_unchangedsince/
- * unchangedsince (if modspec was present) earlier -- not re-zeroed
- * here, or that would silently wipe those two fields back out. */
- req.seq_lo = lo;
- req.seq_hi = hi;
- req.lo_is_star = lo_star;
- req.hi_is_star = hi_star;
- req.mode = mode;
- req.silent = silent;
- req.sysflags = sysflags;
- req.by_uid = by_uid;
- strlcpy(req.keywords, keywords, sizeof(req.keywords));
-
- /* RFC 7162 SS3.1: UNCHANGEDSINCE is a CONDSTORE-enabling command. */
- if (req.has_unchangedsince)
- session_condstore_enable(s);
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- s->cmd_by_uid = by_uid;
- s->state = SESSION_STORING;
-
- if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_STORE, 0, 0, -1,
- &req, sizeof(req)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_STORE", s->id);
- imsgev_add(s->store_iev);
-
- return (1);
-}
-
-/*
- * RFC 9051 SS6.4.7 COPY / SS6.4.8 MOVE: `copy = "COPY" SP sequence-set SP
- * mailbox`, `move = "MOVE" SP sequence-set SP mailbox` -- identical
- * argument grammar, differing only in which IMSG_MBOX_* this sends and
- * (per is_move) which store.c handler that becomes. cmd_copy()/cmd_move()
- * are thin by_uid=0 wrappers around this, and cmd_uid() calls it again
- * with by_uid=1 for UID COPY/UID MOVE -- the same "_dispatch() shared body,
- * thin cmd_*() wrappers" pattern fetch_dispatch()/store_do()/search_
- * dispatch() already established for RFC 9051 SS6.4.9's UID command.
- *
- * Mailbox-name argument reuses parse_list_token() (LIST/STATUS's own
- * token parser). Fast client-side validation reuses mailbox_name_is_
- * inbox()/mailbox_name_valid() -- CREATE/DELETE/RENAME's own precedent
- * (RFC 9051 SS6.3.4-SS6.3.6 flat multi-mailbox support, docs/openimap-
- * storage-backend.md item 10's own follow-up), not cmd_list()/cmd_
- * status()'s "only INBOX can ever match" precedent this function used
- * before that support existed: destname is now genuinely any real,
- * existing mailbox, not just INBOX, so a syntactically valid name gets a
- * real store round trip (IMSG_MBOX_COPY/IMSG_MBOX_MOVE now carry it, see
- * imapd.h's imsg_mbox_copy comment) rather than being answered locally.
- *
- * A syntactically invalid name (BAD, no round trip) is a different
- * failure from "syntactically fine but doesn't exist" -- the latter is
- * exactly SS6.4.7's TRYCREATE case: "If the destination mailbox does not
- * exist, a server MUST return an error... Unless it is certain that the
- * destination mailbox can not be created, the server MUST send the
- * response code '[TRYCREATE]'". store.c's handle_mbox_copy()/handle_mbox_
- * move() report that case back via struct imsg_mbox_result's no_such_
- * mailbox field (mirroring imsg_mbox_appended's own field for APPEND);
- * session_finish_copy_or_move() sends TRYCREATE only then, a plain NO
- * "failed" for any other failure.
- */
-static int
-copy_move_dispatch(struct session *s, const char *tag, char *args,
- int by_uid, int is_move)
-{
- struct imsg_mbox_copy req;
- char mailbox[MBOX_NAME_MAX];
- char *seqtok, *p;
- uint32_t lo, hi;
- int lo_star, hi_star;
- const char *errmsg = NULL;
- const char *cmdname = is_move ?
- (by_uid ? "UID MOVE" : "MOVE") : (by_uid ? "UID COPY" : "COPY");
-
- if (args == NULL) {
- char text[64];
-
- snprintf(text, sizeof(text),
- "%s requires a sequence set and mailbox name", cmdname);
- session_reply(s, tag, "BAD", text);
- return (1);
- }
-
- seqtok = args;
- p = args;
- while (*p != '\0' && *p != ' ')
- p++;
- if (*p == '\0') {
- char text[48];
-
- snprintf(text, sizeof(text), "%s requires a mailbox name",
- cmdname);
- session_reply(s, tag, "BAD", text);
- return (1);
- }
- *p++ = '\0';
- while (*p == ' ')
- p++;
-
- if (strchr(seqtok, ',') != NULL) {
- session_reply(s, tag, "BAD",
- "comma-separated sequence sets not supported in v1 -- "
- "issue separate COPY/MOVE commands");
- return (1);
- }
- if (parse_seq_range(seqtok, &lo, &hi, &lo_star, &hi_star) == -1) {
- session_reply(s, tag, "BAD", "invalid sequence set");
- return (1);
- }
-
- if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
- session_reply(s, tag, "BAD", errmsg);
- return (1);
- }
- if (*p != '\0') {
- session_reply(s, tag, "BAD", "trailing garbage after "
- "mailbox name");
- return (1);
- }
-
- if (!mailbox_name_is_inbox(mailbox) && !mailbox_name_valid(mailbox)) {
- session_reply(s, tag, "BAD", "invalid mailbox name");
- return (1);
- }
-
- if (is_move && s->mbox_readonly) {
- /*
- * MOVE permanently removes the copied messages from the
- * source (selected) mailbox -- RFC 9051 SS6.4.8 describes it
- * as "COPY, followed by removal of the copied messages",
- * exactly the kind of permanent-state change SS6.3.3
- * prohibits for an EXAMINE'd mailbox. COPY, by contrast,
- * never touches the source mailbox's own state, so it's
- * unaffected by mbox_readonly -- only checked for is_move.
- */
- session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
- "(selected via EXAMINE)");
- return (1);
- }
-
- if (s->store_iev == NULL) {
- /* Same internal-invariant check as cmd_select()/cmd_fetch()/
- * cmd_store_cmd() -- ST_SELECTED requires store_iev to
- * already be wired. */
- log_warnx("session %u: %s with no store channel wired",
- s->id, cmdname);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
-
- memset(&req, 0, sizeof(req));
- req.by_uid = by_uid;
- req.seq_lo = lo;
- req.seq_hi = hi;
- req.lo_is_star = lo_star;
- req.hi_is_star = hi_star;
- strlcpy(req.destname, mailbox, sizeof(req.destname));
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- s->cmd_by_uid = by_uid;
- s->cmd_is_move = is_move;
- s->state = SESSION_COPYING;
-
- if (imsg_compose(&s->store_iev->ibuf,
- is_move ? IMSG_MBOX_MOVE : IMSG_MBOX_COPY, 0, 0, -1, &req,
- sizeof(req)) == -1)
- log_warn("session %u: imsg_compose %s", s->id, cmdname);
- imsgev_add(s->store_iev);
-
- return (1);
-}
-
-static int
-cmd_copy(struct session *s, const char *tag, char *args)
-{
- return copy_move_dispatch(s, tag, args, 0, 0);
-}
-
-static int
-cmd_move(struct session *s, const char *tag, char *args)
-{
- return copy_move_dispatch(s, tag, args, 0, 1);
-}
-
-/*
- * RFC 9051 SS6.4.9: `uid = "UID" SP (copy / move / fetch / search /
- * store)`, extended by RFC 4315/UIDPLUS's UID EXPUNGE (folded into base
- * IMAP4rev2 -- confirmed by grepping this server's own copy of RFC 9051
- * SS6.4.9, which documents UID EXPUNGE directly, no separate extension
- * capability needed). All six sub-commands are real as of this pass --
- * FETCH/STORE/SEARCH/EXPUNGE (fetch_dispatch()/store_do()/search_
- * dispatch()/uid_expunge_dispatch()) from an earlier pass, and (this
- * pass) COPY/MOVE too, via the identical copy_move_dispatch() the base
- * (non-UID) COPY/MOVE commands themselves use, just called here with
- * by_uid=1 -- same "_dispatch() shared body, by_uid parameter" pattern
- * as the other four.
- *
- * Sub-command name and its own arguments are split the same way parse_
- * command_line() splits tag/command-name/args at the top level (first
- * space-delimited token, case-insensitive, rest is that sub-command's own
- * argument string, NULL if nothing follows) -- UID's sub-command isn't
- * itself a "tag", so it doesn't reuse that function, but the token-
- * splitting logic is the same shape.
- */
-static int
-cmd_uid(struct session *s, const char *tag, char *args)
-{
- char *sub, *subargs;
-
- if (args == NULL) {
- session_reply(s, tag, "BAD", "UID requires a sub-command");
- return (1);
- }
-
- sub = args;
- while (*args != '\0' && *args != ' ')
- args++;
- if (*args == ' ') {
- *args++ = '\0';
- while (*args == ' ')
- args++;
- subargs = (*args != '\0') ? args : NULL;
- } else
- subargs = NULL;
-
- if (strcasecmp(sub, "FETCH") == 0)
- return fetch_dispatch(s, tag, subargs, 1);
- if (strcasecmp(sub, "STORE") == 0)
- return store_do(s, tag, subargs, 1);
- if (strcasecmp(sub, "SEARCH") == 0)
- return search_dispatch(s, tag, subargs, 1);
- if (strcasecmp(sub, "EXPUNGE") == 0)
- return uid_expunge_dispatch(s, tag, subargs);
- if (strcasecmp(sub, "COPY") == 0)
- return copy_move_dispatch(s, tag, subargs, 1, 0);
- if (strcasecmp(sub, "MOVE") == 0)
- return copy_move_dispatch(s, tag, subargs, 1, 1);
-
- session_reply(s, tag, "BAD",
- "UID sub-command must be COPY, FETCH, MOVE, SEARCH, or STORE");
- return (1);
-}
-
-/*
- * The AUTH channel: only IMSG_AUTH_RESULT arrives here (wired once at
- * boot via peer_fd). On success, kicks off the per-session store spawn.
- *
- * Real bug caught writing SELECT's async round trip: imsgev_init() (see
- * imsgev.c) registers every channel it wraps as plain EV_READ, NOT
- * EV_READ|EV_PERSIST -- confirmed against the real event.h this pass
- * ("The additional flag EV_PERSIST makes an event_add() persistent until
- * event_del() has been called", openbsd_source/src/lib/libevent/event.h),
- * meaning a one-shot registration fires exactly once and then goes
- * inactive until something calls event_add() (imsgev_add(), here) again.
- * This function never did that for iev_auth itself -- session_request_
- * store()'s imsgev_add() call re-arms iev_parent, a different channel,
- * on the one path that reaches it. The result: the SECOND
- * IMSG_AUTH_RESULT ever received on this channel, across the whole
- * daemon's lifetime (not just per-session -- iev_auth is one shared
- * channel), would never be seen -- every AUTHENTICATE after the very
- * first one handled process-wide would silently hang forever. auth.c's
- * own auth_dispatch() happens to avoid this by always calling
- * imsgev_add(iev) after composing its reply, which incidentally re-arms
- * its own read side every time; this function had no equivalent. Fixed
- * by unconditionally re-arming at the end below, the same fix applied to
- * listener_dispatch_parent() and parent.c's store_child_dispatch(), the
- * other two channels with the identical gap.
- */
-static void
+
+void
listener_dispatch_auth(int fd, short event, void *arg)
{
struct imsgev *iev = arg;
struct imsg imsg;
ssize_t n;
- /*
- * EV_WRITE: real bug caught on first real-hardware run -- see
- * auth.c's auth_dispatch() header comment for the full citation
- * (imsg_init(3)'s own EXAMPLES section) and the live symptoms this
- * exact gap produced here specifically: IMSG_AUTH_REQUEST queued by
- * cmd_authenticate() via imsg_compose() never actually left this
- * process, and this function's own unconditional imsgev_add() at
- * the bottom kept re-arming EV_WRITE for a write that never
- * happened -- a busy loop, confirmed via `ps` showing 25+ minutes
- * of CPU time on an otherwise-idle listener process.
- */
+ /* Without this, a queued imsg_compose() never flushes and imsgev_add() busy-loops on EV_WRITE. */
if (event & EV_WRITE) {
if (imsgbuf_write(&iev->ibuf) == -1)
fatal("imsgbuf_write");
"[AUTHENTICATIONFAILED] authentication failed");
break;
}
- session_request_store(s, &res);
+ /* task #321: parent spawns the store child off auth's
+ * own direct IMSG_AUTH_CRED now, not a request relayed
+ * from here -- this just tracks state while we wait
+ * for parent's IMSG_SETUP_PEER (success) or
+ * IMSG_STORE_FORK (failure). */
+ s->uid = res.uid; /* session_notify_idle_peers() groups by this */
+ s->state = SESSION_STORE_PENDING;
break;
}
default:
}
imsg_free(&imsg);
}
- imsgev_add(iev); /* re-arm -- see this function's header comment */
+ imsgev_add(iev); /* re-arm for the next event */
(void)fd;
}
-/*
- * Rebuilds listener_tls_ctx/listener_tls_config from freshly-received
- * cert/key bytes, for a live SIGHUP reload -- called from listener_
- * dispatch_parent()'s IMSG_TLS_CERT/IMSG_TLS_KEY cases below once both
- * halves of one reload have arrived. Deliberately a separate function
- * from listener_main()'s boot-time TLS build rather than a shared helper,
- * even though the tls_config_new()+tls_server()+tls_config_set_ciphers()+
- * tls_config_set_keypair_mem()+tls_configure() chain is identical (same
- * sourcing as listener_main()'s own copy, against httpd's server_tls_
- * init()): boot failure degrades to "no TLS, cleartext still works" (see
- * listener_main()), but a reload failure must NEVER tear down an already-
- * working TLS context just because, say, the operator's cron-driven cert
- * renewal wrote a momentarily-truncated file. Every failure path here
- * frees only the half-built new pair and returns with listener_tls_ctx/
- * listener_tls_config completely untouched; a fully-built replacement
- * pair is only ever swapped in after every step succeeds, and the OLD
- * pair is freed only once the swap itself is done -- see listener_tls_
- * ctx's file-scope comment for why the swap itself is safe with respect
- * to sessions already connected.
- */
-static void
+/* Rebuilds the TLS ctx/config for SIGHUP reload; a failure leaves the old, working pair untouched. */
+void
listener_reload_tls(const char *cert_buf, size_t cert_len,
const char *key_buf, size_t key_len)
{
log_info("listener: SIGHUP reload: TLS configuration reloaded");
}
-/*
- * The PARENT channel (this process's fd 3, kept alive for its whole
- * lifetime -- see this file's header comment): IMSG_STORE_FORK arrives
- * here as parent's *failure* reply to a store-spawn request (see
- * parent.c's parent_handle_store_fork()/store_child_teardown() "fail:"
- * paths, which reuse this type for that -- a *successful* peer wire-up
- * arrives as IMSG_SETUP_PEER instead). IMSG_SETUP_PEER now carries the
- * requesting session's id in the imsg header's "id" field (see parent.c's
- * setup_peer_send()), read back here via imsg_get_id() to find the right
- * session -- this is the fix for the session-demux gap flagged in an
- * earlier pass.
- *
- * IMSG_TLS_CERT/IMSG_TLS_KEY arrive here too now, post-boot, whenever
- * parent's sighup_handler() re-pushes freshly-read cert/key bytes (see
- * that function's header comment in parent.c) -- reusing the exact same
- * two imsg types the boot-time drain loop in listener_main() consumes
- * once, synchronously, before the event loop even starts. Each is staged
- * into reload_cert_buf/reload_key_buf independently (they can arrive in
- * either order, and not necessarily in the same dispatch call -- see
- * those statics' own comment), and listener_reload_tls() only runs once
- * both reload_got_cert and reload_got_key are set, after which both flags
- * reset for the next reload.
- *
- * Re-arms iev (imsgev_add()) at the end, same fix and same reason as
- * listener_dispatch_auth()'s header comment: imsgev_init() registers
- * plain EV_READ, not EV_PERSIST, and nothing here previously re-armed
- * this channel's OWN read side after servicing it -- the second
- * IMSG_STORE_FORK-failure or IMSG_SETUP_PEER, across the daemon's whole
- * lifetime, would otherwise never have been seen.
- */
-static void
+/* PARENT channel (fd 3): IMSG_STORE_FORK (spawn failure), IMSG_SETUP_PEER (spawn success, imsg_get_id() has session id), and SIGHUP's IMSG_TLS_CERT/KEY. */
+void
listener_dispatch_parent(int fd, short event, void *arg)
{
struct imsgev *iev = arg;
struct imsg imsg;
ssize_t n;
- /* EV_WRITE: same real bug, same fix -- see listener_dispatch_auth()'s
- * header comment and auth.c's auth_dispatch() for the full citation. */
+ /* See listener_dispatch_auth()'s comment on this EV_WRITE check. */
if (event & EV_WRITE) {
if (imsgbuf_write(&iev->ibuf) == -1)
fatal("imsgbuf_write");
session_store_dispatch, s);
s->state = SESSION_AUTHENTICATED;
log_debug("session %u: store peer wired", sess_id);
- /* Exact example text from RFC 9051 SS6.2.2's PLAIN
- * example ("S: A03 OK Success (tls protection)") --
- * accurate here too, since AUTHENTICATE PLAIN is only
- * ever reachable once s->tls_active is set (see
- * cmd_authenticate()'s TLS gate). */
+ /* RFC 9051 SS6.2.2's PLAIN example text, verbatim. */
session_reply(s, s->pending_tag, "OK",
"Success (tls protection)");
break;
}
imsg_free(&imsg);
}
- imsgev_add(iev); /* re-arm -- see this function's header comment */
- (void)fd;
-}
-
-/*
- * The per-session STORE channel, wired once listener_dispatch_parent()'s
- * IMSG_SETUP_PEER case sets s->store_iev. IMSG_MBOX_SELECTED (see
- * cmd_select() for the request side, store.c's handle_mbox_select() for
- * what actually produces this reply) was the first IMSG_MBOX_* reply this
- * handled for real; IMSG_MBOX_FETCH_META (zero or more, streamed) and the
- * terminal IMSG_MBOX_RESULT (see cmd_fetch()/store.c's handle_mbox_fetch())
- * are the second pair. Everything else in the family still has no request
- * path at all (store.c's own TODO), so nothing else can arrive here yet.
- *
- * A lost store channel (read failure, or a clean close -- the child
- * process exiting or crashing) tears down the whole client session rather
- * than trying to degrade back to an unselected/unauthenticated state:
- * v1's simplification, not a claim that a client-visible reconnect-
- * without-losing-the-TCP-connection story wouldn't be better eventually.
- *
- * Re-arms s->store_iev (imsgev_add()) at the end -- see listener_dispatch_
- * auth()'s header comment for the underlying EV_PERSIST gap this fixes;
- * this channel has the exact same shape (imsgev_init(), no re-arm on the
- * read side), and without this a second SELECT or FETCH on the same
- * session would send its IMSG_MBOX_* request to store just fine but never
- * see the reply.
- */
-static void
-session_store_dispatch(int fd, short event, void *arg)
-{
- struct session *s = arg;
- struct imsg imsg;
- ssize_t n;
-
- /*
- * EV_WRITE: same real bug as listener_dispatch_auth()/auth.c's
- * auth_dispatch() (see those header comments for the full
- * citation) -- every IMSG_MBOX_* request this session ever sends
- * (SELECT, FETCH, STORE, ...) is queued via imsg_compose() and
- * needs an actual imsgbuf_write() once the fd is writable, which
- * nothing here ever did before this fix.
- */
- if (event & EV_WRITE) {
- if (imsgbuf_write(&s->store_iev->ibuf) == -1) {
- log_warnx("session %u: imsgbuf_write (store)", s->id);
- session_teardown(s);
- return;
- }
- }
-
- if (event & EV_READ) {
- if ((n = imsgbuf_read(&s->store_iev->ibuf)) == -1) {
- log_warnx("session %u: imsgbuf_read (store)", s->id);
- session_teardown(s);
- return;
- }
- if (n == 0) {
- log_warnx("session %u: store closed channel", s->id);
- session_teardown(s);
- return;
- }
- }
-
- for (;;) {
- if ((n = imsg_get(&s->store_iev->ibuf, &imsg)) == -1) {
- log_warnx("session %u: imsg_get (store)", s->id);
- session_teardown(s);
- return;
- }
- if (n == 0)
- break;
-
- switch (imsg_get_type(&imsg)) {
- case IMSG_MBOX_SELECTED: {
- struct imsg_mbox_selected res;
-
- if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
- log_warnx("bad IMSG_MBOX_SELECTED");
- break;
- }
- session_handle_mbox_selected(s, &res);
- break;
- }
- case IMSG_MBOX_STATUS_RESULT: {
- struct imsg_mbox_status_result res;
-
- if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
- log_warnx("bad IMSG_MBOX_STATUS_RESULT");
- break;
- }
- session_handle_mbox_status_result(s, &res);
- break;
- }
- case IMSG_MBOX_FETCH_HEADER: {
- struct imsg_mbox_fetch_header hdr;
- size_t hdrlen;
-
- /*
- * Same fixed-header-plus-variable-trailing-bytes
- * technique as IMSG_MBOX_APPEND's read side in
- * store.c's handle_mbox_append() case (imsg_get_buf()
- * for the fixed part, then imsg_get_len()/
- * imsg_get_buf() for whatever remains) -- just read
- * here instead of there, since this message travels
- * store -> listener rather than the other way.
- */
- if (imsg_get_buf(&imsg, &hdr, sizeof(hdr)) == -1) {
- log_warnx("bad IMSG_MBOX_FETCH_HEADER (header)");
- break;
- }
- free(s->pending_header_buf);
- s->pending_header_buf = NULL;
- s->pending_header_len = 0;
- s->pending_header_found = 0;
-
- hdrlen = imsg_get_len(&imsg);
- if (!hdr.found)
- break; /* store.c found nothing for this
- * message -- nothing more to read. */
- if (hdrlen != hdr.hdrlen) {
- log_warnx("session %u: IMSG_MBOX_FETCH_HEADER "
- "length mismatch (header says %u, imsg "
- "has %zu)", s->id, hdr.hdrlen, hdrlen);
- break;
- }
- if (hdrlen == 0) {
- /* found but empty is a legitimate, if odd,
- * message (a header-less file) --
- * pending_header_found alone is enough for
- * session_send_fetch_response() to emit a
- * zero-length BODY[HEADER] {0} literal. */
- s->pending_header_found = 1;
- break;
- }
- if ((s->pending_header_buf = malloc(hdrlen)) == NULL) {
- log_warn("session %u: malloc pending header "
- "buffer", s->id);
- break;
- }
- if (imsg_get_buf(&imsg, s->pending_header_buf, hdrlen)
- == -1) {
- log_warnx("bad IMSG_MBOX_FETCH_HEADER (body)");
- free(s->pending_header_buf);
- s->pending_header_buf = NULL;
- break;
- }
- s->pending_header_len = (uint32_t)hdrlen;
- s->pending_header_found = 1;
- break;
- }
- case IMSG_MBOX_FETCH_BODY: {
- struct imsg_mbox_fetch_body bodyhdr;
- size_t bodylen;
-
- /* Same technique as IMSG_MBOX_FETCH_HEADER just
- * above -- see that case's comment. */
- if (imsg_get_buf(&imsg, &bodyhdr, sizeof(bodyhdr)) ==
- -1) {
- log_warnx("bad IMSG_MBOX_FETCH_BODY (header)");
- break;
- }
- free(s->pending_body_buf);
- s->pending_body_buf = NULL;
- s->pending_body_len = 0;
- s->pending_body_found = 0;
- /* pending_body_label/has_partial/partial_origin are
- * NOT reset here -- like pending_header_label, they're
- * set once in fetch_dispatch() for the whole FETCH,
- * not per message; bodyhdr.is_text (struct imsg_mbox_
- * fetch_body) is redundant with what fetch_dispatch()
- * already knows it asked for and is intentionally
- * unused here. */
-
- bodylen = imsg_get_len(&imsg);
- if (!bodyhdr.found)
- break; /* store.c found nothing for this
- * message -- nothing more to read. */
- if (bodylen != bodyhdr.bodylen) {
- log_warnx("session %u: IMSG_MBOX_FETCH_BODY "
- "length mismatch (header says %u, imsg "
- "has %zu)", s->id, bodyhdr.bodylen,
- bodylen);
- break;
- }
- if (bodylen == 0) {
- /* found but empty is legitimate (a
- * zero-length message, or BODY.PEEK[TEXT] on
- * a message that's all header) --
- * pending_body_found alone is enough for
- * session_send_fetch_response() to emit a
- * zero-length literal. */
- s->pending_body_found = 1;
- break;
- }
- if ((s->pending_body_buf = malloc(bodylen)) == NULL) {
- log_warn("session %u: malloc pending body "
- "buffer", s->id);
- break;
- }
- if (imsg_get_buf(&imsg, s->pending_body_buf, bodylen)
- == -1) {
- log_warnx("bad IMSG_MBOX_FETCH_BODY (body)");
- free(s->pending_body_buf);
- s->pending_body_buf = NULL;
- break;
- }
- s->pending_body_len = (uint32_t)bodylen;
- s->pending_body_found = 1;
- break;
- }
- case IMSG_MBOX_FETCH_ENVELOPE: {
- struct imsg_mbox_fetch_envelope envhdr;
- size_t envlen;
-
- /* Same technique as IMSG_MBOX_FETCH_HEADER/IMSG_MBOX_
- * FETCH_BODY above -- see IMSG_MBOX_FETCH_HEADER's
- * comment. */
- if (imsg_get_buf(&imsg, &envhdr, sizeof(envhdr)) ==
- -1) {
- log_warnx("bad IMSG_MBOX_FETCH_ENVELOPE (header)");
- break;
- }
- free(s->pending_envelope_buf);
- s->pending_envelope_buf = NULL;
- s->pending_envelope_len = 0;
- s->pending_envelope_found = 0;
-
- envlen = imsg_get_len(&imsg);
- if (!envhdr.found)
- break; /* store.c couldn't build an envelope
- * for this message -- nothing more to
- * read. */
- if (envlen != envhdr.envlen) {
- log_warnx("session %u: IMSG_MBOX_FETCH_ENVELOPE "
- "length mismatch (header says %u, imsg "
- "has %zu)", s->id, envhdr.envlen, envlen);
- break;
- }
- if (envlen == 0) {
- /* build_envelope() always emits at least
- * "(NIL NIL NIL NIL NIL NIL NIL NIL NIL NIL)"
- * -- a zero-length result should never
- * actually happen, but handled the same
- * defensive way as the header/body cases
- * above rather than assumed impossible. */
- s->pending_envelope_found = 1;
- break;
- }
- if ((s->pending_envelope_buf = malloc(envlen)) == NULL) {
- log_warn("session %u: malloc pending envelope "
- "buffer", s->id);
- break;
- }
- if (imsg_get_buf(&imsg, s->pending_envelope_buf, envlen)
- == -1) {
- log_warnx("bad IMSG_MBOX_FETCH_ENVELOPE (body)");
- free(s->pending_envelope_buf);
- s->pending_envelope_buf = NULL;
- break;
- }
- s->pending_envelope_len = (uint32_t)envlen;
- s->pending_envelope_found = 1;
- break;
- }
- case IMSG_MBOX_FETCH_BODYSTRUCTURE: {
- struct imsg_mbox_fetch_bodystructure bshdr;
- size_t bslen;
-
- /* Same technique as IMSG_MBOX_FETCH_HEADER/IMSG_MBOX_
- * FETCH_ENVELOPE above -- see IMSG_MBOX_FETCH_HEADER's
- * comment. */
- if (imsg_get_buf(&imsg, &bshdr, sizeof(bshdr)) ==
- -1) {
- log_warnx("bad IMSG_MBOX_FETCH_BODYSTRUCTURE "
- "(header)");
- break;
- }
- free(s->pending_bodystructure_buf);
- s->pending_bodystructure_buf = NULL;
- s->pending_bodystructure_len = 0;
- s->pending_bodystructure_found = 0;
-
- bslen = imsg_get_len(&imsg);
- if (!bshdr.found)
- break; /* store.c couldn't build a
- * BODYSTRUCTURE for this message --
- * nothing more to read. */
- if (bslen != bshdr.bslen) {
- log_warnx("session %u: "
- "IMSG_MBOX_FETCH_BODYSTRUCTURE length "
- "mismatch (header says %u, imsg has %zu)",
- s->id, bshdr.bslen, bslen);
- break;
- }
- if (bslen == 0) {
- /* build_bodystructure() always emits at
- * least a minimal single-part structure --
- * zero-length should never actually happen,
- * handled defensively anyway, same as the
- * header/body/envelope cases above. */
- s->pending_bodystructure_found = 1;
- break;
- }
- if ((s->pending_bodystructure_buf = malloc(bslen)) ==
- NULL) {
- log_warn("session %u: malloc pending "
- "bodystructure buffer", s->id);
- break;
- }
- if (imsg_get_buf(&imsg, s->pending_bodystructure_buf,
- bslen) == -1) {
- log_warnx("bad IMSG_MBOX_FETCH_BODYSTRUCTURE "
- "(body)");
- free(s->pending_bodystructure_buf);
- s->pending_bodystructure_buf = NULL;
- break;
- }
- s->pending_bodystructure_len = (uint32_t)bslen;
- s->pending_bodystructure_found = 1;
- break;
- }
- case IMSG_MBOX_FETCH_META: {
- struct imsg_mbox_fetch_meta meta;
-
- if (imsg_get_data(&imsg, &meta, sizeof(meta)) == -1) {
- log_warnx("bad IMSG_MBOX_FETCH_META");
- break;
- }
- /* Shared reply type for FETCH, STORE, and (RFC 7162
- * addition this pass) a QRESYNC SELECT resync (see
- * imapd.h's imsg_mbox_store/imsg_mbox_selected
- * comments) -- s->state says which request is
- * actually in flight right now, and therefore which
- * formatter applies. The SELECTING case is buffered
- * (session_handle_select_fetch()), not formatted
- * immediately, per RFC 7162 SS3.2.6's VANISHED-before-
- * FETCH ordering requirement -- see that function's
- * comment. */
- if (s->state == SESSION_SELECTING)
- session_handle_select_fetch(s, &meta);
- else if (s->state == SESSION_STORING)
- session_send_store_fetch_response(s, &meta);
- else
- session_send_fetch_response(s, &meta);
- break;
- }
- case IMSG_MBOX_EXPUNGED: {
- struct imsg_mbox_expunged exp;
-
- if (imsg_get_data(&imsg, &exp, sizeof(exp)) == -1) {
- log_warnx("bad IMSG_MBOX_EXPUNGED");
- break;
- }
- /*
- * A real EXPUNGE/CLOSE writes each one to the client
- * immediately, as it always has. During a MOVE
- * (s->state == SESSION_COPYING), handle_mbox_move()
- * sends this same message type for each moved
- * message's old UID/seqno -- but SS6.4.8 requires
- * COPYUID to precede any EXPUNGE/VANISHED for the
- * same operation, so these get buffered instead and
- * flushed (after COPYUID) only once IMSG_MBOX_RESULT
- * arrives -- see s->move_expunged's comment and
- * session_finish_copy_or_move().
- */
- if (s->state == SESSION_COPYING) {
- if (s->move_expunged_n == s->move_expunged_cap) {
- uint32_t newcap =
- s->move_expunged_cap ?
- s->move_expunged_cap * 2 : 16;
- struct imsg_mbox_expunged *enew =
- reallocarray(s->move_expunged,
- newcap, sizeof(*enew));
-
- if (enew == NULL) {
- log_warn("session %u: "
- "realloc move_expunged",
- s->id);
- break;
- }
- s->move_expunged = enew;
- s->move_expunged_cap = newcap;
- }
- s->move_expunged[s->move_expunged_n++] = exp;
- } else
- session_send_expunge_response(s, &exp);
- break;
- }
- case IMSG_MBOX_COPY_MAPPING: {
- struct imsg_mbox_copy_mapping m;
-
- if (imsg_get_data(&imsg, &m, sizeof(m)) == -1) {
- log_warnx("bad IMSG_MBOX_COPY_MAPPING");
- break;
- }
- session_handle_mbox_copy_mapping(s, &m);
- break;
- }
- case IMSG_MBOX_SEARCH_MATCH: {
- struct imsg_mbox_search_match m;
-
- if (imsg_get_data(&imsg, &m, sizeof(m)) == -1) {
- log_warnx("bad IMSG_MBOX_SEARCH_MATCH");
- break;
- }
- session_handle_mbox_search_match(s, &m);
- break;
- }
- case IMSG_MBOX_SELECT_VANISHED: {
- struct imsg_mbox_select_vanished v;
-
- if (imsg_get_data(&imsg, &v, sizeof(v)) == -1) {
- log_warnx("bad IMSG_MBOX_SELECT_VANISHED");
- break;
- }
- /* Despite the name, also reused for RFC 7162 SS3.2.6's
- * VANISHED UID FETCH modifier this pass -- see
- * imapd.h's imsg_mbox_select_vanished comment.
- * SESSION_SELECTING buffers (ordering against EXISTS/
- * the resync FETCH data); SESSION_FETCHING writes
- * immediately, since a plain UID FETCH has only the
- * "VANISHED before any FETCH response" ordering
- * requirement, already satisfied by store.c's own
- * send order (see handle_mbox_fetch()'s comment). */
- if (s->state == SESSION_SELECTING)
- session_handle_select_vanished(s, &v);
- else
- session_handle_fetch_vanished(s, &v);
- break;
- }
- case IMSG_MBOX_STORE_MODIFIED: {
- struct imsg_mbox_store_modified m;
-
- if (imsg_get_data(&imsg, &m, sizeof(m)) == -1) {
- log_warnx("bad IMSG_MBOX_STORE_MODIFIED");
- break;
- }
- session_handle_store_modified(s, &m);
- break;
- }
- case IMSG_MBOX_IDLE_UID: {
- struct imsg_mbox_idle_uid item;
-
- if (imsg_get_data(&imsg, &item, sizeof(item)) == -1) {
- log_warnx("bad IMSG_MBOX_IDLE_UID");
- break;
- }
- session_handle_idle_uid(s, &item);
- break;
- }
- case IMSG_MBOX_IDLE_REFRESHED: {
- struct imsg_mbox_idle_refreshed res;
-
- if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
- log_warnx("bad IMSG_MBOX_IDLE_REFRESHED");
- break;
- }
- session_handle_idle_refreshed(s, &res);
- break;
- }
- case IMSG_MBOX_RESULT: {
- struct imsg_mbox_result res;
-
- if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
- log_warnx("bad IMSG_MBOX_RESULT");
- break;
- }
- session_handle_mbox_result(s, &res);
- break;
- }
- case IMSG_MBOX_LIST_ITEM: {
- struct imsg_mbox_list_item item;
-
- if (imsg_get_data(&imsg, &item, sizeof(item)) == -1) {
- log_warnx("bad IMSG_MBOX_LIST_ITEM");
- break;
- }
- session_handle_mbox_list_item(s, &item);
- break;
- }
- case IMSG_MBOX_APPENDED: {
- struct imsg_mbox_appended res;
-
- if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
- log_warnx("bad IMSG_MBOX_APPENDED");
- break;
- }
- session_handle_mbox_appended(s, &res);
- break;
- }
- default:
- log_debug("session %u: store channel: unhandled %d",
- s->id, imsg_get_type(&imsg));
- break;
- }
- imsg_free(&imsg);
- }
- imsgev_add(s->store_iev); /* re-arm -- see this function's
- * header comment */
- (void)fd;
-}
-
-/*
- * Finishes the SELECT round trip cmd_select() started: builds and sends
- * the RFC 9051 SS6.3.2-required response sequence (or the NO failure
- * case) using s->pending_tag, and transitions session state.
- *
- * RFC 7162 additions this pass: s->mbox_highestmodseq is cached from
- * res->highestmodseq unconditionally (store.c always populates it -- see
- * imapd.h's imsg_mbox_selected comment), for session_condstore_
- * enable()'s later use regardless of whether *this* session is CONDSTORE-
- * aware yet. If it is (s->condstore_enabled, set synchronously by
- * cmd_select() when it saw a CONDSTORE or QRESYNC select-param -- see that
- * function's comment for why it doesn't go through session_condstore_
- * enable() itself), an OK [HIGHESTMODSEQ] response is included -- NOMODSEQ
- * is never emitted, since v1's only mailbox always supports persistent
- * mod-sequence storage (see struct mbox_index's comment in store.c).
- * Finally, any accumulated QRESYNC resync data (s->vanished_ranges/s->
- * qresync_fetches, populated by session_handle_select_vanished()/session_
- * handle_select_fetch() while this SELECT was in flight) is flushed in the
- * RFC-required order: VANISHED (EARLIER) first, then the resync FETCH
- * responses, then the tagged OK -- see those two fields' comments in
- * struct session for why this can't be streamed live instead.
- */
-static void
-session_handle_mbox_selected(struct session *s, struct imsg_mbox_selected *res)
-{
- char buf[128];
-
- if (!res->ok) {
- /* RFC 9051 SS6.3.2 Result: "NO - select failure, now in
- * authenticated state: no such mailbox, can't access
- * mailbox". v1's only failure mode is "not INBOX" (see
- * handle_mbox_select() in store.c), which maps directly to
- * "no such mailbox" -- RFC 5530 NONEXISTENT's own worked
- * example is literally that same text. */
- s->state = SESSION_AUTHENTICATED;
- session_reply(s, s->pending_tag, "NO",
- "[NONEXISTENT] no such mailbox");
- return;
- }
-
- s->state = SESSION_SELECTED;
- s->mbox_highestmodseq = res->highestmodseq;
-
- /*
- * Order matches RFC 9051 SS6.3.2's own worked example (EXISTS,
- * OK[UIDVALIDITY], OK[UIDNEXT], FLAGS, OK[PERMANENTFLAGS], LIST),
- * though that section explicitly says response order isn't
- * significant.
- */
- snprintf(buf, sizeof(buf), "%u EXISTS", res->exists);
- session_untagged(s, buf);
-
- snprintf(buf, sizeof(buf), "OK [UIDVALIDITY %u] UIDs valid",
- res->uidvalidity);
- session_untagged(s, buf);
-
- snprintf(buf, sizeof(buf), "OK [UIDNEXT %u] Predicted next UID",
- res->uidnext);
- session_untagged(s, buf);
-
- if (s->condstore_enabled) {
- snprintf(buf, sizeof(buf), "OK [HIGHESTMODSEQ %llu]",
- (unsigned long long)res->highestmodseq);
- session_untagged(s, buf);
- }
-
- /*
- * Standard flags, RFC 9051 SS6.3.2's own example list verbatim --
- * matches the five spec-defined maildir flag-suffix letters
- * (D/R/S/T/F) openimap-storage-backend.md sourced against
- * Courier's maildir(5). FLAGS itself is unaffected by read-only:
- * it just reports what flags exist in the mailbox, not what this
- * session may change.
- *
- * PERMANENTFLAGS differs for EXAMINE: SS6.3.3's own worked example
- * shows "OK [PERMANENTFLAGS ()] No permanent flags permitted" for
- * an EXAMINE'd mailbox, matching that section's "No changes to the
- * permanent state of the mailbox... are permitted" text. For a
- * plain SELECT, PERMANENTFLAGS adds "\*" per SS6.3.2's own example:
- * the index format supports arbitrary per-message keywords, so the
- * client is allowed to create new ones.
- */
- session_untagged(s,
- "FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)");
- if (s->mbox_readonly)
- session_untagged(s,
- "OK [PERMANENTFLAGS ()] No permanent flags permitted");
- else
- session_untagged(s,
- "OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen "
- "\\Draft \\*)] System flags and keywords allowed");
-
- /*
- * RFC 9051 SS6.3.2: "The server MUST return a LIST response with
- * the mailbox name." "/" as the hierarchy delimiter -- a resolved
- * project decision as of a later pass (openimap-storage-backend.md,
- * "Open items carried from this session" #9; see also cmd_
- * namespace()'s NAMESPACE response, which reports the identical
- * delimiter). "()" -- no attributes -- since no mailbox here can
- * ever have children (v1 is flat) and this LIST response only fires
- * once SELECT/EXAMINE has already succeeded, i.e. the mailbox is
- * selectable by construction.
- *
- * Real-hardware bug found testing RFC 9051 SS6.3.4-SS6.3.6 multi-
- * mailbox support (docs/openimap-storage-backend.md item 10) on
- * premio: this used to be the fixed literal "INBOX" unconditionally,
- * correct back when INBOX was the only mailbox that could ever be
- * selected. s->selected_mailbox (set by select_or_examine() at
- * dispatch time -- see that field's own comment) now names whatever
- * was actually just selected; quoted, not bare, since an arbitrary
- * flat mailbox name can contain a space (same reasoning as session_
- * handle_mbox_status_result()'s identical choice) -- INBOX itself is
- * still safe unquoted, but there's no need for a special case since
- * a quoted "INBOX" is exactly as valid IMAP as a bare one.
- */
- {
- char listbuf[MBOX_NAME_MAX + 32];
-
- snprintf(listbuf, sizeof(listbuf), "LIST () \"/\" \"%s\"",
- s->selected_mailbox);
- session_untagged(s, listbuf);
- }
-
- if (s->vanished_nranges > 0) {
- char vbuf[8192];
- char full[8192 + 32];
- int truncated;
- size_t vlen;
- int flen;
-
- /*
- * session_untagged()'s own 512-byte buffer is too small for
- * a potentially large VANISHED list -- same bypass session_
- * finish_search() already uses for ESEARCH, and for the same
- * reason (truncating range data would be materially wrong,
- * not cosmetic). Formats "* VANISHED (EARLIER) <ranges>\r\n"
- * directly, not through the small buf[] above.
- */
- vlen = format_range_list(vbuf, sizeof(vbuf),
- s->vanished_ranges, s->vanished_nranges, &truncated);
- if (truncated)
- log_warnx("session %u: VANISHED (EARLIER) list "
- "truncated at %zu bytes", s->id, vlen);
-
- flen = snprintf(full, sizeof(full), "* VANISHED (EARLIER) %s\r\n",
- vbuf);
- if (flen > 0)
- session_write(s, full, (size_t)flen >= sizeof(full) ?
- sizeof(full) - 1 : (size_t)flen);
- }
- free(s->vanished_ranges);
- s->vanished_ranges = NULL;
- s->vanished_nranges = 0;
- s->vanished_cap = 0;
-
- {
- uint32_t i;
-
- for (i = 0; i < s->qresync_nfetches; i++)
- session_send_qresync_fetch_response(s,
- &s->qresync_fetches[i]);
- }
- free(s->qresync_fetches);
- s->qresync_fetches = NULL;
- s->qresync_nfetches = 0;
- s->qresync_fetches_cap = 0;
-
- /*
- * RFC 9051 SS6.3.2: "the server SHOULD prefix the text of the
- * tagged OK response with the '[READ-WRITE]' response code" for
- * SELECT. SS6.3.3: the tagged OK for EXAMINE "MUST begin with the
- * '[READ-ONLY]' response code" -- s->mbox_readonly is a reliable
- * proxy for "this was EXAMINE, not SELECT" since v1 has no other
- * way for a mailbox to end up read-only (see struct session's
- * mbox_readonly comment).
- */
- if (s->mbox_readonly)
- session_reply(s, s->pending_tag, "OK",
- "[READ-ONLY] EXAMINE completed");
- else
- session_reply(s, s->pending_tag, "OK",
- "[READ-WRITE] SELECT completed");
-}
-
-/*
- * Finishes the STATUS round trip cmd_status() started: formats the
- * untagged "* STATUS mailbox (...)" response using s->status_attrs to
- * decide which of store.c's always-populated fields to include, restores
- * s->state to s->status_prev_state (STATUS never changes SELECTED-ness --
- * RFC 9051 SS6.3.11), and sends the tagged completion.
- *
- * Attribute order in the response is a fixed canonical order (MESSAGES,
- * UIDNEXT, UIDVALIDITY, UNSEEN, DELETED, SIZE, HIGHESTMODSEQ), not the
- * client's own request order -- directly sourced from RFC 9051 SS6.3.11's
- * own worked example: "C: A042 STATUS blurdybloop (UIDNEXT MESSAGES)"
- * answered "S: * STATUS blurdybloop (MESSAGES 231 UIDNEXT 44292)" -- the
- * server reordered UIDNEXT/MESSAGES from the client's own request order,
- * so there's no conformance reason to preserve request order here either.
- */
-static void
-session_handle_mbox_status_result(struct session *s,
- struct imsg_mbox_status_result *res)
-{
- char buf[MBOX_NAME_MAX + 256]; /* F2 fix: was buf[256], too small
- * for a near-maximal mailbox name plus
- * the STATUS attrs. */
- size_t len;
- int n, first = 1;
-
- s->state = s->status_prev_state;
-
- if (!res->ok) {
- /* cmd_status() already ruled out a malformed name -- what's
- * left is either a syntactically valid name that doesn't
- * exist on disk (handle_mbox_status()'s select_mailbox_dir()
- * failing, RFC 9051 SS6.3.4-SS6.3.6 multi-mailbox support) or
- * store.c's own open/flock/index_load failing the same way
- * handle_mbox_select() can. v1 has no way to tell those two
- * apart from here (struct imsg_mbox_status_result carries no
- * distinguishing detail, unlike IMSG_MBOX_APPENDED's no_such_
- * mailbox flag) -- NONEXISTENT is still the more informative
- * answer in the common case, so it's used for both, same
- * "one plausible RFC 5530 code covers the real failure modes"
- * judgment call session_handle_mbox_selected() already makes
- * for SELECT's identical ambiguity. */
- session_reply(s, s->pending_tag, "NO",
- "[NONEXISTENT] no such mailbox");
- return;
- }
-
- /*
- * Quoted, not bare -- unlike the fixed literal "INBOX" this response
- * used to always echo, an arbitrary flat mailbox name can contain a
- * space (mailbox_name_valid() only refuses "/", CTL bytes, and the
- * three reserved maildir-internal names), which would otherwise
- * desynchronize a naive client's own response parser. No backslash-
- * escaping of an embedded '"' or '\' within the name itself -- same
- * "not full ABNF/quoted-string conformance" simplification parse_
- * list_token()'s own comment already documents for the parsing
- * direction; a name containing either is an accepted, pre-existing
- * gap, not new here.
- */
- len = (size_t)snprintf(buf, sizeof(buf), "STATUS \"%s\" (",
- s->status_mailbox);
-
-#define STATUS_APPEND(fmt, val) do { \
- if (len < sizeof(buf)) { /* F2 fix: never index past buf */ \
- n = snprintf(buf + len, sizeof(buf) - len, "%s" fmt, \
- first ? "" : " ", (val)); \
- if (n > 0 && (size_t)n < sizeof(buf) - len) \
- len += (size_t)n; \
- first = 0; \
- } \
-} while (0)
-
- if (s->status_attrs & STATUS_ATT_MESSAGES)
- STATUS_APPEND("MESSAGES %u", res->messages);
- if (s->status_attrs & STATUS_ATT_UIDNEXT)
- STATUS_APPEND("UIDNEXT %u", res->uidnext);
- if (s->status_attrs & STATUS_ATT_UIDVALIDITY)
- STATUS_APPEND("UIDVALIDITY %u", res->uidvalidity);
- if (s->status_attrs & STATUS_ATT_UNSEEN)
- STATUS_APPEND("UNSEEN %u", res->unseen);
- if (s->status_attrs & STATUS_ATT_DELETED)
- STATUS_APPEND("DELETED %u", res->deleted);
- if (s->status_attrs & STATUS_ATT_SIZE)
- STATUS_APPEND("SIZE %llu", (unsigned long long)res->size);
- if (s->status_attrs & STATUS_ATT_HIGHESTMODSEQ)
- STATUS_APPEND("HIGHESTMODSEQ %llu",
- (unsigned long long)res->highestmodseq);
-
-#undef STATUS_APPEND
-
- if (len < sizeof(buf) - 1) {
- buf[len++] = ')';
- buf[len] = '\0';
- }
-
- session_untagged(s, buf);
- session_reply(s, s->pending_tag, "OK", "STATUS completed");
-}
-
-/*
- * Terminal reply shared by CREATE/DELETE/RENAME (cmd_create()/cmd_delete()/
- * cmd_rename()): all three send exactly one struct imsg_mbox_result back,
- * with only "ok" meaningful (see imapd.h's imsg_mbox_create/imsg_mbox_
- * delete/imsg_mbox_rename comments) -- "count" is always 0, unlike LIST
- * there's no per-item stream preceding this terminal reply. Which of the
- * three commands was actually in flight is read from s->state before it's
- * overwritten, purely to pick the right command name for the tagged
- * completion/failure text -- same "read s->state before restoring it"
- * pattern session_handle_mbox_result() itself already uses for STORING/
- * EXPUNGING/FETCHING. s->mbox_op_prev_state (whichever ST_AUTH state was
- * current before the round trip -- all three are command-auth and none
- * changes SELECTED-ness, RFC 9051 SS6.3.4-SS6.3.6) is restored
- * unconditionally, success or failure, same as s->status_prev_state's own
- * restore in session_handle_mbox_status_result() above.
- */
-static void
-session_finish_mbox_op(struct session *s, struct imsg_mbox_result *res)
-{
- const char *cmdname;
- char text[64];
-
- if (s->state == SESSION_CREATING)
- cmdname = "CREATE";
- else if (s->state == SESSION_DELETING)
- cmdname = "DELETE";
- else
- cmdname = "RENAME";
-
- s->state = s->mbox_op_prev_state;
-
- if (!res->ok) {
- /* store.c's own failure modes: CREATE's mkdir(2)/
- * ensure_maildir_dirs() failing for a reason other than the
- * name already existing (already ruled out client-side by
- * mailbox_name_is_inbox()/mailbox_name_valid(), and handled
- * as its own EEXIST case -- see handle_mbox_create()), or
- * DELETE/RENAME losing a race against the name's own
- * existence between listener.c's client-side check and
- * store.c's own stat(2) -- no RFC 5530 code fits either
- * cleanly, so a plain NO is the honest answer, same as
- * session_handle_mbox_result()'s own FETCH/STORE/EXPUNGE
- * failure path. */
- snprintf(text, sizeof(text), "%s failed", cmdname);
- session_reply(s, s->pending_tag, "NO", text);
- return;
- }
-
- /*
- * Real-hardware bug found testing RENAME on premio: if this session
- * had the just-renamed mailbox itself SELECTed, s->selected_mailbox
- * (consulted by session_handle_mbox_appended()'s own-mailbox check
- * and echoed in SELECT's/EXAMINE's own untagged LIST response) still
- * held the old name -- store.c's handle_mbox_rename() already makes
- * its own cwd/current_mailbox_dir follow the rename (see that
- * function's comment), so listener.c needs the identical fix on its
- * side of the same problem, or the two would silently disagree about
- * what this session actually has selected. strcmp(cmdname, "RENAME")
- * rather than checking s->state directly since that's already been
- * overwritten (restored to s->mbox_op_prev_state) a few lines above.
- */
- if (strcmp(cmdname, "RENAME") == 0 &&
- strcmp(s->selected_mailbox, s->rename_oldname) == 0)
- strlcpy(s->selected_mailbox, s->rename_newname,
- sizeof(s->selected_mailbox));
-
- snprintf(text, sizeof(text), "%s completed", cmdname);
- session_reply(s, s->pending_tag, "OK", text);
-}
-
-/*
- * One item of the IMSG_MBOX_LIST_ITEM stream store.c's handle_mbox_list()
- * sends during a SESSION_LISTING round trip (list_dispatch()'s own tail,
- * once its synchronous empty-pattern/INBOX special cases are past) --
- * tested immediately against s->list_pattern (the same canonical
- * reference-plus-pattern concatenation list_dispatch() already used for
- * the synchronous INBOX check) via list_pattern_match(), case-sensitively
- * (ci=0 -- see that function's own comment on why INBOX alone gets ci=1),
- * and turned into its own untagged LIST/LSUB response immediately if it
- * matches. Streamed straight through rather than buffered first the way
- * s->search_matches accumulates SEARCH's results -- there's no sorting,
- * deduplication, or MIN/MAX/COUNT-style aggregation LIST needs to do
- * across the whole result set, so nothing is gained by waiting.
- */
-static void
-session_handle_mbox_list_item(struct session *s,
- struct imsg_mbox_list_item *item)
-{
- char buf[(2 * MBOX_NAME_MAX) + 32];
- const char *kw = s->list_is_lsub ? "LSUB" : "LIST";
-
- if (!list_pattern_match(s->list_pattern, item->mailbox, 0))
- return;
-
- /* Quoted, not bare -- same "an arbitrary flat mailbox name can
- * contain a space" reasoning as session_handle_mbox_status_
- * result()'s identical choice; the fixed literal "INBOX" is safe
- * unquoted (see list_dispatch()'s own untagged response for it) but
- * a real, named mailbox isn't. "()" -- no attributes -- same
- * SS7.3.1 "MAY send none of these" choice used throughout. */
- snprintf(buf, sizeof(buf), "%s () \"/\" \"%s\"", kw, item->mailbox);
- session_untagged(s, buf);
-}
-
-/*
- * Terminal reply for the SESSION_LISTING round trip list_dispatch()
- * started: store.c's handle_mbox_list() streams zero or more IMSG_MBOX_
- * LIST_ITEM messages (each already turned into its own untagged response,
- * if it matched, by session_handle_mbox_list_item() above) followed by
- * exactly one terminal IMSG_MBOX_RESULT. res->count (how many items were
- * streamed, matched or not) isn't needed here -- only res->ok, and even
- * that can only be 0 from a real I/O error opening this session's own
- * maildir root (handle_mbox_list()'s own comment), not from anything
- * pattern- or mailbox-name-related; SS6.3.9's "silently ignore" rule for
- * an unmatched pattern is already satisfied by simply not having sent an
- * untagged response for it, same as the synchronous INBOX case just above
- * in list_dispatch(). Restores s->mbox_op_prev_state the same way
- * session_finish_mbox_op() does (LIST/LSUB are command-auth, RFC 9051
- * SS6.3.9, and neither changes SELECTED-ness).
- */
-static void
-session_finish_list(struct session *s, struct imsg_mbox_result *res)
-{
- const char *cmdname = s->list_is_lsub ? "LSUB" : "LIST";
- char text[32];
-
- s->state = s->mbox_op_prev_state;
-
- if (!res->ok) {
- snprintf(text, sizeof(text), "%s failed", cmdname);
- session_reply(s, s->pending_tag, "NO", text);
- return;
- }
-
- snprintf(text, sizeof(text), "%s completed", cmdname);
- session_reply(s, s->pending_tag, "OK", text);
-}
-
-/*
- * EXPUNGE's untagged response (RFC 9051 SS7.5.1): "* <seqno> EXPUNGE",
- * `seqno` already computed by store.c's handle_mbox_expunge() per
- * SS7.5.1's "immediately decremented" rule -- this function has nothing
- * to compute, only to format. Never called for CLOSE's silent=1 request:
- * store.c simply never sends IMSG_MBOX_EXPUNGED when req->silent is set
- * (RFC 9051 SS6.4.1: "No untagged EXPUNGE responses are sent"), so there's
- * no silent-suppression logic needed here either.
- *
- * RFC 7162 SS3.2.10.2 addition: once this session has "ENABLE QRESYNC"'d
- * (s->qresync_enabled), the server "MUST use the VANISHED response without
- * the EARLIER tag instead of the EXPUNGE response... for the duration of
- * the connection" -- UID-based (exp->uid, populated by store.c this pass;
- * see imapd.h's imsg_mbox_expunged comment), one per message, matching
- * this function's existing one-response-per-removed-message shape (RFC
- * 7162's own examples combine several UIDs into a single VANISHED line,
- * but nothing requires it -- see SS3.2.10's "if necessary, two VANISHED
- * responses are sent" for confirmation that multiple VANISHED lines per
- * operation are legal; combining these would need the same "buffer until
- * the terminal reply" treatment the QRESYNC SELECT resync path uses,
- * which isn't necessary here since EXPUNGE has no VANISHED-before-FETCH
- * ordering constraint to satisfy -- SS3.2.10.2's ordering rule is specific
- * to VANISHED (EARLIER), not this plain form).
- */
-static void
-session_send_expunge_response(struct session *s,
- struct imsg_mbox_expunged *exp)
-{
- char buf[32];
-
- if (s->qresync_enabled)
- snprintf(buf, sizeof(buf), "VANISHED %u", exp->uid);
- else
- snprintf(buf, sizeof(buf), "%u EXPUNGE", exp->seqno);
- session_untagged(s, buf);
-}
-
-/*
- * RFC 7162 SS3.2.6's VANISHED UID FETCH modifier: formats one range from
- * store.c's IMSG_MBOX_SELECT_VANISHED (see that struct's now-broadened
- * comment in imapd.h) as a "* VANISHED (EARLIER) ..." untagged
- * response, written immediately rather than buffered -- unlike the QRESYNC
- * SELECT resync case (session_handle_select_vanished()), a UID FETCH has
- * no other untagged response that needs to interleave in a specific
- * relative order with this one; the only ordering requirement (VANISHED
- * before any FETCH response for the same command) is already guaranteed
- * by handle_mbox_fetch() sending all of its VANISHED ranges, in full,
- * before it sends any IMSG_MBOX_FETCH_META.
- */
-static void
-session_handle_fetch_vanished(struct session *s,
- struct imsg_mbox_select_vanished *v)
-{
- char buf[64];
-
- if (v->uid_lo == v->uid_hi)
- snprintf(buf, sizeof(buf), "VANISHED (EARLIER) %u", v->uid_lo);
- else
- snprintf(buf, sizeof(buf), "VANISHED (EARLIER) %u:%u",
- v->uid_lo, v->uid_hi);
- session_untagged(s, buf);
-}
-
-/*
- * Shared by cmd_expunge(), cmd_close(), and (this pass) cmd_uid()'s
- * EXPUNGE branch: all three send the identical IMSG_MBOX_EXPUNGE request
- * shape, transition to SESSION_EXPUNGING, and save the tag -- see
- * imapd.h's imsg_mbox_expunge comment for why CLOSE reuses EXPUNGE's
- * request/reply pair wholesale rather than getting its own. is_close is
- * stashed in s->close_after_expunge so session_handle_mbox_result() knows,
- * once the terminal IMSG_MBOX_RESULT arrives, which tagged completion text
- * to send and which state to return to.
- *
- * RFC 9051 SS6.4.9 addition (UID EXPUNGE): by_uid/uid_lo/uid_hi/lo_star/
- * hi_star carry UID EXPUNGE's required UID-set argument through to
- * store.c (see imapd.h's imsg_mbox_expunge comment) -- meaningless (and
- * always zeroed by cmd_close()/cmd_expunge()) when by_uid is 0, since
- * plain EXPUNGE takes no arguments and CLOSE has no UID-restricted form at
- * all (never called with is_close=1 and by_uid=1 together). Untagged
- * EXPUNGE/VANISHED responses stay seqno/UID exactly as store.c's silent
- * flag and s->qresync_enabled already decide (RFC 9051 SS6.4.9: "The
- * number after the '*' in an untagged FETCH or EXPUNGE response is always
- * a message sequence number... even for a UID command response") -- only
- * the tagged completion text changes based on by_uid, via s->cmd_by_uid
- * and session_handle_mbox_result()'s cmdname.
- */
-static int
-session_request_expunge(struct session *s, const char *tag, int is_close,
- int by_uid, uint32_t uid_lo, uint32_t uid_hi, int lo_star, int hi_star)
-{
- struct imsg_mbox_expunge req;
- const char *cmdname = is_close ? "CLOSE" :
- (by_uid ? "UID EXPUNGE" : "EXPUNGE");
-
- if (s->mbox_readonly) {
- if (is_close) {
- /*
- * RFC 9051 SS6.4.1: "No messages are removed, and no
- * error is given, if the mailbox is selected by
- * EXAMINE." Skip the store round trip entirely --
- * nothing to expunge, s->mbox_highestmodseq is still
- * correct since nothing changed, and CLOSE already
- * sends no untagged EXPUNGE responses even in the
- * read-write case, so there's nothing else this reply
- * needs to do besides deselect and reply OK.
- */
- s->state = SESSION_AUTHENTICATED;
- session_reply(s, tag, "OK", "CLOSE completed");
- return (1);
- }
- /*
- * Unlike CLOSE, plain/UID EXPUNGE has no RFC 9051 text
- * excusing it on a read-only mailbox -- SS6.3.3's "No changes
- * to the permanent state of the mailbox... are permitted"
- * applies directly, since EXPUNGE permanently removes
- * messages. RFC 5530 CANNOT ("The operation violates some
- * invariant of the server and can never succeed") fits: this
- * particular session can never expunge while its selection
- * stays read-only, no retry will help.
- */
- session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
- "(selected via EXAMINE)");
- return (1);
- }
-
- if (s->store_iev == NULL) {
- /* Same internal-invariant check as cmd_select()/cmd_fetch()/
- * cmd_store_cmd() -- ST_SELECTED requires store_iev to
- * already be wired. */
- log_warnx("session %u: %s with no store channel wired",
- s->id, cmdname);
- session_reply(s, tag, "NO", "[SERVERBUG] internal error");
- return (1);
- }
-
- memset(&req, 0, sizeof(req));
- req.silent = is_close;
- req.by_uid = by_uid;
- req.seq_lo = uid_lo;
- req.seq_hi = uid_hi;
- req.lo_is_star = lo_star;
- req.hi_is_star = hi_star;
-
- strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
- s->close_after_expunge = is_close;
- s->cmd_by_uid = by_uid;
- s->state = SESSION_EXPUNGING;
-
- if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_EXPUNGE, 0, 0, -1,
- &req, sizeof(req)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_EXPUNGE", s->id);
- imsgev_add(s->store_iev);
-
- return (1);
-}
-
-/*
- * cmd_uid()'s EXPUNGE branch: RFC 9051 SS6.4.9's second UID command form,
- * "UID EXPUNGE <sequence set>" (the sequence set is a required argument
- * here, unlike plain EXPUNGE, which takes none at all -- see imapd.h's
- * imsg_mbox_expunge comment). Same v1 single-range restriction FETCH/
- * STORE/SEARCH already have (parse_seq_range() rejects a comma internally
- * anyway, but the explicit check here gives a clearer BAD reason, matching
- * those other three call sites' own style). Never reachable with is_close
- * set -- there is no "UID CLOSE" -- so this always calls session_request_
- * expunge() with is_close=0.
- */
-static int
-uid_expunge_dispatch(struct session *s, const char *tag, char *args)
-{
- uint32_t lo, hi;
- int lo_star, hi_star;
-
- if (args == NULL) {
- session_reply(s, tag, "BAD",
- "UID EXPUNGE requires a sequence set of UIDs");
- return (1);
- }
- if (strchr(args, ',') != NULL) {
- session_reply(s, tag, "BAD",
- "comma-separated sequence sets not supported in v1 -- "
- "issue separate UID EXPUNGE commands");
- return (1);
- }
- if (parse_seq_range(args, &lo, &hi, &lo_star, &hi_star) == -1) {
- session_reply(s, tag, "BAD", "invalid sequence set");
- return (1);
- }
-
- return session_request_expunge(s, tag, 0, 1, lo, hi, lo_star, hi_star);
-}
-
-/*
- * Appends one IMSG_MBOX_SEARCH_MATCH's sequence number to s->search_
- * matches, growing the array as needed (doubling, starting at 32 --
- * generous for a v1-scale personal mailbox without over-allocating for
- * the common small-result-set case). store.c streams these in ascending
- * index order (see handle_mbox_search()'s comment); since the index
- * itself is UID-ordered and sequence number is just live position within
- * it, seqno and uid are *both* ascending across the stream, so the array
- * stays sorted and duplicate-free by construction regardless of which of
- * the two values gets pushed below -- session_finish_search() relies on
- * that for both its range-compaction (format_seq_list()) and its MIN/MAX
- * shortcuts (first/last element).
- *
- * A realloc(3) failure here sets s->search_alloc_failed instead of
- * silently dropping the match: an ESEARCH response that's missing
- * matches the server actually found would be *wrong*, not just
- * incomplete, and this codebase's established posture (EXPUNGE's
- * "conservatively kept rather than dropped," APPEND's "reject cleanly
- * rather than silently truncate") is to never hand a client data it
- * can't stand behind. session_finish_search() checks the flag and sends
- * a NO instead of a wrong ESEARCH if it's set. m->uid is now used (RFC
- * 9051 SS6.4.9's UID SEARCH command wrapper, wired up this pass via
- * search_dispatch()'s by_uid) -- see s->cmd_by_uid's comment in struct
- * session for which of seqno/uid gets pushed.
- */
-static void
-session_handle_mbox_search_match(struct session *s,
- struct imsg_mbox_search_match *m)
-{
- if (s->search_alloc_failed)
- return;
-
- if (s->search_nmatches == s->search_matches_cap) {
- uint32_t newcap = s->search_matches_cap ?
- s->search_matches_cap * 2 : 32;
- uint32_t *n = reallocarray(s->search_matches, newcap,
- sizeof(uint32_t));
-
- if (n == NULL) {
- log_warn("session %u: realloc SEARCH match array",
- s->id);
- s->search_alloc_failed = 1;
- return;
- }
- s->search_matches = n;
- s->search_matches_cap = newcap;
- }
-
- /* RFC 9051 SS6.4.9: UID SEARCH reports UIDs instead of sequence
- * numbers in its ESEARCH data -- see s->cmd_by_uid's comment. */
- s->search_matches[s->search_nmatches++] = s->cmd_by_uid ?
- m->uid : m->seqno;
-
- /* RFC 7162 SS3.1.6: track the running max modseq across all matches
- * -- store.c always populates m->modseq (see imapd.h's struct
- * imsg_mbox_search_match comment), so this costs nothing even when
- * the search program didn't use MODSEQ; session_finish_search()
- * only actually prints it when s->search_used_modseq is set. */
- if (m->modseq > s->search_max_modseq)
- s->search_max_modseq = m->modseq;
-}
-
-/*
- * Appends one IMSG_MBOX_COPY_MAPPING's src_uid/dest_uid pair to s->
- * copy_src_uids/copy_dest_uids, growing both arrays together (same
- * doubling-from-16 growth as s->move_expunged, generous for v1-scale
- * COPY/MOVE ranges without over-allocating the common single-message
- * case). store.c streams these in ascending order (both handle_mbox_
- * copy() and handle_mbox_move() walk the index forward), so both arrays
- * stay sorted by construction -- session_finish_copy_or_move() relies on
- * that for format_seq_list()'s range-compaction, same precondition
- * session_handle_mbox_search_match()'s own comment already documents for
- * s->search_matches.
- *
- * A realloc(3) failure here sets s->copy_alloc_failed instead of silently
- * dropping the mapping -- same "never hand a client data it can't stand
- * behind" reasoning as s->search_alloc_failed; session_finish_copy_or_
- * move() checks the flag and sends a NO instead of a wrong/incomplete
- * COPYUID if it's set.
- */
-static void
-session_handle_mbox_copy_mapping(struct session *s,
- struct imsg_mbox_copy_mapping *m)
-{
- if (s->copy_alloc_failed)
- return;
-
- if (s->copy_n == s->copy_cap) {
- uint32_t newcap = s->copy_cap ? s->copy_cap * 2 : 16;
- uint32_t *newsrc = reallocarray(s->copy_src_uids, newcap,
- sizeof(uint32_t));
- uint32_t *newdest;
-
- if (newsrc == NULL) {
- log_warn("session %u: realloc COPY src array", s->id);
- s->copy_alloc_failed = 1;
- return;
- }
- s->copy_src_uids = newsrc;
-
- newdest = reallocarray(s->copy_dest_uids, newcap,
- sizeof(uint32_t));
- if (newdest == NULL) {
- log_warn("session %u: realloc COPY dest array", s->id);
- s->copy_alloc_failed = 1;
- return;
- }
- s->copy_dest_uids = newdest;
- s->copy_cap = newcap;
- }
-
- s->copy_src_uids[s->copy_n] = m->src_uid;
- s->copy_dest_uids[s->copy_n] = m->dest_uid;
- s->copy_n++;
-}
-
-/*
- * Appends one IMSG_MBOX_SELECT_VANISHED range to s->vanished_ranges --
- * same growable-array shape as session_handle_mbox_search_match() above,
- * without an alloc-failure flag: unlike a wrong/incomplete SEARCH result
- * (which this codebase treats as a hard failure -- see session_handle_
- * mbox_search_match()'s comment), a dropped VANISHED range only means the
- * client doesn't find out about some already-vanished messages this one
- * time and will simply learn about them on its next resync -- a real
- * degradation, but not a correctness violation the way handing back a
- * SEARCH result for a query that was never actually evaluated would be.
- * Logged either way.
- */
-static void
-session_handle_select_vanished(struct session *s,
- struct imsg_mbox_select_vanished *v)
-{
- if (s->vanished_nranges == s->vanished_cap) {
- uint32_t newcap = s->vanished_cap ?
- s->vanished_cap * 2 : 16;
- struct vanished_range *n = reallocarray(s->vanished_ranges,
- newcap, sizeof(*n));
-
- if (n == NULL) {
- log_warn("session %u: realloc VANISHED range array",
- s->id);
- return;
- }
- s->vanished_ranges = n;
- s->vanished_cap = newcap;
- }
-
- s->vanished_ranges[s->vanished_nranges].lo = v->uid_lo;
- s->vanished_ranges[s->vanished_nranges].hi = v->uid_hi;
- s->vanished_nranges++;
-}
-
-/*
- * Appends one IMSG_MBOX_FETCH_META (from a QRESYNC SELECT resync -- see
- * session_store_dispatch()'s IMSG_MBOX_FETCH_META case, which routes here
- * specifically when s->state == SESSION_SELECTING) to s->qresync_fetches.
- * Same "held back until the terminal reply" reason as vanished_ranges --
- * RFC 7162 SS3.2.6's VANISHED-before-FETCH ordering requirement.
- */
-static void
-session_handle_select_fetch(struct session *s, struct imsg_mbox_fetch_meta *m)
-{
- if (s->qresync_nfetches == s->qresync_fetches_cap) {
- uint32_t newcap = s->qresync_fetches_cap ?
- s->qresync_fetches_cap * 2 : 16;
- struct imsg_mbox_fetch_meta *n = reallocarray(
- s->qresync_fetches, newcap, sizeof(*n));
-
- if (n == NULL) {
- log_warn("session %u: realloc QRESYNC fetch array",
- s->id);
- return;
- }
- s->qresync_fetches = n;
- s->qresync_fetches_cap = newcap;
- }
-
- s->qresync_fetches[s->qresync_nfetches++] = *m;
-}
-
-/*
- * Appends one IMSG_MBOX_IDLE_UID (RFC 9051 SS6.3.13) to s->idle_incoming_
- * uids -- accumulated during an in-flight refresh, held back until
- * session_handle_idle_refreshed() has the complete list to diff against
- * s->idle_known_uids. Same realloc-doubling shape and same "log and drop
- * this one item on allocation failure" leniency as session_handle_select_
- * vanished()/session_handle_select_fetch() above -- for the same reason:
- * losing track of one UID here just means this round's diff might miss
- * reporting it, not a crash or a wrong tagged response.
- */
-static void
-session_handle_idle_uid(struct session *s, struct imsg_mbox_idle_uid *item)
-{
- if (s->idle_incoming_n == s->idle_incoming_cap) {
- uint32_t newcap = s->idle_incoming_cap ?
- s->idle_incoming_cap * 2 : 16;
- uint32_t *n = reallocarray(s->idle_incoming_uids,
- newcap, sizeof(*n));
-
- if (n == NULL) {
- log_warn("session %u: realloc IDLE UID array", s->id);
- return;
- }
- s->idle_incoming_uids = n;
- s->idle_incoming_cap = newcap;
- }
-
- s->idle_incoming_uids[s->idle_incoming_n++] = item->uid;
-}
-
-/*
- * Pushes "* N EXPUNGE" for every UID present in old (length oldn) but
- * absent from cur (length curn), in ascending old-list-position order.
- * RFC 9051 SS7.5.1: "the server MUST immediately decrement" the sequence
- * number of every subsequent message -- so a removed message's reported
- * seqno is exactly its position among messages processed so far (both
- * already-reported-removed and still-present), which is why seqno only
- * advances on a *present* match below, never on a removed one. Matches
- * store.c's own handle_mbox_expunge() comment on the identical rule
- * ("that message's current sequence number minus one"), and produces the
- * exact same output as SS6.4.3's own worked example (removing original
- * positions 3, 4, 7, 11 from an 11-message mailbox reports 3, 3, 5, 8) --
- * reimplemented here as a plain array diff, rather than reusing that
- * function directly, since this session's own store child isn't the one
- * that removed anything; some *other* session's mutation is what
- * triggered this refresh (session_notify_idle_peers()), so all this
- * session has to go on is two UID snapshots, not a live index to mutate.
- *
- * O(oldn * curn) -- fine at the mailbox sizes this project targets (see
- * openimap-privsep-design.md's "personal use" scope), same non-goal this
- * codebase states elsewhere about large-scale performance.
- */
-static void
-session_push_idle_expunges(struct session *s, const uint32_t *old,
- uint32_t oldn, const uint32_t *cur, uint32_t curn)
-{
- uint32_t i, j, seqno;
- char buf[32];
-
- seqno = 1;
- for (i = 0; i < oldn; i++) {
- int present = 0;
-
- for (j = 0; j < curn; j++) {
- if (cur[j] == old[i]) {
- present = 1;
- break;
- }
- }
- if (present) {
- seqno++;
- continue;
- }
- snprintf(buf, sizeof(buf), "%u EXPUNGE", seqno);
- session_untagged(s, buf);
- }
-}
-
-/*
- * Terminal reply to an IMSG_MBOX_IDLE_REFRESH round trip (RFC 9051
- * SS6.3.13) -- either the baseline one cmd_idle() triggers right after
- * sending "+ idling" (s->idle_baseline_valid still 0: nothing to diff
- * against yet, this call's only job is to adopt the freshly streamed list
- * as the baseline), or a change-triggered one from session_notify_idle_
- * peers() (baseline already valid: diff old vs. new, push EXPUNGE/EXISTS
- * for whatever actually changed).
- *
- * res->ok == 0 (index open/lock/load failure in store.c, same failure
- * modes handle_mbox_select() itself can hit) just drops this round on the
- * floor -- s->idle_known_uids is left exactly as it was, so a later
- * successful refresh can still diff correctly against whatever the last
- * good baseline was; the client just doesn't find out about this
- * particular change until then.
- *
- * Pushing anything is gated on s->idling && s->state == SESSION_SELECTED:
- * this round trip is asynchronous, so by the time it lands the client may
- * already have sent DONE (s->idling cleared) or issued a new SELECT
- * (s->state changed) -- in either case, sending untagged EXPUNGE/EXISTS
- * now would be at best surprising and at worst interleaved with whatever
- * that new command's own response is building. The cache still gets
- * updated either way; only the pushing is skipped.
- */
-static void
-session_handle_idle_refreshed(struct session *s,
- struct imsg_mbox_idle_refreshed *res)
-{
- uint32_t *newlist = s->idle_incoming_uids;
- uint32_t newn = s->idle_incoming_n;
-
- s->idle_incoming_uids = NULL;
- s->idle_incoming_n = 0;
- s->idle_incoming_cap = 0;
-
- s->idle_refresh_pending = 0;
-
- if (!res->ok) {
- log_warnx("session %u: IDLE refresh failed, keeping last "
- "known state", s->id);
- free(newlist);
- goto maybe_again;
- }
-
- if (!s->idle_baseline_valid) {
- free(s->idle_known_uids);
- s->idle_known_uids = newlist;
- s->idle_known_nuids = newn;
- s->idle_known_cap = newn;
- s->idle_baseline_valid = 1;
- goto maybe_again;
- }
-
- if (s->idling && s->state == SESSION_SELECTED) {
- session_push_idle_expunges(s, s->idle_known_uids,
- s->idle_known_nuids, newlist, newn);
- if (newn != s->idle_known_nuids) {
- char buf[32];
-
- snprintf(buf, sizeof(buf), "%u EXISTS", newn);
- session_untagged(s, buf);
- }
- }
-
- free(s->idle_known_uids);
- s->idle_known_uids = newlist;
- s->idle_known_nuids = newn;
- s->idle_known_cap = newn;
-
-maybe_again:
- if (s->idle_refresh_again) {
- s->idle_refresh_again = 0;
- session_request_idle_refresh(s);
- }
-}
-
-/*
- * Sends IMSG_MBOX_IDLE_REFRESH on s's own store_iev -- either cmd_idle()
- * seeding this session's own baseline right after "+ idling", or session_
- * notify_idle_peers() asking a *sibling* session (same uid, currently
- * idling) to recheck after this session's own successful mutation.
- * Coalesces: if a refresh is already in flight for s, this just remembers
- * to run another one immediately after it completes (s->idle_refresh_
- * again) rather than starting a second overlapping request on the same
- * store_iev channel.
- */
-static void
-session_request_idle_refresh(struct session *s)
-{
- if (s->idle_refresh_pending) {
- s->idle_refresh_again = 1;
- return;
- }
- if (s->store_iev == NULL)
- return; /* no store child wired -- nothing to ask */
-
- s->idle_refresh_pending = 1;
- if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_IDLE_REFRESH, 0, 0,
- -1, NULL, 0) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_IDLE_REFRESH",
- s->id);
- imsgev_add(s->store_iev);
-}
-
-/*
- * Called after a successful EXPUNGE/UID EXPUNGE/CLOSE-with-real-removal,
- * APPEND, or MOVE/UID MOVE -- the mutations that can change which UIDs
- * exist (a plain STORE, by contrast, only changes flags, which this v1
- * scope doesn't push during IDLE at all -- see imapd.h's imsg_mbox_
- * idle_uid comment). Scans every other live session belonging to the
- * *same* uid (v1 has no shared mailboxes, so cross-user notification never
- * applies -- see struct session's uid comment) that's currently idling
- * with a mailbox selected, and asks each one to refresh. s itself (the
- * session that just mutated something) is skipped -- it already knows
- * about its own change through its own command's ordinary response.
- *
- * Doesn't check *which* mailbox each peer has selected against which
- * mailbox actually changed -- harmless imprecision, not a correctness
- * bug: a peer with a different mailbox selected still only gets asked to
- * refresh (IMSG_MBOX_IDLE_REFRESH), and that refresh re-diffs the peer's
- * own store child's own current_mailbox_dir against its own cached UID
- * snapshot (handle_mbox_idle_refresh() in store.c), so a mismatched wakeup
- * finds nothing changed and pushes nothing -- at most a wasted round trip,
- * never wrong data. Before RFC 9051 SS6.3.4-SS6.3.6's flat multi-mailbox
- * support (docs/openimap-storage-backend.md item 10) this distinction
- * didn't exist at all (every SELECTED session necessarily had the same,
- * only mailbox selected); tightening this to only wake peers actually
- * selected on the mailbox that changed is a real, flagged follow-up, not
- * done here.
- */
-static void
-session_notify_idle_peers(struct session *s)
-{
- struct session *other;
-
- TAILQ_FOREACH(other, &sessions, entry) {
- if (other == s)
- continue;
- if (other->uid != s->uid)
- continue;
- if (!other->idling || other->state != SESSION_SELECTED)
- continue;
- session_request_idle_refresh(other);
- }
-}
-
-/*
- * Formats one QRESYNC-resync FETCH response: always UID + FLAGS + MODSEQ,
- * matching RFC 7162 SS3.2.5.1's own worked examples exactly (e.g. "* 49
- * FETCH (UID 117 FLAGS (\Seen \Answered) MODSEQ (90060115194045001))") --
- * unlike session_send_fetch_response(), there's no s->fetch_attrs to
- * consult here, since this isn't a client-requested attribute list; the
- * RFC itself fixes what a resync FETCH response contains.
- */
-static void
-session_send_qresync_fetch_response(struct session *s,
- struct imsg_mbox_fetch_meta *meta)
-{
- char buf[MBOX_FLAGS_MAX + 96];
-
- snprintf(buf, sizeof(buf), "%u FETCH (UID %u FLAGS (%s) MODSEQ (%llu))",
- meta->seqno, meta->uid, meta->flags,
- (unsigned long long)meta->modseq);
- session_untagged(s, buf);
-}
-
-/*
- * Appends one IMSG_MBOX_STORE_MODIFIED (seqno of a message that failed a
- * STORE's UNCHANGEDSINCE test) to s->store_modified -- same growable-array
- * shape as the two accumulators above, for session_handle_mbox_result()
- * to range-compact into the tagged response's MODIFIED response code once
- * the terminal IMSG_MBOX_RESULT arrives.
- */
-static void
-session_handle_store_modified(struct session *s,
- struct imsg_mbox_store_modified *m)
-{
- if (s->store_modified_n == s->store_modified_cap) {
- uint32_t newcap = s->store_modified_cap ?
- s->store_modified_cap * 2 : 16;
- uint32_t *n = reallocarray(s->store_modified, newcap,
- sizeof(*n));
-
- if (n == NULL) {
- log_warn("session %u: realloc STORE MODIFIED array",
- s->id);
- return;
- }
- s->store_modified = n;
- s->store_modified_cap = newcap;
- }
-
- /* RFC 7162 SS3.1.3: MODIFIED lists UIDs for UID STORE, sequence
- * numbers for plain STORE -- see s->cmd_by_uid's comment in struct
- * session. */
- s->store_modified[s->store_modified_n++] =
- s->cmd_by_uid ? m->uid : m->seqno;
-}
-
-/*
- * Appends nums[i] as either a bare number or (for a run of consecutive
- * values) a "lo:hi" range to buf, comma-separating entries -- RFC 9051
- * SS7.3.4's own ESEARCH examples use exactly this compacted form (e.g.
- * "ALL 2,10:11"). nums must already be sorted ascending with no
- * duplicates (true by construction -- see session_handle_mbox_search_
- * match()'s comment).
- *
- * Stops cleanly at a token boundary (never writes a partial number or a
- * dangling comma) if the next range wouldn't fit in the remaining space,
- * and reports that via *truncated -- see session_finish_search()'s
- * comment for why a v1-scale response is expected to always fit, and
- * what happens on the rare occasion it doesn't.
- */
-static size_t
-format_seq_list(char *buf, size_t bufsize, const uint32_t *nums, uint32_t n,
- int *truncated)
-{
- size_t written = 0;
- uint32_t i = 0;
- int first = 1;
-
- *truncated = 0;
- buf[0] = '\0';
-
- while (i < n) {
- uint32_t start = nums[i];
- uint32_t end = start;
- uint32_t j = i + 1;
- char tok[24];
- int toklen;
-
- 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)
- 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;
- i = j;
- }
-
- return (written);
-}
-
-/*
- * RFC 7162 counterpart to format_seq_list() above, for VANISHED (EARLIER)'s
- * known-uids list: ranges is already pre-compacted (store.c's qresync_send_
- * resync() computes minimal maximal ranges directly, see imapd.h's
- * imsg_mbox_select_vanished comment), so this just joins ranges[i].lo:
- * ranges[i].hi (or a bare number when lo == hi) with commas -- no run-
- * detection needed, unlike format_seq_list()'s individual-number input.
- * Same truncate-cleanly-at-a-token-boundary contract and *truncated
- * signal as format_seq_list().
- */
-static size_t
-format_range_list(char *buf, size_t bufsize, const struct vanished_range *ranges,
- uint32_t n, int *truncated)
-{
- size_t written = 0;
- uint32_t i;
- int first = 1;
-
- *truncated = 0;
- buf[0] = '\0';
-
- for (i = 0; i < n; i++) {
- char tok[24];
- int toklen;
-
- 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)
- 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;
- }
-
- return (written);
-}
-
-/*
- * Terminal reply for the SEARCH round trip cmd_search() started --
- * called from session_handle_mbox_result() when s->state is SESSION_
- * SEARCHING, before that function's generic FETCH/STORE/EXPUNGE/CLOSE
- * handling (SEARCH's reply shape, an ESEARCH response built from the
- * accumulated match list rather than a fixed "<cmdname> completed/
- * failed" text, is different enough to warrant its own function rather
- * than another branch bolted onto the generic one).
- *
- * RFC 9051 SS6.4.4/SS7.3.4's MIN/MAX/ALL/COUNT semantics, resolved
- * against that section's own worked examples (not just the prose, which
- * reads ambiguously on its own -- see the specific examples cited
- * below): COUNT is included, unconditionally on match count (even
- * "COUNT 0"), if and only if it was requested (RETURN (MIN) UNSEEN's
- * own example response, "MIN 4", has no COUNT at all, showing COUNT is
- * NOT sent just because it's a valid default -- only when actually
- * asked for); MIN/MAX/ALL are each included only if requested AND there
- * is at least one match (RETURN (MIN) UNSEEN's response also confirms
- * MIN is omitted, not sent as 0, when unrequested attributes are absent,
- * and SEARCH's own no-RETURN-clause example, "SEARCH TEXT ...", with no
- * matches produces a bare "* ESEARCH (TAG "...")" with nothing after
- * the correlator at all -- the default-ALL implied option, with zero
- * matches, is simply omitted). The correlator ("(TAG "<tag>")") is
- * always sent, since every ESEARCH this server produces is a direct
- * response to a specific tagged command, never spontaneous.
- *
- * No "UID" indicator: see imapd.h's imsg_mbox_search comment on why
- * ESEARCH data is always in sequence-number space this pass.
- *
- * Built via session_write() directly with a generously-sized local
- * buffer (8192 bytes, matching SESSION_INBUF_MAX's own "generous"
- * precedent), bypassing session_untagged()'s ordinary 512-byte cap --
- * that cap is fine for this file's other short, fixed-shape status
- * text, but truncating ESEARCH's ALL data at 512 bytes would silently
- * hand the client a materially wrong (incomplete) result, not just a
- * cosmetically shortened one. format_seq_list() itself never writes a
- * partial token even within this larger buffer; on the rare v1-scale
- * mailbox where even 8192 bytes isn't enough for a highly non-contiguous
- * match set, the response is truncated at a clean token boundary and a
- * warning is logged server-side -- a known, flagged v1-scale limitation
- * (RFC 9051 has no mechanism to signal a partial ALL list to the
- * client), not a silent-corruption risk, since COUNT (when requested) is
- * always computed from the true, untruncated match count regardless.
- */
-static void
-session_finish_search(struct session *s, struct imsg_mbox_result *res)
-{
- char buf[8192];
- size_t len;
- int truncated = 0;
-
- s->state = SESSION_SELECTED;
-
- log_debug("session %u: SEARCH done, ok=%d, %u match(es)", s->id,
- res->ok, res->count);
-
- if (!res->ok || s->search_alloc_failed) {
- session_reply(s, s->pending_tag, "NO",
- s->cmd_by_uid ? "UID SEARCH failed" : "SEARCH failed");
- goto cleanup;
- }
-
- len = (size_t)snprintf(buf, sizeof(buf), "* ESEARCH (TAG \"%s\")",
- s->pending_tag);
-
- /* RFC 9051 SS9 esearch-response ABNF: `"ESEARCH" [search-correlator]
- * [SP "UID"] *(SP search-return-data)` -- the "UID" indicator (RFC
- * 9051 SS6.4.9: "the corresponding ESEARCH response MUST include the
- * UID indicator") comes right after the correlator, before any of
- * MIN/MAX/ALL/COUNT/MODSEQ. Matches SS6.4.9's own worked examples
- * exactly: `* ESEARCH (TAG "A285") UID MIN 7 MAX 3800` and
- * `* ESEARCH (TAG "A301") UID ALL 17,900,901`. */
- if (s->cmd_by_uid && len < sizeof(buf))
- len += (size_t)snprintf(buf + len, sizeof(buf) - len, " UID");
-
- if ((s->search_return_opts & SEARCH_RETURN_MIN) &&
- s->search_nmatches > 0 && len < sizeof(buf))
- len += (size_t)snprintf(buf + len, sizeof(buf) - len,
- " MIN %u", s->search_matches[0]);
-
- if ((s->search_return_opts & SEARCH_RETURN_MAX) &&
- s->search_nmatches > 0 && len < sizeof(buf))
- len += (size_t)snprintf(buf + len, sizeof(buf) - len,
- " MAX %u", s->search_matches[s->search_nmatches - 1]);
-
- if ((s->search_return_opts & SEARCH_RETURN_ALL) &&
- s->search_nmatches > 0 && len < sizeof(buf)) {
- size_t listlen;
-
- len += (size_t)snprintf(buf + len, sizeof(buf) - len,
- " ALL ");
- if (len < sizeof(buf)) {
- listlen = format_seq_list(buf + len,
- sizeof(buf) - len, s->search_matches,
- s->search_nmatches, &truncated);
- len += listlen;
- if (truncated)
- log_warnx("session %u: SEARCH ALL response "
- "truncated at %zu bytes (%u total "
- "matches) -- known v1-scale limitation, "
- "see session_finish_search()'s comment",
- s->id, sizeof(buf), s->search_nmatches);
- }
- }
-
- if ((s->search_return_opts & SEARCH_RETURN_COUNT) && len < sizeof(buf))
- len += (size_t)snprintf(buf + len, sizeof(buf) - len,
- " COUNT %u", s->search_nmatches);
-
- /* RFC 7162 SS3.1.10 (Example 19): a MODSEQ SEARCH criterion with a
- * non-empty result gets "MODSEQ n" appended to the ESEARCH response,
- * using the highest mod-sequence among the returned messages --
- * this codebase always answers SEARCH in ESEARCH form (see the RETURN
- * default set above), so SS3.1.10's ESEARCH case is the only one
- * that applies here; SS3.1.6's plain "* SEARCH ... (MODSEQ n)" form
- * never arises. */
- if (s->search_used_modseq && s->search_nmatches > 0 &&
- len < sizeof(buf))
- len += (size_t)snprintf(buf + len, sizeof(buf) - len,
- " MODSEQ %llu",
- (unsigned long long)s->search_max_modseq);
-
- if (len >= sizeof(buf))
- len = sizeof(buf) - 1; /* truncate rather than overflow --
- * see this function's header comment;
- * only reachable if even the fixed
- * correlator/MIN/MAX/COUNT portions
- * somehow overflowed, which a v1-scale
- * tag length never should */
-
- buf[len++] = '\r';
- buf[len++] = '\n';
- session_write(s, buf, len);
-
- /* RFC 9051 SS6.4.9's own worked example shows "UID FETCH completed"
- * verbatim; UID EXPUNGE's own worked example shows "UID EXPUNGE
- * completed". "UID SEARCH completed" isn't shown as a literal
- * example in the RFC text, but follows the same "UID <cmd>
- * completed" pattern by direct symmetry with those two -- not
- * fabricated, just not independently quoted. */
- session_reply(s, s->pending_tag, "OK",
- s->cmd_by_uid ? "UID SEARCH completed" : "SEARCH completed");
-
-cleanup:
- free(s->search_matches);
- s->search_matches = NULL;
- s->search_nmatches = 0;
- s->search_matches_cap = 0;
- s->search_alloc_failed = 0;
- s->search_used_modseq = 0;
- s->search_max_modseq = 0;
-}
-
-/*
- * Finishes the COPY/MOVE round trip copy_move_dispatch() started. Peeled
- * off session_handle_mbox_result() the same way SEARCH is, since COPY/
- * MOVE's reply shape (a COPYUID response code, optionally an untagged OK
- * plus buffered EXPUNGE/VANISHED for a MOVE) doesn't fit the generic
- * "<cmdname> completed/failed" text FETCH/STORE/EXPUNGE/CLOSE share.
- *
- * Response placement differs between the two, per RFC 9051 SS6.4.7/
- * SS6.4.8/SS7.1: COPY's COPYUID goes in its own TAGGED OK ("This response
- * code is returned in a tagged OK response to the COPY or UID COPY
- * command"); MOVE's goes in an UNTAGGED OK sent before any EXPUNGE/
- * VANISHED ("Servers are also REQUIRED to send the COPYUID response code
- * in an untagged OK before sending EXPUNGE"), followed by every buffered
- * s->move_expunged entry (in original order -- see that field's own
- * comment for why these were buffered instead of written live), then a
- * plain tagged OK with no COPYUID in it.
- *
- * If zero messages matched, RFC 9051 SS6.4.7's own worked example shows
- * a plain "OK No matching messages, so nothing copied" with no COPYUID
- * at all -- COPYUID's uid-set ABNF has no legal empty-set spelling, so
- * omitting the bracket code entirely (rather than emitting "[COPYUID v
- * ]" with nothing after it) is the only spec-consistent choice; this
- * function applies the same omission to a zero-match MOVE by the same
- * reasoning, with no RFC worked example to cite for that half but no
- * more of a legal spelling for an empty MOVE either.
- */
-static void
-session_finish_copy_or_move(struct session *s, struct imsg_mbox_result *res)
-{
- const char *cmdname = s->cmd_is_move ?
- (s->cmd_by_uid ? "UID MOVE" : "MOVE") :
- (s->cmd_by_uid ? "UID COPY" : "COPY");
- uint32_t i;
-
- s->state = SESSION_SELECTED;
-
- if (!res->ok || s->copy_alloc_failed) {
- char text[48];
-
- /*
- * RFC 9051 SS6.4.7/SS6.4.8: destname resolved to a
- * syntactically valid name (BAD would already have been
- * sent otherwise, before any store round trip) but store.c
- * couldn't find it on disk -- TRYCREATE, not a plain
- * failure, same distinction imsg_mbox_appended's own
- * no_such_mailbox field already makes for APPEND.
- */
- if (!s->copy_alloc_failed && res->no_such_mailbox)
- snprintf(text, sizeof(text), "[TRYCREATE] no such "
- "mailbox");
- else
- snprintf(text, sizeof(text), "%s failed", cmdname);
- session_reply(s, s->pending_tag, "NO", text);
- goto cleanup;
- }
-
- /* RFC 7162 SS3.1.2.1: cache the mailbox's post-operation
- * HIGHESTMODSEQ, same as session_handle_mbox_result() already does
- * for STORE/EXPUNGE -- COPY/MOVE both mutate the mailbox (COPY
- * always appends; MOVE both appends and removes) and so both change
- * it too, even though neither is itself one of RFC 7162 SS3.1's six
- * CONDSTORE-enabling commands (no [HIGHESTMODSEQ] is emitted on
- * COPY/MOVE's own tagged/untagged OK anywhere below -- this is
- * purely for session_condstore_enable()'s later cached-value use if
- * CONDSTORE gets enabled afterward). */
- s->mbox_highestmodseq = res->highestmodseq;
-
- /*
- * RFC 9051 SS6.3.13 (IDLE): both COPY and MOVE mutate a mailbox
- * some *other* same-uid session could have selected and be idling
- * on -- COPY always appends at the destination; MOVE does that too
- * and additionally removes s->move_expunged_n message(s) from the
- * source. Before cross-mailbox COPY/MOVE existed (RFC 9051
- * SS6.3.4-SS6.3.6, docs/openimap-storage-backend.md item 10's own
- * follow-up) the destination was always the same mailbox already
- * selected by *this* session, so a peer noticing a COPY's own
- * appended messages could only ever be a peer with that same
- * mailbox open -- still worth waking, but this call was
- * (incorrectly) gated to MOVE only until now, which meant a peer
- * idling on that shared mailbox never got woken by a plain COPY
- * into it. Now that the destination can be genuinely different
- * from anything *this* session has open, notifying unconditionally
- * is the only way a peer with the *destination* mailbox selected
- * ever finds out. session_notify_idle_peers()'s own comment
- * already documents why not filtering by which mailbox each peer
- * has selected is harmless imprecision, not a correctness bug --
- * that reasoning covers this unconditional call the same way.
- * Gated on copy_n > 0 since the zero-match branch below sends no
- * COPYUID/EXPUNGE at all, i.e. neither copied nor moved anything.
- */
- if (s->copy_n > 0)
- session_notify_idle_peers(s);
-
- if (s->copy_n > 0) {
- char srcbuf[2048], destbuf[2048];
- char text[4096 + 64];
- int truncated;
-
- format_seq_list(srcbuf, sizeof(srcbuf), s->copy_src_uids,
- s->copy_n, &truncated);
- if (truncated)
- log_warnx("session %u: COPYUID source list truncated",
- s->id);
- format_seq_list(destbuf, sizeof(destbuf), s->copy_dest_uids,
- s->copy_n, &truncated);
- if (truncated)
- log_warnx("session %u: COPYUID dest list truncated",
- s->id);
-
- if (s->cmd_is_move) {
- snprintf(text, sizeof(text), "OK [COPYUID %u %s %s]",
- res->uidvalidity, srcbuf, destbuf);
- session_untagged(s, text);
-
- for (i = 0; i < s->move_expunged_n; i++)
- session_send_expunge_response(s,
- &s->move_expunged[i]);
-
- session_reply(s, s->pending_tag, "OK", "Done");
- } else {
- snprintf(text, sizeof(text),
- "[COPYUID %u %s %s] %s completed",
- res->uidvalidity, srcbuf, destbuf, cmdname);
- session_reply(s, s->pending_tag, "OK", text);
- }
- } else {
- /* RFC 9051 SS6.4.7's own worked example, verbatim: "S: A005
- * OK No matching messages, so nothing copied" -- see this
- * function's header comment for why MOVE gets the identical
- * text in the zero-match case. */
- session_reply(s, s->pending_tag, "OK",
- "No matching messages, so nothing copied");
- }
-
-cleanup:
- free(s->copy_src_uids);
- s->copy_src_uids = NULL;
- free(s->copy_dest_uids);
- s->copy_dest_uids = NULL;
- s->copy_n = 0;
- s->copy_cap = 0;
- s->copy_alloc_failed = 0;
- free(s->move_expunged);
- s->move_expunged = NULL;
- s->move_expunged_n = 0;
- s->move_expunged_cap = 0;
- s->cmd_is_move = 0;
-}
-
-/*
- * Terminal reply for the FETCH, STORE, EXPUNGE/CLOSE, SEARCH, COPY, MOVE,
- * CREATE, DELETE, or RENAME round trip cmd_fetch()/cmd_store_cmd()/
- * cmd_expunge()/cmd_close()/cmd_search()/cmd_copy()/cmd_move()/cmd_create()/
- * cmd_delete()/cmd_rename() started: store.c sends exactly one IMSG_MBOX_
- * RESULT after the last (zero or more) per-message reply for a given
- * request. SEARCH, COPY/MOVE, and CREATE/DELETE/RENAME are all peeled off
- * first, into session_finish_search()/session_finish_copy_or_move()/
- * session_finish_mbox_op() respectively, since none of their reply shapes
- * fit the generic "<cmdname> completed/failed" text the remaining four
- * share. Which of those four this was is read from s->state (and, for
- * EXPUNGE vs. CLOSE specifically, s->close_after_expunge) before either is
- * overwritten -- purely to pick the right tagged completion text and the
- * right next state; all four otherwise share this handler completely. See
- * the ST_SELECTED/ST_AUTH comments above the dispatch table for why
- * SESSION_FETCHING/SESSION_STORING/SESSION_EXPUNGING/SESSION_SEARCHING/
- * SESSION_COPYING/SESSION_CREATING/SESSION_DELETING/SESSION_RENAMING all
- * have to stay excluded from ST_SELECTED (or ST_AUTH, for the last three)
- * until exactly this point.
- */
-static void
-session_handle_mbox_result(struct session *s, struct imsg_mbox_result *res)
-{
- int was_close = 0, was_storing = 0, was_expunging = 0;
- const char *cmdname;
-
- if (s->state == SESSION_SEARCHING) {
- session_finish_search(s, res);
- return;
- }
- if (s->state == SESSION_COPYING) {
- session_finish_copy_or_move(s, res);
- return;
- }
- if (s->state == SESSION_CREATING || s->state == SESSION_DELETING ||
- s->state == SESSION_RENAMING) {
- /* RFC 9051 SS6.3.4-SS6.3.6 (docs/openimap-storage-backend.md
- * item 10) -- same "peel off before the generic FETCH/
- * STORE/EXPUNGE shape below" pattern as SESSION_SEARCHING/
- * SESSION_COPYING just above, since CREATE/DELETE/RENAME's
- * reply text ("CREATE completed"/"failed", etc.) doesn't fit
- * this function's cmdname table either. */
- session_finish_mbox_op(s, res);
- return;
- }
- if (s->state == SESSION_LISTING) {
- /* RFC 9051 SS6.3.9 -- same peel-off reasoning as SESSION_
- * CREATING/DELETING/RENAMING just above; LIST/LSUB's
- * "completed"/"failed" text is keyed off s->list_is_lsub,
- * not this function's by-state cmdname table. */
- session_finish_list(s, res);
- return;
- }
-
- /*
- * RFC 9051 SS6.4.9 addition: every one of these becomes "UID <CMD>"
- * when s->cmd_by_uid is set (see that field's own comment in struct
- * session for the sourcing on "UID <cmd> completed"/"failed" text) --
- * CLOSE is the one exception, since there is no "UID CLOSE" (UID
- * EXPUNGE's is_close is always 0, enforced by uid_expunge_dispatch()
- * only ever calling session_request_expunge() that way).
- */
- if (s->state == SESSION_STORING) {
- cmdname = s->cmd_by_uid ? "UID STORE" : "STORE";
- was_storing = 1;
- } else if (s->state == SESSION_EXPUNGING) {
- was_close = s->close_after_expunge;
- cmdname = was_close ? "CLOSE" :
- (s->cmd_by_uid ? "UID EXPUNGE" : "EXPUNGE");
- was_expunging = 1;
- } else
- cmdname = s->cmd_by_uid ? "UID FETCH" : "FETCH";
-
- log_debug("session %u: %s done, ok=%d, %u response(s) sent",
- s->id, cmdname, res->ok, res->count);
-
- /*
- * RFC 7162: cache the mailbox's post-operation HIGHESTMODSEQ for
- * session_condstore_enable()'s later use -- only STORE/EXPUNGE (not
- * a plain FETCH, which leaves res->highestmodseq at its zeroed
- * default; see imapd.h's imsg_mbox_result comment) ever change
- * it, so a plain FETCH mustn't blindly overwrite the cached value
- * with that default 0.
- */
- if (was_storing || was_expunging)
- s->mbox_highestmodseq = res->highestmodseq;
-
- /*
- * RFC 9051 SS6.4.1: CLOSE "returns to the authenticated state from
- * the selected state" -- unlike FETCH/STORE/a real EXPUNGE, which
- * all stay in Selected.
- */
- s->state = was_close ? SESSION_AUTHENTICATED : SESSION_SELECTED;
-
- if (!res->ok) {
- /* v1's only failure mode is an index I/O error (see
- * store.c's handle_mbox_fetch()/handle_mbox_store()/
- * handle_mbox_expunge() ok = 0 paths) -- RFC 9051 has no
- * specific response code for this, so a plain NO is the
- * honest answer. */
- char text[32];
-
- free(s->store_modified);
- s->store_modified = NULL;
- s->store_modified_n = 0;
- s->store_modified_cap = 0;
-
- snprintf(text, sizeof(text), "%s failed", cmdname);
- session_reply(s, s->pending_tag, "NO", text);
- return;
- }
-
- /*
- * RFC 7162 SS3.1.3: a STORE that used UNCHANGEDSINCE and had at
- * least one message fail the conditional test gets the MODIFIED
- * response code on its tagged OK -- v1 never has a reason to use
- * the tagged NO form instead (SS3.1.3's Example 11 mixes an
- * UNCHANGEDSINCE failure with expunged messages to get that; v1's
- * STORE simply never visits an expunged message's index line at
- * all -- see handle_mbox_store()'s lo/hi bound against the current
- * idx.nlines -- so that combination can't arise here).
- */
- if (was_storing && s->store_modified_n > 0) {
- char rbuf[2048];
- char text[2048 + 32];
- int truncated;
-
- format_seq_list(rbuf, sizeof(rbuf), s->store_modified,
- s->store_modified_n, &truncated);
- if (truncated)
- log_warnx("session %u: MODIFIED list truncated",
- s->id);
- snprintf(text, sizeof(text), "[MODIFIED %s] Conditional "
- "%s failed for some messages", rbuf, cmdname);
- session_reply(s, s->pending_tag, "OK", text);
-
- free(s->store_modified);
- s->store_modified = NULL;
- s->store_modified_n = 0;
- s->store_modified_cap = 0;
- return;
- }
- free(s->store_modified);
- s->store_modified = NULL;
- s->store_modified_n = 0;
- s->store_modified_cap = 0;
-
- /*
- * RFC 9051 SS6.3.13 (IDLE): a successful EXPUNGE/UID EXPUNGE, or a
- * CLOSE that silently expunged \Deleted messages, may have changed
- * what messages exist -- wake any other same-uid session currently
- * idling on this mailbox so it can push EXISTS/EXPUNGE. Fired
- * unconditionally on was_expunging rather than gated on res->count,
- * since (per the HIGHESTMODSEQ comment just below) store.c only
- * reports a nonzero count for a real, non-silent EXPUNGE -- CLOSE's
- * count is always 0 even when it removed messages, so count can't be
- * used to detect that case. An extra, harmless peer refresh on a
- * no-op EXPUNGE/CLOSE is the tradeoff.
- */
- if (was_expunging)
- session_notify_idle_peers(s);
-
- /*
- * RFC 7162 SS3.2.7: a real EXPUNGE (not CLOSE -- SS3.2.8 explicitly
- * forbids it there, "as this might cause loss of synchronization on
- * the client") that removed at least one message includes
- * HIGHESTMODSEQ in its tagged OK once this session is CONDSTORE-
- * aware. res->count is exact here: store.c only composes
- * IMSG_MBOX_EXPUNGED (which is what increments it) when !silent,
- * i.e. for a real EXPUNGE, never for CLOSE.
- */
- if (was_expunging && !was_close && s->condstore_enabled &&
- res->count > 0) {
- char text[64];
-
- snprintf(text, sizeof(text), "[HIGHESTMODSEQ %llu] %s "
- "completed", (unsigned long long)res->highestmodseq,
- cmdname);
- session_reply(s, s->pending_tag, "OK", text);
- return;
- }
-
- {
- char text[32];
-
- snprintf(text, sizeof(text), "%s completed", cmdname);
- session_reply(s, s->pending_tag, "OK", text);
- }
-}
-
-static void
-session_request_store(struct session *s, struct imsg_auth_result *res)
-{
- struct imsg_store_fork req;
-
- req.session_id = s->id;
- req.uid = res->uid;
- req.gid = res->gid;
- /*
- * Also kept on struct session itself, not just forwarded in this
- * request -- session_notify_idle_peers() (RFC 9051 SS6.3.13) needs
- * to find this session's *other* concurrent sessions later, and
- * this is the only point in the whole connection lifecycle where
- * the authenticated uid is available at all (auth.c doesn't share
- * it any other way, and store.c never sends it back).
- */
- s->uid = res->uid;
- /*
- * res->maildir was resolved by auth.c straight from the credential
- * file's fifth field and was, until this pass, silently dropped
- * here -- imapd.h's imsg_store_fork comment has the full "this
- * was always meant to carry it" citation. Without this, store.c has
- * no way to know which subdirectory of the shared spool_root is
- * this session's own mailbox.
- */
- strlcpy(req.maildir, res->maildir, sizeof(req.maildir));
-
- s->state = SESSION_STORE_PENDING;
-
- if (imsg_compose(&iev_parent.ibuf, IMSG_STORE_FORK, 0, 0, -1,
- &req, sizeof(req)) == -1)
- log_warn("imsg_compose IMSG_STORE_FORK");
- imsgev_add(&iev_parent);
-}
-
-static struct session *
+ imsgev_add(iev); /* re-arm for the next event */
+ (void)fd;
+}
+
+struct session *
session_find(uint32_t id)
{
struct session *s;
return (NULL);
}
-/*
- * Tears down a session fully: notifies its store child (if wired) with
- * IMSG_STORE_SHUTDOWN, closes both fds, unregisters both events, removes
- * from the session table, frees. Closes a small gap surfaced by actually
- * writing this: store.c has handled IMSG_STORE_SHUTDOWN since it was
- * first written ("arrives directly from listener, no round-trip through
- * parent" -- see its header comment), but nothing anywhere ever sent
- * one. Best-effort on the imsg send -- if the store child is already
- * gone or its channel is backed up, log and move on rather than block
- * client teardown on it.
- */
-static void
+/* Best-effort IMSG_STORE_SHUTDOWN to the store child, then closes fds, unregisters events, frees. */
+void
session_teardown(struct session *s)
{
if (s->store_iev != NULL) {
if (s->tls_ctx != NULL) {
int ret = tls_close(s->tls_ctx);
- /*
- * tls_close() closes the underlying fd itself -- confirmed
- * by reading tls_close()'s real implementation
- * (openbsd_source/src/lib/libtls/tls.c) rather than
- * assuming; it's a real, easy-to-get-wrong fact (plenty of
- * TLS libraries in other languages do NOT close the
- * wrapped fd for you). The one case it does NOT close the
- * fd is when the close_notify shutdown itself needs another
- * network round trip (TLS_WANT_POLLIN/POLLOUT) -- not
- * something a forced teardown should wait for, so we close
- * the fd ourselves in exactly that case. Any other return
- * (0 success, or a hard -1 from a failed shutdown(2)/
- * close(2) syscall) means tls_close() already reached its
- * own close(2) call, so closing again here would double-
- * close the fd.
- */
+ /* tls_close() closes the fd itself except when close_notify wants another round trip. */
if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT)
close(s->client_fd);
else if (ret != 0)
close(s->client_fd);
}
- free(s->literal_buf); /* NULL-safe if no APPEND literal was ever
- * in flight when this session was torn
- * down */
- free(s->search_matches); /* NULL-safe if no SEARCH was ever in
- * flight when this session was torn down */
- free(s->vanished_ranges); /* NULL-safe -- RFC 7162 QRESYNC resync
- * accumulator, see struct session's comment */
- free(s->qresync_fetches); /* NULL-safe, same reason */
- free(s->store_modified); /* NULL-safe -- RFC 7162 STORE UNCHANGEDSINCE
- * accumulator, see struct session's comment */
- free(s->pending_header_buf); /* NULL-safe -- see struct session's
- * comment; store.c's ordering contract means
- * this is normally already NULL by the time a
- * FETCH finishes, but a mid-stream teardown
- * (client disconnects between the header and
- * meta imsgs) could leave it set */
- free(s->pending_body_buf); /* NULL-safe, same reasoning as
- * pending_header_buf just above */
- free(s->pending_envelope_buf); /* NULL-safe, same reasoning as
- * pending_header_buf just above */
- free(s->pending_bodystructure_buf); /* NULL-safe, same reasoning as
- * pending_header_buf just above */
+ /* All NULL-safe -- can still be set on a mid-stream teardown (FETCH/SEARCH/etc. in flight). */
+ free(s->literal_buf);
+ free(s->search_matches);
+ free(s->vanished_ranges);
+ free(s->qresync_fetches);
+ free(s->store_modified);
+ free(s->pending_header_buf);
+ free(s->pending_body_buf);
+ free(s->pending_envelope_buf);
+ free(s->pending_bodystructure_buf);
+ /* Any commands pipelined behind the one in flight when this session was torn down. */
+ while (s->cmd_queue_n > 0)
+ free(s->cmd_queue[--s->cmd_queue_n]);
+
TAILQ_REMOVE(&sessions, s, entry);
log_debug("session %u: closed", s->id);
free(s);
blob - 891297a134890265490234e6143f2866aaba7dc8
blob + 4df72d97e7332fa0c20a4bd7bfd9e9ae4b99ce13
--- src/log.c
+++ src/log.c
/*
* Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ * Copyright (c) 2003, 2004 Henning Brauer <henning@openbsd.org>
*
+ * This file's log_init()/log_procinit()/log_setverbose()/log_getverbose()/
+ * vlog()/logit()/log_warn()/log_warnx()/log_info()/log_debug()/fatal()/
+ * fatalx() API is the same daemon-logging idiom Henning Brauer wrote and
+ * that is carried, nearly verbatim, across essentially every privsep
+ * daemon in the OpenBSD base system (smtpd, bgpd, and many others all
+ * credit him in their own log.c). This file's implementation details
+ * (log_procname storage, log_init()'s parameters) are this project's own,
+ * but the API shape and name are that same shared idiom, so his copyright
+ * is carried forward here too -- same rationale as parse.y's copyright
+ * chain.
+ *
* 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.
void
log_procinit(const char *name)
{
+ /* truncation just shortens the prefix; name is argv[0], not attacker-influenced */
if (name != NULL)
(void)strlcpy(log_procname, name, sizeof(log_procname));
}
blob - 7b785502874e0472f92af2fc91ab23b0ad5088de
blob + 0e32858221d95f544b0de3cc47589ed40fdcbd5a
--- src/log.h
+++ src/log.h
/*
* Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ * Copyright (c) 2003, 2004 Henning Brauer <henning@openbsd.org>
*
+ * This header's call signatures were deliberately matched to smtpd's own
+ * log.h (see the comment below) -- that log_init()/fatal()/fatalx()/
+ * log_warn()/log_debug() API is the same shared daemon-logging idiom
+ * Henning Brauer wrote and that smtpd, bgpd, and most other privsep
+ * daemons in OpenBSD base carry his copyright for. Same rationale as
+ * log.c's copyright chain.
+ *
* 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.
blob - c881c86004f3b0309b567ed7aa864a6e6c5ca0bd
blob + 3296ea513e7104ad1d7c66bbd2ab5a8277a1bacf
--- src/main.c
+++ src/main.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * imapd(8) entry point: argv parsing and the role dispatch described in
- * openimap-privsep-design.md's "fork+exec re-invocation" section.
- *
- * Invoked two different ways:
- *
- * - No "-x" flag: this is the original, root-owned invocation (from
- * rc.d or a shell at boot). Falls through to parent_main(), which
- * does the actual fork()+dup2(sp[0], 3)+closefrom(4)+execvp() dance
- * for "listener" and "auth" per that document -- parent_main() is
- * handed argc/argv so it can re-exec children with the same argv[0]
- * plus "-x <role>" appended, exactly as documented.
- *
- * - "-x <role>": this is a re-exec'd child. Its control channel to
- * parent is already sitting on fd 3 (parent dup2'd it there before
- * exec). We skip straight to that role's _main(), which begins by
- * reading fd 3 in a setup_proc()-style loop -- see listener.c/auth.c/
- * store.c.
- */
+/* main.c -- imapd(8) entry point: parses argv, dispatches to the role named by "-x". */
#include <sys/types.h>
#include <err.h>
+#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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.
+ */
+ signal(SIGPIPE, SIG_IGN);
+
while ((ch = getopt(argc, argv, "D:df:Vvx:")) != -1) {
switch (ch) {
case 'V':
- /*
- * Print version and exit immediately -- before
- * log_init()/config_load()/any role dispatch, so
- * this has zero side effects (no syslog handle
- * opened, no config file touched, no privsep
- * children spawned). This is deliberate: it's the
- * RELINK smoke-test command in ../Makefile
- * (RELINK= "./${PROG} -V > /dev/null"), which bsd.
- * prog.mk runs against a freshly-relinked binary
- * before installing it -- it has to be safe to run
- * as any user, with no config file present, with no
- * chroot/privsep setup done, and it has to actually
- * exercise "the binary starts and runs its own
- * code" rather than just being a static string
- * embedded in the executable.
- */
+ /* exits before log_init()/config_load(); must work with no config or privsep setup */
printf("%s %s\n", getprogname(), IMAPD_VERSION);
return (0);
case 'D':
- /*
- * "-D name=value" config-file macro definitions,
- * matching ripd(8)/smtpd(8)'s own "-D" convention --
- * applied to parse.y's symbol table immediately
- * (cmdline_symset()), not deferred, so they're
- * already in place by the time config_load() below
- * calls yyparse(). Only meaningful for role ==
- * PROC_PARENT (only parent ever parses the config
- * file), but harmless to accept unconditionally
- * before the role is known -- getopt(3) runs before
- * the role switch either way.
- */
+ /* "-D name=value" macro, applied to parse.y's symbol table before config_load() */
if (cmdline_symset(optarg) == -1)
fatalx("could not parse macro definition %s",
optarg);
log_init(debug, verbose);
log_procinit(log_procname(role));
- /*
- * Only parent reads imapd.conf. Re-exec'd children (role !=
- * PROC_PARENT) get their slice of it over fd 3 from parent instead
- * -- IMSG_LISTENER_INIT, IMSG_AUTH_INIT, IMSG_STORE_INIT -- not
- * from this "conf" local, which is why listener_main()/auth_main()/
- * store_main() don't take it as a parameter at all. This used to be
- * a flagged gap (an earlier draft passed this same zeroed,
- * never-populated "conf" through to every role, which was silently
- * wrong for auth specifically: auth_main() derived its chroot
- * directory from conf->cred_file, which was always an empty string
- * for a re-exec'd child). Fixed by removing the parameter entirely
- * rather than leaving an unused one that looks like it should work.
- */
+ /* re-exec'd children get their config slice over fd 3 (IMSG_*_INIT), so *_main() takes no conf arg */
switch (role) {
case PROC_PARENT:
- /* parent alone reads and validates the real config file. */
if (config_load(conffile, &conf) == -1)
fatalx("config_load: %s", conffile);
parent_main(conffile, argc, argv, &conf);
blob - 0b114fd2ed455f752f3bfed30d1c2dadb7a15023
blob + d7ddcce302acb1f06be1b9c92ea56a678fbe9360
--- src/parent.c
+++ src/parent.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * parent.c -- privileged supervisor. Implements the mechanisms decided in
- * openimap-privsep-design.md:
- *
- * - fork()+dup2(sp[0], 3)+closefrom(4)+execvp() re-exec for listener and
- * auth at boot ("Peer-wiring handshake" section).
- * - the SETUP_PEER/SETUP_DONE handshake, sourced against smtpd.c's
- * setup_peers()/setup_done()/setup_proc(), wiring listener<->auth at
- * boot.
- * - per-session store spawn ("Per-session store spawn & peer-wiring"),
- * including the deliberate departures from forkmda(): re-exec instead
- * of plain fork, IMSG_STORE_INIT carrying runtime uid/gid, and
- * session-scoped (not daemon-fatal) handling of a handshake timeout --
- * sourced against setup_done()'s real fatal()-on-timeout behavior,
- * which this file deliberately does NOT copy, for the reasons that
- * section spells out.
- *
- * TODO, flagged rather than silently stubbed: TLS cert loading reads the
- * files but does no validation. Does not block the process-management
- * mechanics this file exists to get right. (config_load() itself used to
- * be flagged here too -- it's a real yacc-based imapd.conf parser now,
- * living in parse.y, not this file; see that file's header comment.)
- *
- * API NAMES: checked against the real src/imsg.h ($OpenBSD: imsg.h,v
- * 1.24 2025/06/05, Claudio Jeker et al.) this session. Every imsg/imsgbuf
- * call used in this skeleton -- imsgbuf_init(), imsgbuf_read(),
- * imsgbuf_flush(), imsgbuf_set_maxsize(), imsgbuf_queuelen(), imsg_get(),
- * imsg_get_type(), imsg_get_data(), imsg_get_fd(), imsg_compose(),
- * imsg_free() -- matches the real header's signature and calling
- * convention exactly. imsg_get_ibuf() was guessed with the wrong
- * signature (real: `int imsg_get_ibuf(struct imsg *, struct ibuf *)`,
- * not what earlier comments assumed) but is never actually called
- * anywhere in this codebase, only referenced in stale comments -- no
- * runtime bug resulted. One real gap the real header surfaced:
- * imsgbuf_allow_fdpass(struct imsgbuf *) exists and was NOT being called
- * anywhere, despite every role either sending or receiving fd-passed
- * messages (listening sockets, SETUP_PEER peer fds). Added below and in
- * imsgev.c/listener.c/auth.c/store.c wherever a struct imsgbuf is
- * imsgbuf_init()'d. Same verification applies in listener.c/auth.c/
- * store.c/imsgev.c.
- */
+/* parent.c -- privileged supervisor: process management, SETUP_PEER/SETUP_DONE handshake, privilege drop. */
#include <sys/types.h>
#include <sys/queue.h>
#include "imapd.h"
#include "log.h"
-#define STORE_SETUP_TIMEOUT_SEC 10 /* matches smtpd's setup_done()
- * precedent of 10000ms -- see the
- * design doc's sourcing note on why
- * OpenIMAPD keeps the value but not
- * smtpd's fatal()-on-timeout
- * behavior. */
+#define STORE_SETUP_TIMEOUT_SEC 10 /* smtpd's setup_done() precedent, minus its fatal()-on-timeout */
-#define STORE_CHILD_MAX 64 /* F5 fix: cap concurrent per-session
- * store children so a fork flood cannot
- * exhaust PIDs/fds/memory in the root
- * parent. */
+#define STORE_CHILD_MAX 64 /* caps concurrent store children so a fork flood can't exhaust PIDs/fds */
struct child {
pid_t pid;
TAILQ_ENTRY(child) entry;
};
-/*
- * A per-session store child, from fork through teardown. "pending" is
- * true from fork until its IMSG_SETUP_DONE ack arrives (or the handshake
- * times out); timeout_ev is only armed while pending. Deliberately ONE
- * struct for both states rather than two (an earlier draft of this file
- * had a separate store_pending vs. store_child pair and "promoted"
- * between them by copying struct imsgev -- unsafe, since struct imsgev
- * embeds a struct event that's already registered with libevent at its
- * original address by the time of that copy; event(3) requires the
- * struct not move while registered. One struct, allocated once, closes
- * that bug rather than papering over it.
- */
+/* per-session store child; "pending" until IMSG_SETUP_DONE arrives/times out; one struct, never copied (embeds struct event) */
struct store_child {
uint32_t session_id;
pid_t pid;
struct imsgev iev; /* parent<->this store child */
int pending;
struct event timeout_ev;
- struct imsgev *listener_iev; /* who to notify on
- * failure -- see
- * store_child_fail() */
+ struct imsgev *listener_iev; /* who to notify on failure, see store_child_fail() */
TAILQ_ENTRY(store_child) entry;
};
static struct openimap_config *gconf;
static char progpath[PATH_MAX];
static char **saved_argv;
-static const char *conf_path; /* set once in parent_main(), read by
- * sighup_handler() to re-run
- * config_load() against the same
- * file main() originally loaded --
- * see imapd.h's parent_main() comment */
+static const char *conf_path; /* set once in parent_main(); read by sighup_handler() to reload */
static struct event ev_sighup, ev_sigterm, ev_sigchld;
static void setup_peer_send(struct imsgev *, struct imsgev *, uint32_t);
static void setup_done_send(struct imsgev *);
static void parent_dispatch_child(int, short, void *);
-static void parent_handle_store_fork(uint32_t, uid_t, gid_t, const char *,
- struct imsgev *);
+static void parent_handle_store_fork(uint32_t, uid_t, gid_t, const char *);
static void store_child_dispatch(int, short, void *);
static void store_child_timeout(int, short, void *);
static void store_child_teardown(struct store_child *, int);
static void sigchld_handler(int, short, void *);
static void reap_child(pid_t, int);
-/*
- * config_load() used to live here as a stub that filled in v1 defaults and
- * ignored its "path" argument entirely. The real implementation -- a full
- * imapd.conf grammar, yacc-based, matching the pattern used throughout
- * the OpenBSD base system's own daemons -- now lives in parse.y, per the
- * project's own "follow the OpenSMTPD/httpd/ntpd pattern" design
- * philosophy. See parse.y's header comment for the grammar's sourcing and
- * scope; imapd.h still carries config_load()'s prototype unchanged, so
- * this file's only callers (main.c) needed no changes at all.
- */
-
+/* config_load() -- the full imapd.conf grammar -- lives in parse.y */
__dead void
parent_main(const char *conffile, int argc, char *argv[],
struct openimap_config *conf)
int cleartext_fds[LISTENER_MAX_ADDRS], tls_fds[LISTENER_MAX_ADDRS];
int n_cleartext, n_tls, i;
- (void)argc; /* saved_argv (argv itself) is what fork_child()/
- * parent_handle_store_fork() actually walk when
- * building a child's re-exec argv -- see both for
- * why: they scan for a NUL terminator via
- * saved_argv[i] != NULL rather than using argc. */
+ (void)argc; /* fork_child()/parent_handle_store_fork() walk saved_argv, scanning for a NULL terminator */
if (geteuid() != 0)
fatalx("parent must start as root");
event_init();
- /*
- * Boot-time children: listener and auth only. store is NOT started
- * here -- see the "Per-session store spawn" section of
- * openimap-privsep-design.md. This corrects an earlier draft of
- * that document, which originally showed store as a fourth
- * boot-time process.
- */
+ /* boot-time children: listener and auth only -- store is spawned per-session, see parent_handle_store_fork() */
fork_child(PROC_LISTENER, &iev_listener, parent_dispatch_child);
fork_child(PROC_AUTH, &iev_auth, parent_dispatch_child);
- /*
- * IMSG_AUTH_INIT before the peer handshake, not after (unlike
- * listener's INIT below) -- auth_main() derives its chroot
- * directory from cred_file, so it needs this message before it can
- * even chroot(), let alone finish the handshake. Mirrors
- * IMSG_STORE_INIT's precedent of arriving before anything else on
- * that child's fd 3. auth.c reads this first, matching the order
- * sent here.
- */
+ /* IMSG_AUTH_INIT before the peer handshake -- auth_main() derives its chroot dir from cred_file */
send_auth_init(iev_auth, conf);
- /* IMSG_SETUP_PEER / IMSG_SETUP_DONE, sourced against smtpd.c's
- * setup_peers()/setup_done() -- see design doc. Only listener<->auth
- * is wired at boot. */
+ /* IMSG_SETUP_PEER / IMSG_SETUP_DONE, sourced from smtpd.c's setup_peers()/setup_done(); listener<->auth only */
setup_peer_send(iev_listener, iev_auth, 0);
setup_done_send(iev_listener);
setup_done_send(iev_auth);
- /*
- * send_listener_sockets() fd-passes cleartext_fds[]/tls_fds[] to
- * listener via imsg_compose() (SCM_RIGHTS over the AF_UNIX
- * socketpair, not a local dup(2)) -- our own copies are closed right
- * after, listener now owns the only live references.
- * IMSG_LISTENER_INIT rides along with these post-handshake boot
- * extras, carrying n_cleartext/n_tls so listener's synchronous
- * drain loop knows how many IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS
- * messages to expect (1 each normally, 2 each for "listen on *") --
- * that loop tolerates any arrival order among these messages (see
- * listener.c), so sending init first here is for readability, not
- * correctness.
- */
+ /* fd-passes the listen sockets via SCM_RIGHTS; our own copies closed right after */
send_listener_init(iev_listener, conf, n_cleartext, n_tls);
send_listener_sockets(iev_listener, cleartext_fds, n_cleartext,
tls_fds, n_tls);
signal_add(&ev_sighup, NULL);
signal_add(&ev_sigterm, NULL);
signal_add(&ev_sigchld, NULL);
- signal(SIGPIPE, SIG_IGN);
+ /* SIGPIPE is ignored process-wide in main.c, before fork_child() -- too late here to reach listener/auth, which are already spawned above */
- /*
- * pledge, resolved in openimap-privsep-design.md: proc/exec/sendfd
- * stay for the process lifetime (not narrowed post-boot) because
- * store is fork-per-session -- parent forks continuously, not just
- * at startup. See that document's item 5 for why an earlier draft
- * of this decision was wrong.
- */
+ /* proc/exec/sendfd stay for the process lifetime, not narrowed post-boot, since store is fork-per-session */
#ifdef __OpenBSD__
if (pledge("stdio rpath inet proc exec sendfd", NULL) == -1)
fatal("pledge");
fatalx("parent: exited event loop");
}
-/*
- * Re-exec mechanism, sourced against smtpd.c's start_child(): parent
- * creates a socketpair, fork()s, the child dup2()s its end onto fd 3,
- * closefrom(4)s, and execvp()s argv[0] plus "-x <role>". Returns the
- * child's pid; *ievp is initialized in place on a freshly allocated
- * struct child, never copied afterward -- see the struct store_child
- * comment above for why that matters (a struct imsgev must not move once
- * imsgev_init() has registered its struct event with libevent).
- *
- * Only used for the two boot-time children (listener, auth). Per-session
- * store children go through parent_handle_store_fork() below instead,
- * which duplicates a little of this function's fork/exec body rather
- * than threading an opaque "child type" through one shared path -- boot
- * children and per-session store children have different enough
- * lifecycles (fixed pair-wiring vs. a dynamic session-keyed table with a
- * timeout) that forcing one function to cover both got harder to read
- * than two similar ones.
- */
+/* re-exec mechanism (smtpd.c's start_child()): socketpair/fork/dup2 onto fd 3/closefrom/execvp -x <role> */
static pid_t
fork_child(enum openimap_proc_type type, struct imsgev **ievp,
void (*handler)(int, short, void *))
n = 0;
nargv[n++] = progpath;
nargv[n++] = "-x";
- nargv[n++] = (char *)log_procname(type);
+ /* log_procname() returns const char *; execvp(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. Routed through
+ * uintptr_t, not a direct (char *) cast, matching the real
+ * precedent for this exact situation (a const process-title
+ * string spliced into an execvp() argv[]) in OpenBSD base's
+ * own privsep proc.c, shared by relayd/vmd/iked/snmpd (e.g.
+ * usr.sbin/relayd/proc.c's "nargv[proc_i] =
+ * (char *)(uintptr_t)p->p_title;") -- confirmed directly
+ * against that source, not assumed. Verified with gcc that
+ * the uintptr_t hop (unlike a direct (char *) cast) does not
+ * trigger -Wcast-qual, since the diagnostic only tracks
+ * qualifier loss across a direct pointer-to-pointer cast. */
+ nargv[n++] = (char *)(uintptr_t)log_procname(type);
for (i = 1; saved_argv[i] != NULL && n < 13; i++) {
- /* skip a pre-existing -x <role> from our own argv,
- * everything else (e.g. -d/-v) passes through */
+ /* skip a pre-existing -x <role> from our own argv, everything else passes through */
if (strcmp(saved_argv[i], "-x") == 0) {
i++;
continue;
fatal("calloc");
c->pid = pid;
c->type = type;
- /*
- * Real bug caught via gdb backtrace + core dump on premio (first
- * real-hardware run to ever exercise this path -- parent_dispatch_
- * child() is only invoked post-boot, and nothing sent listener/auth
- * anything post-boot until this session's first successful
- * AUTHENTICATE triggered the first-ever IMSG_STORE_FORK): passing
- * "c" here instead of NULL meant iev->data (and therefore the arg
- * libevent hands back to parent_dispatch_child()) was the enclosing
- * struct child*, not &c->iev. Every dispatch handler in this
- * codebase -- parent_dispatch_child() included, unlike
- * store_child_dispatch(), which deliberately takes a struct
- * store_child* and is the one caller that correctly passes its
- * enclosing struct -- does `struct imsgev *iev = arg;` and expects
- * arg to already be &iev. With arg == c (8 bytes before &c->iev on
- * this build), iev->ibuf's fields were read shifted by the width of
- * struct child's pid+type header, producing exactly the implausible
- * fd/pointer values the core dump showed (confirmed by comparing
- * the crashing arg against a live `print iev_listener` in the same
- * gdb session: arg was iev_listener - 8, i.e. c itself). NULL here
- * takes imsgev_init()'s own fallback (iev->data = data != NULL ?
- * data : iev) to self-reference &c->iev instead -- the same pattern
- * every other imsgev_init() call site in the tree already uses
- * (store.c's, listener.c's x3, auth.c's).
- */
+ /* NULL here, not "c" -- handlers expect arg == &iev; imsgev_init()'s own fallback self-references &c->iev */
imsgev_init(&c->iev, sp[0], handler, NULL);
TAILQ_INSERT_TAIL(&children, c, entry);
return (pid);
}
-/*
- * IMSG_SETUP_PEER: parent opens a fresh socketpair and fd-passes one end
- * to each of "a" and "b" over their existing fd-3 channels. Sourced
- * against smtpd.c's setup_peers() (imsg_compose(..., IMSG_SETUP_PEER,
- * b->proc, b->pid, sp[0], ...)) -- that quoted call shape is exactly
- * where "id" slots into imsg_compose()'s second argument the same way
- * "b->proc" does there; this project's "id" is a uint32_t session_id
- * instead of smtpd's proc type, but the mechanism (the imsg header's
- * generic 32-bit id field, read back via imsg_get_id()) is the same one.
- *
- * "id" is 0 for the one boot-time call (listener<->auth, no session
- * concept yet) and the session_id for every per-session store call --
- * see parent_handle_store_fork() below. This is the fix for the gap
- * flagged in an earlier pass: listener previously had no way to tell
- * which in-flight store handshake a given IMSG_SETUP_PEER belonged to,
- * since this always sent peerid/id 0. listener.c's IMSG_SETUP_PEER
- * handler now reads it back with imsg_get_id() to find the right
- * session.
- */
+/* IMSG_SETUP_PEER (smtpd.c's setup_peers()): fresh socketpair, fd-passed to "a"/"b"; id is 0 at boot, else session_id */
static void
setup_peer_send(struct imsgev *a, struct imsgev *b, uint32_t id)
{
fatal("imsgbuf_flush");
}
-/*
- * IMSG_SETUP_DONE: tell a child no more IMSG_SETUP_PEER messages are
- * coming, then block for its ack. Sourced against smtpd.c's setup_done()
- * -- including its 10000ms blocking wait -- but see the design doc for
- * why this file does NOT copy setup_done()'s fatal()-on-timeout for the
- * *per-session* store handshake (parent_handle_store_setup_done() below
- * uses an event-driven timeout instead, precisely because that handshake
- * happens continuously at runtime, not once at boot like this one).
- * This blocking version is only used for the boot-time listener/auth
- * wiring, where blocking the whole daemon during startup is fine -- there
- * is no other in-flight work yet to protect.
- */
+/* IMSG_SETUP_DONE: tell a child no more peers are coming, block for its ack (smtpd.c's setup_done()); boot-time only */
static void
setup_done_send(struct imsgev *iev)
{
if (imsgbuf_flush(&iev->ibuf) == -1)
fatal("imsgbuf_flush");
- /*
- * imsg_get() before imsgbuf_read(): a real deadlock, caught via
- * ktrace(1) on the first real-OpenBSD run, not this sandbox --
- * see imsgev.c's setup_recv_one_peer()/setup_recv_done_and_ack()
- * header comments for the full citation. setup_peer_send() and
- * this function's own IMSG_SETUP_DONE send happen back to back
- * on the same fd with no wait in between, so the kernel can (and,
- * on the live hang, did) coalesce both into one readable chunk on
- * the child's end -- meaning the child's ack can arrive already
- * sitting in *our own* ibuf here, buffered from this same
- * function's earlier reads in the multi-child boot sequence.
- * Blindly calling imsgbuf_read() first would issue a real blocking
- * syscall waiting for bytes that were never coming.
- */
+ /* imsg_get() before imsgbuf_read(): kernel may coalesce the setup_peer_send() reply with this ack already */
for (;;) {
if ((n = imsg_get(&iev->ibuf, &imsg)) == -1)
fatal("imsg_get");
imsg_free(&imsg);
}
-/* dispatch for the boot-time listener/auth channels once we're in the
- * main event loop. TODO: real handling of IMSG_AUTH_* passthrough isn't
- * needed here at all -- listener and auth talk to each other directly
- * over the peer channel wired above, not through parent. The only
- * message parent expects on these channels post-boot is
- * IMSG_STORE_FORK, from listener. */
+/* dispatch for boot-time listener/auth channels post-boot; task #321: IMSG_AUTH_CRED arrives here from auth, triggering a store spawn directly (no longer relayed via listener's old IMSG_STORE_FORK request) */
static void
parent_dispatch_child(int fd, short event, void *arg)
{
struct imsg imsg;
ssize_t n;
- /*
- * EV_WRITE: real bug caught on first real-hardware run -- see
- * auth.c's auth_dispatch() header comment for the full citation
- * against imsg_init(3)'s own EXAMPLES section. imsg_compose() only
- * queues; imsgbuf_write() is what actually puts bytes on the wire,
- * and nothing here ever called it.
- */
+ /* EV_WRITE: imsg_compose() only queues -- imsgbuf_write() puts bytes on the wire */
if (event & EV_WRITE) {
if (imsgbuf_write(&iev->ibuf) == -1)
fatal("imsgbuf_write");
break;
switch (imsg_get_type(&imsg)) {
- case IMSG_STORE_FORK: {
- struct imsg_store_fork req;
+ case IMSG_AUTH_CRED: {
+ struct imsg_auth_cred req;
if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
- log_warnx("bad IMSG_STORE_FORK");
+ log_warnx("bad IMSG_AUTH_CRED");
break;
}
parent_handle_store_fork(req.session_id, req.uid,
- req.gid, req.maildir, iev);
+ req.gid, req.maildir);
break;
}
default:
}
imsg_free(&imsg);
}
- /*
- * Real bug caught on first real-hardware run, same shape and same
- * fix as auth.c's auth_dispatch() and store.c's store_dispatch()
- * (see auth_dispatch()'s header comment for the full citation
- * against imsg_init(3)): nothing re-armed this channel when the
- * for loop above found nothing to process, which is exactly what
- * happens on a pure EV_WRITE firing now that EV_WRITE is actually
- * handled (just above). Unconditional call closes that gap.
- */
+ /* unconditional re-arm: without it, a pure EV_WRITE firing would let this channel's event lapse */
imsgev_add(iev);
(void)fd;
}
-/*
- * F1 helper: reject a maildir that could escape the intended spool
- * subtree. Rejects NULL/empty, absolute paths, and any path containing
- * a "." or ".." component. Used before spawning a store child because
- * the maildir is relayed through the untrusted, network-facing listener.
- */
+/* rejects a maildir that could escape the spool subtree: NULL/empty, absolute paths, "." / ".." components */
static int
maildir_path_is_safe(const char *p)
{
return (1);
}
-/*
- * Per-session store spawn, triggered by listener's IMSG_STORE_FORK.
- * Mirrors the boot-time re-exec + SETUP_PEER mechanism above, with the
- * departures documented in openimap-privsep-design.md: re-exec (not
- * forkmda()'s plain fork), an IMSG_STORE_INIT carrying this session's
- * uid/gid before the child privilege-drops, and an event-driven timeout
- * instead of setup_done()'s blocking-and-fatal() behavior.
- */
+/* task #321: per-session store spawn triggered directly by auth's IMSG_AUTH_CRED; mirrors fork_child() but with a timeout, not fatal() */
static void
parent_handle_store_fork(uint32_t session_id, uid_t uid, gid_t gid,
- const char *maildir, struct imsgev *listener_iev)
+ const char *maildir)
{
struct store_child *sc;
int pair[2];
unsigned int nchildren = 0;
/*
- * F1 fix (defense in depth): uid/gid/maildir arrived relayed
- * through the untrusted, network-facing listener. Never grant a
- * store child uid/gid 0, and never accept an unsafe maildir. The
- * complete fix is for the trusted auth process to vend these to the
- * parent directly, keyed by session_id; this closes the escalation
- * in the meantime.
+ * task #321: uid/gid/maildir now arrive directly from auth, the
+ * process that actually resolved them from the credentials file --
+ * not relayed through the network-facing listener. These checks stay
+ * as defense in depth regardless: they guard against a misconfigured
+ * credentials file (e.g. a "root:...:0:0:/" line) or a future bug in
+ * auth, not just a hostile sender.
*/
if (uid == 0 || gid == 0) {
- log_warnx("refusing IMSG_STORE_FORK: privileged uid=%u gid=%u "
+ log_warnx("refusing store spawn: privileged uid=%u gid=%u "
"for session %u", (unsigned)uid, (unsigned)gid,
session_id);
goto fail;
}
if (!maildir_path_is_safe(maildir)) {
- log_warnx("refusing IMSG_STORE_FORK: unsafe maildir for "
+ log_warnx("refusing store spawn: unsafe maildir for "
"session %u", session_id);
goto fail;
}
TAILQ_FOREACH(it, &store_children, entry)
nchildren++;
if (nchildren >= STORE_CHILD_MAX) {
- log_warnx("refusing IMSG_STORE_FORK: %u store children active "
+ log_warnx("refusing store spawn: %u store children active "
"(session %u)", nchildren, session_id);
goto fail;
}
close(pair[1]);
- /*
- * Allocated once, in place -- sc->iev is never copied afterward.
- * See the struct store_child comment above for why that matters.
- */
+ /* allocated once, in place -- sc->iev is never copied afterward (same reason as struct store_child) */
sc = calloc(1, sizeof(*sc));
if (sc == NULL)
fatal("calloc");
sc->session_id = session_id;
sc->pid = pid;
sc->pending = 1;
- sc->listener_iev = listener_iev;
+ sc->listener_iev = iev_listener;
imsgev_init(&sc->iev, pair[0], store_child_dispatch, sc);
- /* IMSG_STORE_INIT: the one message a per-session store child needs
- * that a boot-time child doesn't -- its privilege target is a
- * runtime value, not fixed by the role flag. See the design doc's
- * "Departure #2". */
+ /* IMSG_STORE_INIT: the one message a store child needs that boot-time children don't -- runtime privilege target */
init_payload.session_id = session_id;
init_payload.uid = uid;
init_payload.gid = gid;
- (void)strlcpy(init_payload.spool_root, gconf->spool_root,
- sizeof(init_payload.spool_root));
- /* maildir came from listener's IMSG_STORE_FORK, which got it from
- * auth's IMSG_AUTH_RESULT, which resolved it from the credential
- * file's fifth field -- see imapd.h's imsg_store_fork comment.
- * Passed through verbatim; parent itself never interprets it. */
- (void)strlcpy(init_payload.maildir, maildir,
- sizeof(init_payload.maildir));
+ if (strlcpy(init_payload.spool_root, gconf->spool_root,
+ sizeof(init_payload.spool_root)) >=
+ sizeof(init_payload.spool_root)) {
+ log_warnx("session %u: spool_root truncated spawning store "
+ "child -- refusing to wire a session to a possibly "
+ "wrong spool root", session_id);
+ goto fail_kill;
+ }
+ /* task #321: maildir came directly from auth's IMSG_AUTH_CRED, passed through verbatim */
+ if (strlcpy(init_payload.maildir, maildir,
+ sizeof(init_payload.maildir)) >= sizeof(init_payload.maildir)) {
+ log_warnx("session %u: maildir truncated spawning store "
+ "child -- refusing to wire a session to a possibly "
+ "wrong mailbox", session_id);
+ goto fail_kill;
+ }
init_payload.bodystructure_read_max = gconf->bodystructure_read_max;
if (imsg_compose(&sc->iev.ibuf, IMSG_STORE_INIT, 0, 0, -1,
&init_payload, sizeof(init_payload)) == -1) {
goto fail_kill;
}
- /* session_id threaded through as the imsg "id" field -- see
- * setup_peer_send()'s header comment -- so listener.c's
- * IMSG_SETUP_PEER handler can tell which in-flight store handshake
- * this new peer fd belongs to. */
- setup_peer_send(&sc->iev, listener_iev, session_id);
+ /* session_id rides as the imsg "id" field so listener.c can tell which in-flight handshake this fd belongs to */
+ setup_peer_send(&sc->iev, iev_listener, session_id);
if (imsg_compose(&sc->iev.ibuf, IMSG_SETUP_DONE, 0, 0, -1, NULL, 0)
== -1) {
fail_kill:
kill(pid, SIGKILL);
event_del(&sc->iev.ev);
- close(sc->iev.ibuf.fd); /* F11 fix: match store_child_teardown();
- * without this each setup failure leaks the
- * socketpair fd in the root parent. */
+ close(sc->iev.ibuf.fd); /* match store_child_teardown(); else a setup failure leaks the fd */
free(sc);
fail:
- /* Per the design doc's resolution: fail only this one session, not
- * the daemon. Reusing IMSG_STORE_FORK as the failure-reply type
- * (listener never otherwise receives that type from parent), with
- * uid/gid zeroed -- listener must key off "this came from parent"
- * plus session_id to recognize a failure reply, not the uid/gid
- * fields, which are meaningless here. memset() first now that the
- * struct also carries maildir -- otherwise that field would send
- * whatever garbage happened to be on the stack rather than an
- * empty string; harmless over a local imsg channel either way, but
- * sloppy, and listener.c doesn't read it on this path regardless. */
+ /* fail only this one session: IMSG_STORE_FORK is now solely this failure-reply type, to listener */
memset(&fail_payload, 0, sizeof(fail_payload));
fail_payload.session_id = session_id;
- if (imsg_compose(&listener_iev->ibuf, IMSG_STORE_FORK, 0, 0, -1,
+ if (imsg_compose(&iev_listener->ibuf, IMSG_STORE_FORK, 0, 0, -1,
&fail_payload, sizeof(fail_payload)) == -1)
log_warn("imsg_compose IMSG_STORE_FORK (failure reply)");
- imsgev_add(listener_iev);
+ imsgev_add(iev_listener);
}
-/*
- * Real bug caught while wiring listener.c's SELECT round trip (same root
- * cause, three places -- see listener_dispatch_auth()'s header comment
- * there for the full citation against the real event.h): imsgev_init()
- * registers plain EV_READ, not EV_PERSIST, and this function never
- * re-armed sc->iev's own read side after servicing it. Not actively
- * harmful yet (store.c never sends parent anything after its boot-time
- * IMSG_SETUP_DONE), but it's the identical latent gap, so fixed the same
- * way: unconditional imsgev_add() at the end, reached only when sc wasn't
- * just torn down by store_child_fail() (both call sites return
- * immediately after calling it).
- */
+/* imsgev_init() is EV_READ not EV_PERSIST; unconditional imsgev_add() at the end re-arms unless torn down first */
static void
store_child_dispatch(int fd, short event, void *arg)
{
struct imsg imsg;
ssize_t n;
- /*
- * EV_WRITE: same real bug as this file's parent_dispatch_child() --
- * see auth.c's auth_dispatch() header comment for the full citation
- * against imsg_init(3). imsg_compose() only queues; without an
- * actual imsgbuf_write() on a writable fd, nothing this process
- * sends a store child (IMSG_STORE_INIT, IMSG_SETUP_PEER) would ever
- * really leave the socket.
- */
+ /* EV_WRITE: same as parent_dispatch_child() -- imsg_compose() only queues */
if (event & EV_WRITE) {
if (imsgbuf_write(&sc->iev.ibuf) == -1) {
store_child_fail(sc);
store_child_fail(sc);
}
-/*
- * Shared teardown for a store_child, whether it's still alive (needs
- * killing) or was reaped by SIGCHLD already (do not kill(2) a pid that's
- * already exited -- harmless on most systems but sloppy, and this project
- * would rather be explicit). If it never finished setup (sc->pending),
- * tells listener_iev the fork failed, reusing IMSG_STORE_FORK as the
- * failure-reply type -- see parent_handle_store_fork()'s "fail:" path for
- * the matching send on earlier-failure cases (socketpair/fork failure,
- * before a store_child even exists) that never reach this function.
- */
+/* shared teardown for a store_child, alive or already SIGCHLD-reaped; if sc->pending, tells listener the fork failed */
static void
store_child_teardown(struct store_child *sc, int already_dead)
{
store_child_teardown(sc, 0);
}
-/*
- * Binds, sets SO_REUSEADDR, and listen(2)s one socket of the given family
- * against sa/salen -- the common tail end of every case in
- * bind_listen_socket() below, factored out rather than repeated three
- * times. listen(2)'s backlog of 16 is unchanged from before this pass;
- * see README.skeleton's own note on that being a known, minor,
- * accepted v1 limitation, not something this pass touches.
- */
+/* binds, sets SO_REUSEADDR, and listen(2)s one socket -- the common tail end of every bind_listen_socket() case */
static int
bind_one(int family, const struct sockaddr *sa, socklen_t salen,
uint16_t port)
return (fd);
}
-/*
- * Resolves and binds "addr" for "port", filling fds[] and returning how
- * many it filled (1, or LISTENER_MAX_ADDRS for "*" -- see that macro's
- * comment in imapd.h for the full "why two sockets, not one dual-mapped
- * one" sourcing). "addr" is deliberately restricted to "*" or a literal
- * IPv4/IPv6 address -- no getaddrinfo(3) hostname resolution -- per this
- * pass's scoped decision: a personal single-box mail server has no real
- * need for "listen on" to carry a DNS dependency, and parse.y's own
- * grammar action rejects anything else before config_load() ever returns,
- * so reaching the fatalx() below at runtime would mean parse.y's check and
- * this one have drifted out of sync with each other, not a bad config
- * file reaching this far.
- */
+/* resolves+binds "addr" for "port"; deliberately "*" or a literal IPv4/IPv6 address only, no getaddrinfo(3) DNS */
static int
bind_listen_socket(const char *addr, uint16_t port, int fds[LISTENER_MAX_ADDRS])
{
fatal("imsgbuf_flush");
}
-/*
- * Reads and sends both the TLS certificate AND the private key, as two
- * separate IMSG_TLS_CERT / IMSG_TLS_KEY messages. Real gap fixed this
- * pass: this function used to be named send_tls_cert() (singular) and
- * only ever read+sent conf->tls_cert_file -- listener.c's TLS server
- * context needs both (tls_config_set_keypair_mem(), sourced against
- * httpd's server_tls_init() -- see listener.c), so without the key, real
- * TLS could never have worked no matter what listener.c did with the
- * cert alone.
- *
- * Two more real bugs fixed this pass, both found testing against smtpd(8)
- * on the same box (premio) rather than guessed at:
- *
- * 1. The private key was never permission-checked. Found because
- * /etc/ssl/private/openimap.key sat at mode 0644 (world-readable)
- * this whole time -- this daemon never noticed or complained, while
- * smtpd's load_pki_keys() refused to even start with the same file
- * ("insecure permissions: must be at most rwxr-----"). Added the
- * same check smtpd's ssl_load_key() does (openbsd_source/src/
- * usr.sbin/smtpd/ssl.c:132-145): reject unless owned by uid 0 and
- * mode is no more permissive than rwxr----- (0740). The cert file
- * gets no such check -- it's public by definition, same as smtpd
- * only permission-checks the key, never the cert.
- *
- * 2. Both the old fopen()-failure paths and (without care) a new
- * permission-check failure would have `return`ed early, sending
- * neither IMSG_TLS_CERT nor IMSG_TLS_KEY for that failed file. But
- * listener.c's boot loop (its "while (!got_sockets || !got_cert ||
- * !got_key || !got_init)" drain, see listener_main()) blocks until
- * it receives exactly one of each message -- parent_main() never
- * retries after send_tls_certs() returns, so a missing/unreadable/
- * insecurely-permissioned cert or key used to hang listener at boot
- * forever instead of degrading to "no TLS", which is what listener's
- * own cert_len==0/key_len==0 handling (line ~1164) is designed to
- * do. Fixed by always sending both messages, with a zero-length
- * payload standing in for "unusable" on any failure, so listener's
- * existing graceful-degrade path is what actually runs.
- *
- * TODO: reads cert/key files but does no further validation (expiry,
- * matching pubkey, etc.) -- libtls's own loading functions should
- * probably do this work instead of hand-rolled file reads. TODO: each
- * read is capped at sizeof(buf) (8192) and sent as a single imsg, itself
- * capped at MAX_IMSGSIZE (16384) -- fine for a single leaf cert/key pair
- * (typically 1-2KB each PEM-encoded), but there's no chunking for a
- * certificate chain that exceeded either limit. Flagged, not solved --
- * v1 doesn't need chain support per the design docs' current scope.
- */
+/* sends IMSG_TLS_CERT/IMSG_TLS_KEY, always both even on failure (zero-length = unusable), so listener never hangs */
static void
send_tls_certs(struct imsgev *iev, struct openimap_config *conf)
{
size_t n;
struct stat st;
- /* Cert is public -- existence/readability is all that matters. */
+ /* cert is public -- existence/readability is all that matters */
n = 0;
if ((fp = fopen(conf->tls_cert_file, "r")) == NULL) {
log_warn("fopen %s", conf->tls_cert_file);
if (imsgbuf_flush(&iev->ibuf) == -1)
fatal("imsgbuf_flush");
- /*
- * Key is private material: same ownership/mode check smtpd applies
- * (see this function's header comment), and n stays 0 -- sending an
- * empty IMSG_TLS_KEY -- on any failure rather than returning early.
- */
+ /* key is private material, permission-checked (uid 0, mode <= 0740, per smtpd's ssl_load_key()) */
n = 0;
if ((fp = fopen(conf->tls_key_file, "r")) == NULL) {
log_warn("fopen %s", conf->tls_key_file);
fclose(fp);
}
- /*
- * imsg_compose() copies buf's contents into the imsgbuf's own
- * internal queue immediately (see imsg.h -- it takes a `const
- * void *`/length pair, not ownership of buf itself), so it's safe
- * to scrub our own stack copy of the key material right after this
- * call returns, before imsgbuf_flush() actually writes the queued
- * bytes to the socket.
- */
+ /* imsg_compose() copies buf immediately, so it's safe to scrub our stack copy right after this call */
if (imsg_compose(&iev->ibuf, IMSG_TLS_KEY, 0, 0, -1, buf, n) == -1) {
explicit_bzero(buf, sizeof(buf));
fatal("imsg_compose IMSG_TLS_KEY");
fatal("imsgbuf_flush");
}
-/*
- * IMSG_LISTENER_INIT: listener's slice of config. Used for a startup log
- * line as before, but n_cleartext_addrs/n_tls_addrs are load-bearing now
- * -- listener's boot-time drain loop needs them to know how many
- * IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS messages to wait for (see that
- * struct's comment in imapd.h). The listening sockets themselves still
- * always arrive as already-bound fds via send_listener_sockets(), never
- * reconstructed from listen_addr here.
- */
+/* IMSG_LISTENER_INIT: n_cleartext_addrs/n_tls_addrs tell listener's boot-time drain loop how many sockets to expect */
static void
send_listener_init(struct imsgev *iev, struct openimap_config *conf,
int n_cleartext, int n_tls)
struct imsg_listener_init init;
memset(&init, 0, sizeof(init));
- (void)strlcpy(init.listen_addr, conf->listen_addr,
- sizeof(init.listen_addr));
+ if (strlcpy(init.listen_addr, conf->listen_addr,
+ sizeof(init.listen_addr)) >= sizeof(init.listen_addr))
+ fatalx("listen_addr truncated sending IMSG_LISTENER_INIT -- "
+ "config value too long for the wire struct's field");
init.port_cleartext = conf->port_cleartext;
init.port_implicit_tls = conf->port_implicit_tls;
init.n_cleartext_addrs = (uint8_t)n_cleartext;
fatal("imsgbuf_flush");
}
-/*
- * IMSG_AUTH_INIT: auth's slice of config -- just cred_file, the one
- * field auth_main() actually needs (to derive its chroot directory and,
- * post-chroot, the unveil() path). Not optional polish -- see this
- * struct's comment in imapd.h for the bug this closes.
- */
+/* IMSG_AUTH_INIT: auth's slice of config -- just cred_file, to derive its chroot dir and unveil() path */
static void
send_auth_init(struct imsgev *iev, struct openimap_config *conf)
{
struct imsg_auth_init init;
memset(&init, 0, sizeof(init));
- (void)strlcpy(init.cred_file, conf->cred_file, sizeof(init.cred_file));
+ if (strlcpy(init.cred_file, conf->cred_file, sizeof(init.cred_file))
+ >= sizeof(init.cred_file))
+ fatalx("cred_file truncated sending IMSG_AUTH_INIT -- config "
+ "value too long for the wire struct's field");
if (imsg_compose(&iev->ibuf, IMSG_AUTH_INIT, 0, 0, -1,
&init, sizeof(init)) == -1)
fatal("imsgbuf_flush");
}
-/*
- * SIGHUP: re-read imapd.conf and adopt what's safely reloadable, matching
- * httpd(8)'s own documented behavior (man.openbsd.org/httpd.8: "httpd
- * rereads its configuration file when it receives SIGHUP") -- the model
- * this project follows throughout for "listen on" syntax, so it's the
- * natural precedent for reload semantics too. Unlike httpd, there's no
- * SIGUSR1/log-file-reopen counterpart here: this daemon logs to syslog(3)
- * only (see log.c), same as smtpd(8) (man.openbsd.org/smtpd.8: no log-file
- * FILES entry at all, just stderr under -d or syslogd(8) otherwise) --
- * httpd's SIGUSR1 exists specifically to reopen its access.log/error.log
- * after newsyslog(8) rotation, which doesn't apply here.
- *
- * Not everything in imapd.conf can be safely swapped into a *running*
- * daemon:
- *
- * - listen_addr/port_cleartext/port_implicit_tls: the listening sockets
- * are already bound and fd-passed to listener at boot (bind_listen_
- * socket() above, called once from parent_main() before the privilege
- * drop). Changing these live would mean rebinding new sockets and
- * redoing the whole IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS fd-passing
- * handshake listener's boot-time drain loop expects exactly once --
- * not attempted here. A real change to these three fields requires a
- * restart, same as smtpd/httpd expect for their own listen directives.
- *
- * - cred_file: auth_main() chroot(2)s into this file's dirname once, at
- * boot (see auth.c), before ever reading IMSG_AUTH_REQUEST. There is no
- * way to hand auth a new cred_file living outside that chroot without
- * re-chrooting, which is impossible for an already-running process.
- * Same "requires a restart" treatment.
- *
- * Both of the above are detected and logged as a warning -- config_load()
- * itself always succeeds against a file that merely changed one of these,
- * so silently ignoring the new values (rather than pretending they took
- * effect) is what keeps the running daemon's actual behavior truthful.
- *
- * spool_root and bodystructure_read_max are trivially safe: both are read
- * fresh out of *gconf by parent_handle_store_fork() every time a session
- * authenticates and a new store child is spawned (see that function's
- * imsg_store_init population above) -- nothing caches a copy anywhere else,
- * so updating gconf's copy here is the entire fix.
- *
- * TLS cert/key are re-read and re-pushed to listener unconditionally,
- * whether or not the *path* strings changed -- the ordinary reason to
- * SIGHUP a running mail server at all is that acme-client(1) (or a cron
- * job wrapping it) just renewed the certificate at the same path, not that
- * the path moved. listener.c's new IMSG_TLS_CERT/IMSG_TLS_KEY handling in
- * listener_dispatch_parent() (added this same pass) rebuilds its TLS
- * context from the fresh bytes and only swaps it in on complete success --
- * see listener_reload_tls()'s header comment for why a reload failure must
- * never tear down an already-working TLS context. send_tls_certs() already
- * degrades gracefully to a zero-length payload on any read/permission
- * failure (see that function's own header comment), which listener's
- * reload path treats the same way: keep serving with whatever TLS
- * configuration already worked.
- */
+/* SIGHUP: reloads imapd.conf; listen_addr/port/cred_file can't be swapped live (sockets bound, auth chrooted) and warn instead */
static void
sighup_handler(int fd, short event, void *arg)
{
"required for this to take effect", conf_path);
}
+ /* checked before writing into gconf: fields are overwritten in place with no saved-old-value to restore */
+ if (strlen(newconf.spool_root) >= sizeof(gconf->spool_root) ||
+ strlen(newconf.tls_cert_file) >= sizeof(gconf->tls_cert_file) ||
+ strlen(newconf.tls_key_file) >= sizeof(gconf->tls_key_file)) {
+ log_warnx("SIGHUP: %s: reloaded value too long for gconf's "
+ "field -- keeping the already-running configuration",
+ conf_path);
+ return;
+ }
+
/* Safe to adopt immediately -- see this function's header comment. */
(void)strlcpy(gconf->spool_root, newconf.spool_root,
sizeof(gconf->spool_root));
reap_child(pid, status);
}
-/*
- * Reaching the "children" (listener/auth) branch below always means an
- * unexpected exit, never a graceful shutdown's own SIGCHLD: sigterm_
- * handler() (this file) calls exit(0) immediately after kill()ing every
- * child, without re-entering event_dispatch(), so this process never
- * actually processes the SIGCHLD from children it deliberately killed.
- *
- * Deliberately NOT auto-restarted -- checked against both reference
- * daemons this project follows before deciding, rather than assumed.
- * smtpd's parent_sig_handler() SIGCHLD case (smtpd.c) treats its own
- * core structural children (queue/control/lka/scheduler/dispatcher/ca --
- * CHILD_DAEMON, the direct equivalent of listener/auth here) the same
- * way this function now does: log a warning, keep running degraded,
- * never respawn. (A different category, CHILD_PROCESSOR -- dynamically
- * loaded table/filter plugins -- does trigger smtpd's own full shutdown
- * on failure, but that's an externally-loaded plugin, not a core
- * structural process, so it doesn't apply here.) httpd goes further
- * still: its running parent (httpd.c) never even registers a SIGCHLD
- * handler, so a dead child there goes completely unnoticed until actual
- * shutdown (proc_kill(), proc.c). Neither reference daemon auto-restarts
- * a crashed core child, so this one doesn't either -- matches this
- * project's "smaller feature set" principle throughout, and avoids the
- * real complexity (crash-loop protection, re-running the SETUP_PEER
- * handshake and config delivery at *runtime* instead of only at boot) a
- * real restart would need. Recovery is operator-driven: "rcctl restart
- * imapd", same as an admin would do for smtpd or httpd today.
- */
+/* children (listener/auth) reaching here always means an unexpected exit; deliberately not auto-restarted, log and degrade */
static void
reap_child(pid_t pid, int status)
{
if (c->pid == pid) {
const char *what;
- /*
- * listener owns every bound socket, so its death
- * takes down IMAP service entirely -- no new
- * connections can be accepted. auth's death is
- * narrower: already-authenticated sessions are
- * unaffected (post-login traffic is listener<->store
- * only, never listener<->auth -- see imapd.h's imsg
- * catalog), but new AUTHENTICATE attempts will fail,
- * since listener.c's own auth-channel-closed handling
- * (listener_dispatch_auth(), "auth closed channel")
- * never sends a client a completing reply either way.
- */
+ /* listener's death takes down IMAP entirely; auth's death only breaks new logins */
what = c->type == PROC_LISTENER ?
"IMAP service is now unreachable (no listening "
"sockets)" : "new logins will now fail "
blob - /dev/null
blob + 2cb78dfc0e89505b92cf682de96fbeced91d8781 (mode 644)
--- /dev/null
+++ src/index.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.
+ */
+
+/* index.c -- the maildir index file format: load/save/append, QRESYNC resync, and vanished-UID tracking. */
+
+#include <sys/types.h>
+#include <sys/file.h>
+#include <sys/stat.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <event.h>
+#include <fcntl.h>
+#include <imsg.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "store_internal.h"
+
+int
+index_load(int fd, struct mbox_index *idx)
+{
+ FILE *fp;
+ char line[STORE_INDEX_LINE_MAX];
+ int dupfd;
+ int first = 1;
+
+ memset(idx, 0, sizeof(*idx));
+
+ if (lseek(fd, 0, SEEK_SET) == -1) {
+ log_warn("session %u: lseek %s", session_id, STORE_INDEX_NAME);
+ return (-1);
+ }
+ if ((dupfd = dup(fd)) == -1) {
+ log_warn("session %u: dup %s", session_id, STORE_INDEX_NAME);
+ return (-1);
+ }
+ if ((fp = fdopen(dupfd, "r")) == NULL) {
+ log_warn("session %u: fdopen %s", session_id, STORE_INDEX_NAME);
+ close(dupfd);
+ return (-1);
+ }
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ line[strcspn(line, "\n")] = '\0';
+ if (line[0] == '\0')
+ continue;
+
+ if (first) {
+ char *colon, *colon2, *ep;
+
+ first = 0;
+ if ((colon = strchr(line, ':')) == NULL) {
+ log_warnx("session %u: malformed index "
+ "header: %s", session_id, line);
+ fclose(fp);
+ return (-1);
+ }
+ *colon = '\0';
+ errno = 0;
+ idx->uidvalidity = (uint32_t)strtoul(line, &ep, 10);
+ if (*ep != '\0' || errno != 0) {
+ log_warnx("session %u: malformed "
+ "UIDVALIDITY: %s", session_id, line);
+ fclose(fp);
+ return (-1);
+ }
+
+ /* RFC 7162: optional third field HIGHESTMODSEQ; NULL means older two-field header, defaults to 1 */
+ if ((colon2 = strchr(colon + 1, ':')) != NULL)
+ *colon2 = '\0';
+
+ errno = 0;
+ idx->uidnext = (uint32_t)strtoul(colon + 1, &ep, 10);
+ if (*ep != '\0' || errno != 0) {
+ log_warnx("session %u: malformed UIDNEXT: %s",
+ session_id, colon + 1);
+ fclose(fp);
+ return (-1);
+ }
+
+ if (colon2 != NULL) {
+ errno = 0;
+ idx->highestmodseq = strtoull(colon2 + 1, &ep,
+ 10);
+ if (*ep != '\0' || errno != 0) {
+ log_warnx("session %u: malformed "
+ "HIGHESTMODSEQ: %s", session_id,
+ colon2 + 1);
+ fclose(fp);
+ return (-1);
+ }
+ } else
+ idx->highestmodseq = 1;
+ 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);
+ fclose(fp);
+ return (-1);
+ }
+ idx->lines = newlines;
+ idx->cap = newcap;
+ }
+ if ((idx->lines[idx->nlines] = strdup(line)) == NULL) {
+ log_warn("session %u: strdup index line", session_id);
+ fclose(fp);
+ return (-1);
+ }
+ idx->nlines++;
+ }
+ if (ferror(fp)) {
+ log_warn("session %u: fgets %s", session_id, STORE_INDEX_NAME);
+ fclose(fp);
+ return (-1);
+ }
+ fclose(fp);
+
+ if (first) {
+ idx->uidvalidity = (uint32_t)time(NULL);
+ idx->uidnext = 1;
+ idx->highestmodseq = 1;
+ }
+
+ return (0);
+}
+
+/* Parses one "UID:basename:keywords[:MODSEQ]" index line; returns -1 (logged) on a corrupt line, caller skips it. */
+int
+index_parse_line(const char *line, struct index_rec *rec)
+{
+ const char *p, *q, *r;
+ char *ep;
+
+ memset(rec, 0, sizeof(*rec));
+
+ errno = 0;
+ rec->uid = (uint32_t)strtoul(line, &ep, 10);
+ if (*ep != ':') {
+ log_warnx("session %u: corrupt index line: %s", session_id,
+ line);
+ return (-1);
+ }
+ p = ep + 1;
+ if ((q = strchr(p, ':')) == NULL) {
+ log_warnx("session %u: corrupt index line: %s", session_id,
+ line);
+ return (-1);
+ }
+ if ((size_t)(q - p) >= sizeof(rec->basename)) {
+ log_warnx("session %u: basename too long in index line",
+ session_id);
+ return (-1);
+ }
+ memcpy(rec->basename, p, (size_t)(q - p));
+ rec->basename[q - p] = '\0';
+
+ p = q + 1;
+ if ((r = strchr(p, ':')) != NULL) {
+ /* keywords field ends at the MODSEQ separator */
+ if ((size_t)(r - p) >= sizeof(rec->keywords)) {
+ log_warnx("session %u: keywords too long in index "
+ "line", session_id);
+ return (-1);
+ }
+ memcpy(rec->keywords, p, (size_t)(r - p));
+ rec->keywords[r - p] = '\0';
+
+ errno = 0;
+ rec->modseq = strtoull(r + 1, &ep, 10);
+ if (*ep != '\0' || errno != 0) {
+ log_warnx("session %u: malformed per-message MODSEQ "
+ "in index line: %s", session_id, line);
+ return (-1);
+ }
+ } else {
+ /* no MODSEQ field -- pre-CONDSTORE line (struct index_rec backward-compatibility) */
+ if (strlcpy(rec->keywords, p, sizeof(rec->keywords)) >=
+ sizeof(rec->keywords)) {
+ log_warnx("session %u: keywords too long in index "
+ "line", session_id);
+ return (-1);
+ }
+ rec->modseq = 1;
+ }
+
+ return (0);
+}
+
+/* Highest UID of a *present* message (idx->lines is UID-ascending), 0 if none; this is "*" for SEARCH/FETCH/STORE/EXPUNGE, not uidnext-1. */
+uint32_t
+index_max_uid(struct mbox_index *idx)
+{
+ const char *line;
+ char *ep;
+ uint32_t v;
+
+ if (idx->nlines == 0)
+ return (0);
+
+ line = idx->lines[idx->nlines - 1];
+ errno = 0;
+ v = (uint32_t)strtoul(line, &ep, 10);
+ if (*ep != ':')
+ return (0); /* corrupt last line -- treat as "no UIDs in use" rather than guessing */
+ return (v);
+}
+
+/* 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,
+ struct imsgev *iev)
+{
+ uint32_t want, i;
+
+ if (hi < lo)
+ return;
+
+ want = lo;
+ for (i = 0; i < idx->nlines && want <= hi; i++) {
+ struct index_rec rec;
+
+ if (index_parse_line(idx->lines[i], &rec) == -1)
+ continue;
+ if (rec.uid < want)
+ continue;
+ if (rec.uid > hi)
+ break;
+
+ if (rec.uid > want) {
+ struct imsg_mbox_select_vanished van;
+
+ memset(&van, 0, sizeof(van));
+ van.uid_lo = want;
+ van.uid_hi = rec.uid - 1;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECT_VANISHED,
+ 0, 0, -1, &van, sizeof(van)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_SELECT_VANISHED", session_id);
+ }
+
+ want = rec.uid + 1;
+ }
+
+ if (want <= hi) {
+ struct imsg_mbox_select_vanished van;
+
+ memset(&van, 0, sizeof(van));
+ van.uid_lo = want;
+ van.uid_hi = hi;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECT_VANISHED, 0, 0,
+ -1, &van, sizeof(van)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_SELECT_VANISHED", session_id);
+ }
+}
+
+/* Linear scan for a basename already in the index; O(n) per lookup, fine for v1's modest-mailbox-size scope. */
+int
+index_has_basename(struct mbox_index *idx, const char *basename)
+{
+ size_t i;
+
+ for (i = 0; i < idx->nlines; i++) {
+ const char *p = idx->lines[i];
+ const char *basefield, *end;
+ size_t len;
+
+ if ((basefield = strchr(p, ':')) == NULL)
+ continue;
+ basefield++;
+ end = strchr(basefield, ':');
+ len = (end != NULL) ? (size_t)(end - basefield) :
+ strlen(basefield);
+ if (strlen(basename) == len &&
+ strncmp(basefield, basename, len) == 0)
+ return (1);
+ }
+ return (0);
+}
+
+/* Appends one "UID:basename::MODSEQ" record; caller owns idx->uidnext. RFC 7162 SS3.1: each append gets its own bumped modseq. */
+int
+index_append(struct mbox_index *idx, uint32_t uid, const char *basename)
+{
+ char line[STORE_INDEX_LINE_MAX];
+ int len;
+
+ /* defense in depth: refuse a basename containing ':' or newline (refresh_index() already pre-skips these) */
+ if (strpbrk(basename, ":\r\n") != NULL) {
+ log_warnx("session %u: refusing index entry with unsafe "
+ "basename: %s", session_id, basename);
+ return (-1);
+ }
+
+ idx->highestmodseq++;
+
+ len = snprintf(line, sizeof(line), "%u:%s::%llu", uid, basename,
+ (unsigned long long)idx->highestmodseq);
+ if (len < 0 || (size_t)len >= sizeof(line)) {
+ log_warnx("session %u: index line too long for %s",
+ session_id, basename);
+ 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 ((idx->lines[idx->nlines] = strdup(line)) == NULL) {
+ log_warn("session %u: strdup index line", session_id);
+ return (-1);
+ }
+ idx->nlines++;
+ return (0);
+}
+
+/* Rewrites index to STORE_INDEX_TMP_NAME, rename(2)s over STORE_INDEX_NAME so a reader never sees a torn file. */
+int
+index_save(const struct mbox_index *idx)
+{
+ FILE *fp;
+ int fd;
+ size_t i;
+
+ /* O_EXCL so a pre-planted symlink can't be followed; unlink any stale temp from a prior crash first */
+ if (unlink(STORE_INDEX_TMP_NAME) == -1 && errno != ENOENT) {
+ log_warn("session %u: unlink %s", session_id,
+ STORE_INDEX_TMP_NAME);
+ return (-1);
+ }
+ if ((fd = open(STORE_INDEX_TMP_NAME, O_WRONLY | O_CREAT | O_EXCL,
+ 0600)) == -1) {
+ log_warn("session %u: open %s", session_id,
+ STORE_INDEX_TMP_NAME);
+ return (-1);
+ }
+ if ((fp = fdopen(fd, "w")) == NULL) {
+ log_warn("session %u: fdopen %s", session_id,
+ STORE_INDEX_TMP_NAME);
+ close(fd);
+ return (-1);
+ }
+
+ if (fprintf(fp, "%u:%u:%llu\n", idx->uidvalidity, idx->uidnext,
+ (unsigned long long)idx->highestmodseq) < 0) {
+ log_warnx("session %u: write index header failed",
+ session_id);
+ fclose(fp);
+ return (-1);
+ }
+ for (i = 0; i < idx->nlines; i++) {
+ if (fprintf(fp, "%s\n", idx->lines[i]) < 0) {
+ log_warnx("session %u: write index line failed",
+ session_id);
+ fclose(fp);
+ return (-1);
+ }
+ }
+ if (fclose(fp) != 0) {
+ log_warn("session %u: fclose %s", session_id,
+ STORE_INDEX_TMP_NAME);
+ return (-1);
+ }
+
+ if (rename(STORE_INDEX_TMP_NAME, STORE_INDEX_NAME) == -1) {
+ log_warn("session %u: rename %s -> %s", session_id,
+ STORE_INDEX_TMP_NAME, STORE_INDEX_NAME);
+ return (-1);
+ }
+ return (0);
+}
+
+
+void
+index_free(struct mbox_index *idx)
+{
+ size_t i;
+
+ for (i = 0; i < idx->nlines; i++)
+ free(idx->lines[i]);
+ free(idx->lines);
+ memset(idx, 0, sizeof(*idx));
+}
+
+/* RFC 7162 SS3.2.5.1 QRESYNC resync: streams VANISHED ranges then FETCH_META for messages with modseq > qresync_modseq. */
+void
+qresync_send_resync(const struct imsg_mbox_select *req,
+ const struct mbox_index *idx, struct imsgev *iev)
+{
+ uint32_t uid_lo, uid_hi, want, i;
+
+ if (req->qresync_has_uids) {
+ uid_lo = req->qresync_uid_lo;
+ uid_hi = req->qresync_uid_hi;
+ } else {
+ /* SS3.2.5.1: no known-uids list acts as "1:<uidnext-1>", or empty if uidnext == 1 (never assigned) */
+ if (idx->uidnext <= 1)
+ return;
+ uid_lo = 1;
+ uid_hi = idx->uidnext - 1;
+ }
+ if (uid_hi < uid_lo)
+ return; /* empty requested range -- nothing to do */
+
+ /* single forward pass; want tracks the lowest UID not yet accounted for -- a gap before it means vanished */
+ want = uid_lo;
+ for (i = 0; i < idx->nlines && want <= uid_hi; i++) {
+ struct index_rec rec;
+
+ if (index_parse_line(idx->lines[i], &rec) == -1)
+ continue; /* corrupt line, already logged -- not reported either way */
+ if (rec.uid < want)
+ continue; /* below the requested range, or already accounted for */
+ if (rec.uid > uid_hi)
+ break;
+
+ if (rec.uid > want) {
+ struct imsg_mbox_select_vanished van;
+
+ memset(&van, 0, sizeof(van));
+ van.uid_lo = want;
+ van.uid_hi = rec.uid - 1;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECT_VANISHED,
+ 0, 0, -1, &van, sizeof(van)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_SELECT_VANISHED", session_id);
+ }
+
+ if (rec.modseq > req->qresync_modseq) {
+ struct imsg_mbox_fetch_meta meta;
+ char suffix[64];
+ off_t size;
+
+ memset(&meta, 0, sizeof(meta));
+ meta.seqno = i + 1;
+ meta.uid = rec.uid;
+ meta.modseq = rec.modseq;
+ if (locate_message_file(rec.basename, &size, suffix,
+ sizeof(suffix)) == 0) {
+ build_flags_string(suffix, rec.keywords,
+ meta.flags, sizeof(meta.flags));
+ }
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_FETCH_META, 0,
+ 0, -1, &meta, sizeof(meta)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_FETCH_META (qresync)",
+ session_id);
+ }
+
+ want = rec.uid + 1;
+ }
+
+ if (want <= uid_hi) {
+ struct imsg_mbox_select_vanished van;
+
+ memset(&van, 0, sizeof(van));
+ van.uid_lo = want;
+ van.uid_hi = uid_hi;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECT_VANISHED, 0, 0,
+ -1, &van, sizeof(van)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_SELECT_VANISHED", session_id);
+ }
+}
+
+/* Loads the index (fd already flock(2)'d LOCK_EX) and indexes any new/ files not yet known; on failure idx is already index_free()'d. */
+int
+refresh_index(struct mbox_index *idx, int fd)
+{
+ DIR *dp;
+ struct dirent *de;
+
+ if (index_load(fd, idx) == -1)
+ return (-1);
+
+ dp = opendir("new");
+ if (dp == NULL) {
+ if (errno == ENOENT)
+ return (0); /* no new/ yet on a never-used mailbox -- not an error */
+ log_warn("session %u: opendir new", session_id);
+ index_free(idx);
+ return (-1);
+ }
+ while ((de = readdir(dp)) != NULL) {
+ if (de->d_name[0] == '.')
+ 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) {
+ log_warnx("session %u: skipping new/ file with unsafe "
+ "name (contains ':' or newline): %s", session_id,
+ de->d_name);
+ continue;
+ }
+ if (index_has_basename(idx, de->d_name))
+ continue;
+ if (index_append(idx, idx->uidnext, de->d_name) == -1) {
+ closedir(dp);
+ index_free(idx);
+ return (-1);
+ }
+ idx->uidnext++;
+ }
+ closedir(dp);
+
+ if (index_save(idx) == -1) {
+ index_free(idx);
+ return (-1);
+ }
+ return (0);
+}
+
+/* RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9; re-validated here independently of listener.c's client-side check (privsep defense in depth). */
+void
+handle_mbox_idle_refresh(struct imsgev *iev)
+{
+ struct mbox_index idx;
+ struct imsg_mbox_idle_refreshed reply;
+ struct imsg_mbox_idle_uid item;
+ int fd;
+ size_t i;
+ struct index_rec rec;
+
+ memset(&reply, 0, sizeof(reply));
+
+ if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+ log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+ goto send;
+ }
+ if (flock(fd, LOCK_EX) == -1) {
+ log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+ close(fd);
+ goto send;
+ }
+ if (refresh_index(&idx, fd) == -1) {
+ flock(fd, LOCK_UN);
+ close(fd);
+ goto send;
+ }
+
+ for (i = 0; i < idx.nlines; i++) {
+ if (index_parse_line(idx.lines[i], &rec) == -1)
+ continue; /* skip malformed line, don't fail the whole request */
+ memset(&item, 0, sizeof(item));
+ item.uid = rec.uid;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_UID, 0, 0, -1,
+ &item, sizeof(item)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_IDLE_UID", session_id);
+ }
+
+ reply.ok = 1;
+ reply.exists = (uint32_t)idx.nlines;
+ reply.uidvalidity = idx.uidvalidity;
+ reply.uidnext = idx.uidnext;
+ reply.highestmodseq = idx.highestmodseq;
+
+ index_free(&idx);
+ flock(fd, LOCK_UN);
+ close(fd);
+
+send:
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_REFRESHED, 0, 0, -1,
+ &reply, sizeof(reply)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_IDLE_REFRESHED",
+ session_id);
+ imsgev_add(iev);
+}
blob - 0614ff4d02f82aaa32ca6c562e0cd3e269a6126e
blob + 9846dc4ba7e91776deb0293561504738804eddee
--- src/parse.y
+++ src/parse.y
/*
* Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ * Copyright (c) 2002, 2003, 2004 Henning Brauer <henning@openbsd.org>
+ * Copyright (c) 2001 Markus Friedl. All rights reserved.
+ * Copyright (c) 2001 Daniel Hartmeier. All rights reserved.
+ * Copyright (c) 2001 Theo de Raadt. All rights reserved.
*
+ * The parser skeleton below (lgetc()/lungetc()/findeol(), the hand-
+ * written yylex() built on top of them, pushfile()/popfile(), and
+ * symset()/symget()) follows the structure common to ripd's, smtpd's,
+ * and httpd's own parse.y in the OpenBSD base system, all of which
+ * carry this same four-name copyright chain at their root. This file's
+ * grammar and domain-specific rules are original; the above four names
+ * are carried forward for the shared parser-skeleton lineage only.
+ *
* 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 IPv6 pass works unquoted, matching every other address form
* documented in imapd.conf.example -- found as a real bug, not
* designed in up front: an unquoted "listen on * port 143" line
- * failed with a bare "syntax error" on real hardware (premio) before
+ * failed with a bare "syntax error" on real hardware before
* this fix, since '*' alone satisfied none of the original starting
* characters here and fell through to being returned as a raw,
* unexpected single-character token instead of ever entering this
blob - bbbf2d8cb4c55ae27eed5cb70cec9c3ff817bb28 (mode 755)
blob + bbbf2d8cb4c55ae27eed5cb70cec9c3ff817bb28 (mode 644)
blob - /dev/null
blob + 22356e4c4e6c46c8052d0c9f57aa0dff3df46b29 (mode 644)
--- /dev/null
+++ src/listener.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.
+ */
+
+/*
+ * listener.h -- internal, listener-process-only shared declarations.
+ *
+ * Not installed, not part of imapd's public wire protocol (that's
+ * imapd.h) -- this exists solely so listener.c's core (the accept loop
+ * and session dispatch machinery) and the eight files split out of what
+ * used to be one 10,619-line listener.c (auth_cmd.c, mailbox_cmd.c,
+ * append_cmd.c, fetch_cmd.c, search_cmd.c, store_cmd.c, store_ipc.c, and
+ * listener.c itself) can all see struct session, enum session_state,
+ * and each other's entry points. Nothing in here crosses process
+ * boundaries or gets seen by auth.c/store.c/parent.c -- those live
+ * entirely behind imapd.h's imsg wire structs instead.
+ */
+
+#ifndef LISTENER_H
+#define LISTENER_H
+
+#include <sys/types.h>
+#include <sys/queue.h>
+
+#include <event.h>
+#include <stdint.h>
+#include <tls.h>
+
+enum session_state {
+ SESSION_NOT_AUTH,
+ SESSION_AUTHENTICATING, /* IMSG_AUTH_REQUEST sent, awaiting reply */
+ SESSION_STORE_PENDING, /* auth succeeded; awaiting parent's store-child handshake (task #321) */
+ SESSION_AUTHENTICATED,
+ SESSION_SELECTING, /* IMSG_MBOX_SELECT sent, awaiting reply --
+ * see cmd_select()/session_store_dispatch() */
+ SESSION_SELECTED,
+ SESSION_FETCHING, /* IMSG_MBOX_FETCH sent, awaiting the
+ * IMSG_MBOX_FETCH_META stream + terminal
+ * IMSG_MBOX_RESULT -- see cmd_fetch()/
+ * session_handle_mbox_result() */
+ SESSION_STORING, /* IMSG_MBOX_STORE sent, awaiting the same
+ * IMSG_MBOX_FETCH_META stream + terminal
+ * IMSG_MBOX_RESULT shape SESSION_FETCHING
+ * uses -- see cmd_store_cmd()'s comment on
+ * why STORE reuses FETCH's reply types */
+ SESSION_EXPUNGING, /* IMSG_MBOX_EXPUNGE sent (by EXPUNGE itself,
+ * or by CLOSE with silent=1), awaiting the
+ * IMSG_MBOX_EXPUNGED stream + terminal
+ * IMSG_MBOX_RESULT -- see cmd_expunge()/
+ * cmd_close()/session_handle_mbox_result() */
+ SESSION_APPENDING, /* IMSG_MBOX_APPEND sent, awaiting the single
+ * terminal IMSG_MBOX_APPENDED reply -- see
+ * cmd_append()/session_finish_append()/
+ * session_handle_mbox_appended(). Distinct
+ * from the *client-literal-read* phase that
+ * precedes it (s->literal_pending -- see that
+ * field's comment): this state only covers
+ * the store round trip, once the full
+ * message has already been read off the
+ * wire. */
+ SESSION_SEARCHING, /* IMSG_MBOX_SEARCH sent, awaiting the
+ * IMSG_MBOX_SEARCH_MATCH stream + terminal
+ * IMSG_MBOX_RESULT -- see cmd_search()/
+ * session_finish_search(). Same per-match-
+ * stream-then-terminal-result reply shape as
+ * SESSION_FETCHING/STORING/EXPUNGING, so it's
+ * handled by the same session_handle_mbox_
+ * result() entry point, which branches to
+ * session_finish_search() first. */
+ SESSION_STATUSING, /* IMSG_MBOX_STATUS sent, awaiting the single
+ * terminal IMSG_MBOX_STATUS_RESULT reply --
+ * see cmd_status()/session_handle_mbox_
+ * status_result(). Same single-request/single-
+ * reply shape as SESSION_SELECTING, but
+ * (unlike SELECT) never changes s->state's
+ * SELECTED-ness -- STATUS "does not change the
+ * currently selected mailbox" (RFC 9051
+ * SS6.3.11) -- so s->status_prev_state records
+ * whichever ST_AUTH state was current when
+ * cmd_status() was called, for session_handle_
+ * mbox_status_result() to restore. */
+ SESSION_COPYING, /* IMSG_MBOX_COPY or IMSG_MBOX_MOVE sent
+ * (s->cmd_is_move says which), awaiting the
+ * IMSG_MBOX_COPY_MAPPING stream (plus, for a
+ * MOVE, an interleaved IMSG_MBOX_EXPUNGED
+ * stream -- see s->move_expunged's comment) +
+ * terminal IMSG_MBOX_RESULT -- see cmd_copy()/
+ * cmd_move()/session_finish_copy_or_move().
+ * Same per-message-stream-then-terminal-result
+ * shape as SESSION_FETCHING/STORING/EXPUNGING/
+ * SEARCHING, so it's handled by the same
+ * session_handle_mbox_result() entry point,
+ * which branches to session_finish_copy_or_
+ * move() first, the same way it already
+ * branches to session_finish_search(). */
+
+ /*
+ * RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 additions (flat multi-mailbox
+ * support). All four are
+ * command-auth (valid in Authenticated or Selected state) and never
+ * change s->state's SELECTED-ness -- same "record whichever ST_AUTH
+ * state was current before, restore it after" pattern SESSION_
+ * STATUSING's s->status_prev_state already established, reused here
+ * as s->mbox_op_prev_state (one shared field: only one of these four
+ * can ever be in flight for a given session at once, so there's no
+ * need for four separate fields the way SESSION_APPENDING has its
+ * own append_prev_state).
+ */
+ SESSION_CREATING, /* IMSG_MBOX_CREATE sent, awaiting the single
+ * terminal IMSG_MBOX_RESULT reply -- see
+ * cmd_create()/session_finish_mbox_op(). */
+ SESSION_DELETING, /* IMSG_MBOX_DELETE sent, same single-terminal-
+ * reply shape as SESSION_CREATING -- see
+ * cmd_delete()/session_finish_mbox_op(). */
+ SESSION_RENAMING, /* IMSG_MBOX_RENAME sent, same single-terminal-
+ * reply shape as SESSION_CREATING -- see
+ * cmd_rename()/session_finish_mbox_op(). */
+ SESSION_LISTING /* IMSG_MBOX_LIST sent, awaiting the
+ * IMSG_MBOX_LIST_ITEM stream + terminal
+ * IMSG_MBOX_RESULT -- see list_dispatch()/
+ * session_finish_list(). Same per-item-stream-
+ * then-terminal-result shape as SESSION_
+ * SEARCHING/COPYING, so it's handled by the
+ * same session_handle_mbox_result() entry
+ * point too, branching to session_finish_
+ * list() first. */
+};
+
+/*
+ * Generous line-length cap for the raw CRLF-delimited read buffer below.
+ * RFC 9051 doesn't mandate a specific limit -- SS7.1.3's "* BAD Command
+ * line too long" is example text, not a normative value -- but any real
+ * server needs one to bound memory for a client that never sends CRLF.
+ * Revisit once IMAP literals ({n}-prefixed octet counts, SS4.3) are
+ * implemented: those need a different, larger mechanism than a flat line
+ * buffer, since a literal's byte count is part of the command syntax
+ * itself, not just "a longer line".
+ */
+#define SESSION_INBUF_MAX 8192
+
+/*
+ * Generous bound for a client-chosen tag we need to remember across an
+ * async IMSG_AUTH_REQUEST/IMSG_AUTH_RESULT (and, on success, IMSG_STORE_
+ * FORK/IMSG_SETUP_PEER) round trip -- RFC 9051 SS9 doesn't specify a tag
+ * length limit (`tag = 1*<any ASTRING-CHAR except "+">`), so this is the
+ * same kind of deliberate, flagged simplification as session_reply()'s
+ * 512-byte response buffer: truncated via strlcpy() rather than rejected,
+ * matching this file's existing truncate-rather-than-overflow style.
+ */
+#define IMAP_TAG_MAX 64
+
+/*
+ * Bound on how many complete command lines a session may have pipelined
+ * ahead of the one currently awaiting an async store round trip (RFC 9051
+ * SS5.5 permits a client to send further commands before a prior one's
+ * tagged response arrives, as long as the server processes them in order --
+ * see session_enqueue_cmd()/session_dequeue_next() in listener.c). Deep
+ * enough for any real client (the iOS Mail bug this was written for only
+ * ever overlapped two: LIST then SELECT); a session that queues past this
+ * is either buggy or hostile, so it's disconnected rather than given
+ * unbounded memory -- same philosophy as SESSION_INBUF_MAX/IMAP_TAG_MAX
+ * above. Deliberately does NOT apply to the pre-authentication states
+ * (SESSION_AUTHENTICATING/STORE_PENDING) -- see session_is_busy()'s
+ * comment -- so an unauthenticated client gains no new memory-allocation
+ * surface from this queue at all.
+ */
+#define SESSION_CMD_QUEUE_MAX 8
+
+/*
+ * Cap on the verbatim label text (e.g. "HEADER.FIELDS (DATE FROM)" or
+ * "HEADER.FIELDS.NOT (X-SPAM-STATUS)") stashed on struct session's
+ * pending_header_label and echoed back into the FETCH response's
+ * "BODY[<label>]" -- see that field's own comment for the full
+ * reasoning. Defined up here (rather than down near parse_fetch_atts(),
+ * where it's actually used) because struct session, just below,
+ * declares a fixed array of this size -- needs to be visible before that
+ * point. Never crosses the imsg boundary (listener.c has this text
+ * straight from the client's own command line), so this is a
+ * listener.c-local constant, not a shared imapd.h one like HEADER_
+ * FIELDS_MAX. Sized generously above HEADER_FIELDS_MAX (256, imapd.h)
+ * to cover the "HEADER.FIELDS.NOT (...)" wrapper text around whatever
+ * field-name list already fits under that cap.
+ */
+#define HEADER_FIELDS_LABEL_MAX 288
+
+/*
+ * RFC 9051 SS6.4.4's SEARCH result options this server understands as
+ * ESEARCH return items (SS7.3.4) -- SAVE (the "$" search result
+ * variable, SS6.4.4.1) is recognized but rejected with a flagged NO; see
+ * parse_search_return_opts()'s comment for why it's out of scope this
+ * pass. Shared here (not file-local to search_cmd.c, where they're
+ * mostly used) because store_cmd.c's session_finish_search() also tests
+ * these bits when assembling the ESEARCH response.
+ */
+#define SEARCH_RETURN_MIN (1U << 0)
+#define SEARCH_RETURN_MAX (1U << 1)
+#define SEARCH_RETURN_ALL (1U << 2)
+#define SEARCH_RETURN_COUNT (1U << 3)
+
+/* One RFC 7162 VANISHED (EARLIER) range -- see struct session's vanished_
+ * ranges comment and format_range_list(). Named (not anonymous) so format_
+ * range_list() can take it as a parameter type. */
+struct vanished_range {
+ uint32_t lo;
+ uint32_t hi;
+};
+
+struct session {
+ uint32_t id;
+ int client_fd;
+ struct event client_ev;
+ enum session_state state;
+ int implicit_tls; /* accepted on port 993, per
+ * RFC 8314 -- vs. port 143,
+ * where TLS only starts after
+ * STARTTLS */
+ struct imsgev *store_iev; /* NULL until STORE_PENDING
+ * resolves */
+ int client_ev_added; /* client_ev is only safe to
+ * event_del() once this is
+ * set -- see listener_accept()'s
+ * implicit-TLS early-teardown
+ * path, which never reaches
+ * the event_set()/event_add()
+ * call below it. */
+ int tls_active; /* 1 once a real TLS handshake
+ * (implicit-TLS accept or
+ * STARTTLS) has completed --
+ * threaded through CAPABILITY's
+ * state-appropriate response and
+ * AUTHENTICATE's TLS gate. */
+ struct tls *tls_ctx; /* NULL until tls_accept_socket()
+ * -- non-NULL during handshake
+ * too, not just once tls_active */
+ int pending_greeting; /* implicit-TLS only: the
+ * greeting can't be sent until
+ * after the handshake completes
+ * (RFC 8314 -- no protocol bytes
+ * before TLS), so listener_accept()
+ * defers it and session_tls_
+ * handshake() sends it on success */
+ char inbuf[SESSION_INBUF_MAX];
+ size_t inbuflen; /* bytes of unparsed input
+ * currently in inbuf */
+ int auth_cont; /* 1 while the next raw client
+ * line is a SASL continuation-
+ * response (base64 or "*"), not
+ * a fresh tagged command -- set
+ * by cmd_authenticate() when the
+ * client sends bare "AUTHENTICATE
+ * PLAIN" with no inline initial
+ * response, cleared by session_
+ * handle_auth_continuation() as
+ * soon as that line arrives. */
+ int idling; /* 1 while the next raw client
+ * line is IDLE's "DONE"
+ * continuation, not a fresh
+ * tagged command -- set by
+ * cmd_idle(), cleared by
+ * session_handle_idle_
+ * continuation() (RFC 9051
+ * SS6.3.13). Same shape as
+ * auth_cont above, checked
+ * second in session_dispatch_
+ * client()'s read loop. */
+ char *cmd_queue[SESSION_CMD_QUEUE_MAX]; /* malloc(3)'d
+ * command lines the client
+ * pipelined while session_is_
+ * busy() was true -- e.g. iOS
+ * Mail sending SELECT right
+ * behind LIST, before LIST's
+ * tagged OK arrives (RFC 9051
+ * SS5.5 permits this). Entries
+ * 0..cmd_queue_n-1 are valid,
+ * always compacted (no holes);
+ * session_enqueue_cmd() appends,
+ * session_dequeue_next() pops
+ * the front and shifts. Never
+ * touched for auth_cont/idling
+ * continuation lines -- those
+ * bypass this queue entirely,
+ * same as they bypass session_
+ * handle_line()'s tag/name/args
+ * parser. */
+ uint32_t cmd_queue_n; /* count of valid entries in
+ * cmd_queue, 0..SESSION_CMD_
+ * QUEUE_MAX */
+ uid_t uid; /* set once, in session_request_
+ * store(), from the same imsg_
+ * auth_result the store-fork
+ * request itself uses -- exists
+ * purely so session_notify_
+ * idle_peers() can find this
+ * session's *other* concurrent
+ * sessions (same user, v1 has
+ * no shared mailboxes so that's
+ * the only case that can ever
+ * matter) without a second round
+ * trip through auth just to ask
+ * "whose session is this." */
+ char pending_tag[IMAP_TAG_MAX]; /* copy of whichever
+ * command's tag is waiting on an
+ * async imsg round trip right now
+ * (AUTHENTICATE -> auth, or SELECT
+ * -> store) -- tag itself is a
+ * pointer into inbuf, which gets
+ * overwritten by the next read()
+ * long before IMSG_AUTH_RESULT (or
+ * the later IMSG_SETUP_PEER/
+ * IMSG_STORE_FORK store-spawn
+ * reply, or IMSG_MBOX_SELECTED)
+ * comes back, so it has to be
+ * saved out. Only ever one such
+ * round trip in flight at a time
+ * per session (SESSION_
+ * AUTHENTICATING/STORE_PENDING/
+ * SELECTING/FETCHING are all
+ * mutually exclusive states), so
+ * one field is enough. */
+ uint32_t fetch_attrs; /* MBOX_FETCH_* bitmask for
+ * the FETCH currently in flight
+ * (SESSION_FETCHING) -- needed
+ * because struct imsg_mbox_
+ * fetch_meta's fields (uid,
+ * size, ...) are always
+ * populated by store regardless
+ * of what was actually
+ * requested, so session_send_
+ * fetch_response() needs to
+ * know which ones to print. */
+ char *pending_header_buf; /* malloc(3)'d raw header
+ * bytes from the most recent
+ * IMSG_MBOX_FETCH_HEADER, held
+ * here until the very next
+ * IMSG_MBOX_FETCH_META (same
+ * message, always sent
+ * immediately after -- see that
+ * struct's comment in
+ * imapd.h) folds it into one
+ * FETCH response line; freed and
+ * NULL'd there. Never allocated
+ * for more than one message at a
+ * time -- store.c's ordering
+ * contract means this is never
+ * still set when the next
+ * IMSG_MBOX_FETCH_HEADER
+ * arrives, but session_teardown()
+ * frees it defensively anyway in
+ * case a FETCH is torn down
+ * mid-stream. */
+ uint32_t pending_header_len; /* valid only alongside
+ * pending_header_buf != NULL */
+ int pending_header_found; /* 0 -- this message's
+ * header couldn't be read; 1
+ * -- pending_header_buf/_len are
+ * valid. Distinguishes "BODY[HEADER]
+ * was requested but this message
+ * has none to give" from "BODY[HEADER]
+ * wasn't requested at all", the
+ * latter never populating this or
+ * pending_header_buf in the first
+ * place. */
+ char pending_header_label[HEADER_FIELDS_LABEL_MAX];
+ /* verbatim client-typed section-spec
+ * text -- "HEADER" for plain BODY.PEEK
+ * [HEADER], or e.g. "HEADER.FIELDS
+ * (DATE FROM)" for that variant --
+ * echoed back as-is in the FETCH
+ * response's "BODY[<label>]" rather than
+ * reconstructed, since RFC 9051 doesn't
+ * mandate an exact echo format and
+ * verbatim is simplest and trivially
+ * correct. Set once in fetch_dispatch(),
+ * read by session_send_fetch_response()
+ * -- never needs its own free()/reset
+ * since it's a fixed-size buffer, not a
+ * malloc(3)'d pointer like pending_
+ * header_buf. */
+ char *pending_body_buf; /* same stash-until-
+ * the-next-IMSG_MBOX_FETCH_META
+ * shape as pending_header_buf above,
+ * just for IMSG_MBOX_FETCH_BODY
+ * (BODY.PEEK[]/BODY.PEEK[TEXT])
+ * instead -- see struct imsg_mbox_
+ * fetch_body's comment in
+ * imapd.h. Freed and NULL'd by
+ * session_send_fetch_response();
+ * session_teardown() frees it
+ * defensively too, same reasoning as
+ * pending_header_buf. */
+ uint32_t pending_body_len; /* valid only alongside
+ * pending_body_buf != NULL */
+ int pending_body_found; /* same found/not-found
+ * distinction as pending_header_
+ * found, for BODY.PEEK[]/
+ * BODY.PEEK[TEXT] */
+ char pending_body_label[SECTION_PART_MAX];
+ /* verbatim section text for the
+ * "BODY[<label>]" response --
+ * "" for BODY.PEEK[] (whole
+ * message), "TEXT" for BODY.PEEK
+ * [TEXT], or the client-typed
+ * dotted-numeric path (e.g. "3.1")
+ * for BODY.PEEK[<section-part>] --
+ * same "precompute the full
+ * verbatim label at dispatch time"
+ * idiom as pending_header_label,
+ * unifying what used to be a
+ * pending_body_is_text boolean
+ * (adequate when only WHOLE/TEXT
+ * existed, not once a third,
+ * client-supplied-text variant --
+ * PART -- joined them). Set once in
+ * fetch_dispatch() from whichever of
+ * MBOX_FETCH_BODY_WHOLE/_TEXT/_PART
+ * attrs selects (same WHOLE > TEXT >
+ * PART precedence store.c's
+ * handle_mbox_fetch() applies --
+ * see that function's comment),
+ * read by session_send_fetch_
+ * response(). */
+ int pending_body_has_partial; /* 1 if the
+ * client's own BODY.PEEK[...]
+ * token carried a <<start.count>>
+ * suffix (RFC 9051 SS6.4.5) for
+ * whichever of WHOLE/TEXT/PART was
+ * actually selected -- see struct
+ * imsg_mbox_fetch's has_partial
+ * comment in imapd.h. Only
+ * pending_body_partial_origin (the
+ * *requested* start octet, not
+ * store.c's own bodylen -- SS6.4.5:
+ * "The origin octet facility MUST
+ * NOT be used ... unless the client
+ * specifically requested it", and
+ * the response echoes only the
+ * origin, never the count) is echoed
+ * back; set alongside pending_body_
+ * label in fetch_dispatch(). */
+ uint32_t pending_body_partial_origin;
+ char *pending_envelope_buf; /* same stash-
+ * until-the-next-IMSG_MBOX_
+ * FETCH_META shape as pending_
+ * header_buf/pending_body_buf
+ * above, for IMSG_MBOX_FETCH_
+ * ENVELOPE -- see struct imsg_
+ * mbox_fetch_envelope's comment
+ * in imapd.h. Unlike those
+ * two, holds already-formatted
+ * "(...)" envelope response
+ * text, not raw message bytes,
+ * so session_send_fetch_
+ * response() writes it directly
+ * rather than wrapping it in a
+ * `{n}` literal. Freed and
+ * NULL'd by session_send_fetch_
+ * response(); session_teardown()
+ * frees it defensively too, same
+ * reasoning as pending_header_buf. */
+ uint32_t pending_envelope_len; /* valid only
+ * alongside pending_envelope_buf
+ * != NULL */
+ int pending_envelope_found; /* same found/
+ * not-found distinction as
+ * pending_header_found, for
+ * ENVELOPE */
+ char *pending_bodystructure_buf; /* same stash-
+ * until-the-next-IMSG_MBOX_
+ * FETCH_META shape, same
+ * already-formatted-text shape,
+ * as pending_envelope_buf above,
+ * for IMSG_MBOX_FETCH_
+ * BODYSTRUCTURE -- see struct
+ * imsg_mbox_fetch_bodystructure's
+ * comment in imapd.h. */
+ uint32_t pending_bodystructure_len; /* valid only
+ * alongside pending_
+ * bodystructure_buf != NULL */
+ int pending_bodystructure_found; /* same found/
+ * not-found distinction as
+ * pending_header_found, for
+ * BODYSTRUCTURE */
+ char pending_bodystructure_label[16]; /*
+ * "BODY" or "BODYSTRUCTURE" --
+ * RFC 9051 SS9's msg-att-static:
+ * `"BODY" ["STRUCTURE"] SP body`
+ * means the response label
+ * itself echoes whichever bare
+ * token the client used (both
+ * set the same MBOX_FETCH_
+ * BODYSTRUCTURE bit and produce
+ * identical body text in this
+ * implementation -- see that
+ * bit's imapd.h comment --
+ * but the label still has to
+ * match what was asked for).
+ * Set once in fetch_dispatch(),
+ * read by session_send_fetch_
+ * response() -- same fixed-
+ * buffer, no-free-needed shape
+ * as pending_header_label. */
+ int close_after_expunge; /* 1 if the in-flight
+ * SESSION_EXPUNGING round trip
+ * was started by CLOSE, not a
+ * real EXPUNGE command -- both
+ * send the identical IMSG_MBOX_
+ * EXPUNGE (CLOSE with silent=1)
+ * and get back the identical
+ * IMSG_MBOX_RESULT, so this is
+ * the only way session_handle_
+ * mbox_result() can tell which
+ * tagged completion text/next
+ * state applies: CLOSE goes to
+ * SESSION_AUTHENTICATED, a real
+ * EXPUNGE goes back to SESSION_
+ * SELECTED. */
+ int literal_pending; /* 1 while the next bytes off
+ * the wire are a client
+ * literal's raw octets (RFC 9051
+ * SS4.3), not a CRLF-delimited
+ * line -- set by cmd_append()
+ * when it finds a trailing
+ * "{n}"/"{n+}" on the APPEND
+ * command line, cleared once
+ * literal_buf_len reaches
+ * literal_len. Checked at the
+ * very top of session_dispatch_
+ * client()'s read loop, *before*
+ * the normal CRLF search -- so
+ * unlike every async-imsg-wait
+ * state above, this needs no
+ * ST_* dispatch-table exclusion:
+ * while it's set, session_
+ * handle_line() is never reached
+ * at all, regardless of s->state,
+ * so no new complete "tag SP
+ * command CRLF" can ever be
+ * mis-parsed out of literal
+ * bytes. */
+ char *literal_buf; /* malloc(3)'d accumulator for
+ * the literal currently being
+ * read, literal_len bytes, freed
+ * once handed off to session_
+ * finish_append() (or on early
+ * teardown -- see session_
+ * teardown()) */
+ uint64_t literal_len; /* total announced literal size
+ * (what "{n}" both this and
+ * literal_remaining start from) */
+ uint64_t literal_remaining; /* bytes of the literal still
+ * needed -- literal_len minus
+ * however much has landed in
+ * literal_buf so far, across
+ * possibly many reads */
+ char append_mailbox[MBOX_NAME_MAX]; /* APPEND's
+ * arguments, parsed by
+ * cmd_append() *before* the
+ * literal is read (mailbox/
+ * flags/date all precede the
+ * literal in RFC 9051 SS6.3.12's
+ * grammar) and held here across
+ * the literal-read phase until
+ * session_finish_append() needs
+ * them to build IMSG_MBOX_
+ * APPEND */
+ uint32_t append_sysflags;
+ char append_keywords[MBOX_FLAGS_MAX];
+ int append_has_date;
+ int64_t append_date;
+ enum session_state append_prev_state; /* SESSION_AUTHENTICATED or
+ * SESSION_SELECTED -- whichever
+ * s->state was when cmd_append()
+ * was first called (APPEND is
+ * valid in either, RFC 9051
+ * command-auth), so session_
+ * handle_mbox_appended() knows
+ * which one to restore once
+ * IMSG_MBOX_APPENDED arrives --
+ * unlike FETCH/STORE/EXPUNGE,
+ * which are only ever dispatched
+ * from (and so only ever return
+ * to) SESSION_SELECTED, APPEND's
+ * "go back to" state isn't
+ * fixed. */
+ uint32_t status_attrs; /* STATUS_ATT_* bitmask for the
+ * STATUS currently in flight
+ * (SESSION_STATUSING) -- which
+ * status-att values to include
+ * in the response, in listener.c's
+ * fixed canonical order; store.c
+ * always computes MESSAGES/
+ * UIDNEXT/UIDVALIDITY/
+ * HIGHESTMODSEQ regardless (see
+ * imapd.h's imsg_mbox_status
+ * comment), same "store always
+ * computes, listener decides
+ * whether to print" split as
+ * s->fetch_attrs. */
+ char status_mailbox[MBOX_NAME_MAX]; /* the mailbox
+ * name STATUS was asked about
+ * (RFC 9051 SS6.3.4-SS6.3.6's
+ * flat multi-mailbox support --
+ * before that, STATUS could only
+ * ever mean INBOX, so this field
+ * didn't need to exist) --
+ * stashed here so session_
+ * handle_mbox_status_result() can
+ * echo it back in the untagged
+ * "* STATUS <mailbox> (...)"
+ * response; store.c's own reply
+ * (struct imsg_mbox_status_
+ * result) has no mailbox field of
+ * its own to read it back from,
+ * since listener.c already knows
+ * what it asked. */
+ enum session_state status_prev_state; /* SESSION_AUTHENTICATED or
+ * SESSION_SELECTED -- same
+ * "STATUS is command-auth, valid
+ * in either state" reasoning as
+ * s->append_prev_state above, and
+ * for the identical reason:
+ * session_handle_mbox_status_
+ * result() needs to know which
+ * one to restore. */
+ enum session_state mbox_op_prev_state; /* SESSION_AUTHENTICATED or
+ * SESSION_SELECTED -- same
+ * "record whichever ST_AUTH
+ * state was current before,
+ * restore it after" pattern as
+ * s->status_prev_state just
+ * above, shared across all four
+ * of SESSION_CREATING/DELETING/
+ * RENAMING/LISTING (see that enum
+ * block's own comment) since only
+ * one of the four can ever be in
+ * flight at once for a given
+ * session. session_finish_mbox_
+ * op()/session_finish_list() both
+ * restore it. */
+ char list_pattern[2 * MBOX_NAME_MAX]; /* canonical
+ * LIST/LSUB pattern (reference
+ * concatenated with the mailbox
+ * pattern by list_dispatch(),
+ * same construction the old
+ * synchronous 'canon' local
+ * used) stashed across the
+ * SESSION_LISTING round trip so
+ * session_handle_mbox_list_
+ * item() can test each IMSG_
+ * MBOX_LIST_ITEM name against it
+ * as it streams in, one at a
+ * time -- unlike s->search_
+ * matches, nothing needs to be
+ * accumulated first, since each
+ * match can be turned into its
+ * untagged response
+ * immediately. */
+ int list_is_lsub; /* 1 if the in-flight SESSION_
+ * LISTING round trip was started
+ * by LSUB rather than LIST --
+ * picks the untagged response
+ * keyword and the tagged
+ * completion text, same is_lsub
+ * parameter list_dispatch()
+ * already threads through
+ * synchronously today. */
+ char rename_oldname[MBOX_NAME_MAX]; /* RENAME's
+ * two arguments, stashed by
+ * cmd_rename() across the
+ * SESSION_RENAMING round trip
+ * purely so session_finish_
+ * mbox_op() can compare
+ * rename_oldname against s->
+ * selected_mailbox on success --
+ * if this session had the
+ * just-renamed mailbox itself
+ * selected, its own s->selected_
+ * mailbox needs to follow along
+ * to rename_newname, the same
+ * way store.c's handle_mbox_
+ * rename() already makes its own
+ * cwd/current_mailbox_dir follow
+ * (see that function's own real-
+ * hardware-bug comment). Neither
+ * name is otherwise needed here
+ * -- req.oldname/req.newname
+ * already made the trip to
+ * store.c in the imsg itself. */
+ char rename_newname[MBOX_NAME_MAX];
+ uint32_t search_return_opts; /* SEARCH_RETURN_* bitmask
+ * for the SEARCH currently in
+ * flight (SESSION_SEARCHING) --
+ * which of MIN/MAX/ALL/COUNT
+ * session_finish_search() should
+ * include in the ESEARCH
+ * response; store.c never needs
+ * this, since it only computes
+ * *which* messages match, not
+ * how the client wants that
+ * reported. */
+ uint32_t *search_matches; /* malloc(3)'d/realloc(3)'d,
+ * growable -- ascending sequence
+ * numbers streamed in via
+ * IMSG_MBOX_SEARCH_MATCH, kept
+ * in full (not range-compacted
+ * until formatting time) so
+ * MIN/MAX/COUNT/ALL can all be
+ * derived from one array. Freed
+ * by session_finish_search() once
+ * the ESEARCH response is sent,
+ * or by session_teardown() on
+ * early disconnect. */
+ uint32_t search_nmatches; /* entries actually in
+ * search_matches */
+ uint32_t search_matches_cap; /* allocated capacity of
+ * search_matches, >= search_
+ * nmatches */
+ int search_alloc_failed; /* 1 if a realloc(3) in
+ * session_handle_mbox_search_
+ * match() ever failed for this
+ * SEARCH -- makes session_
+ * finish_search() send a NO
+ * instead of a silently
+ * incomplete ESEARCH response;
+ * see that function's comment on
+ * why "some matches dropped" is
+ * treated as a hard failure, not
+ * a best-effort partial result. */
+ int search_used_modseq; /* 1 if the SEARCH program
+ * currently in flight
+ * contained a MODSEQ
+ * search-key -- RFC 7162
+ * SS3.1.5/SS3.1.8: makes
+ * this SEARCH a CONDSTORE-
+ * enabling command, and
+ * makes session_finish_
+ * search() append "(MODSEQ
+ * n)" using the running
+ * max below. */
+ uint64_t search_max_modseq; /* highest modseq seen
+ * across all matches of
+ * the SEARCH currently in
+ * flight, tracked by
+ * session_handle_mbox_
+ * search_match() --
+ * store.c always sends
+ * modseq per match (cheap,
+ * see imapd.h's struct
+ * imsg_mbox_search_match
+ * comment), so this is
+ * just a running max, not
+ * conditional on search_
+ * used_modseq itself. */
+
+ /*
+ * RFC 7162 (CONDSTORE/QRESYNC) session state, added this pass.
+ * condstore_enabled/qresync_enabled are both sticky for the whole
+ * connection once set (SS3.1: "Once a client issues a CONDSTORE
+ * enabling command, it has announced itself... until the connection
+ * is closed"; SS3.2.3 says the same for QRESYNC) -- never cleared
+ * back to 0 anywhere in this file. qresync_enabled implies
+ * condstore_enabled is also 1 (SS3.2.3: "the presence of the
+ * 'QRESYNC' capability implies support for the CONDSTORE IMAP
+ * extension"), enforced at every place qresync_enabled gets set.
+ */
+ int condstore_enabled;
+ int qresync_enabled;
+
+ /*
+ * Best-known HIGHESTMODSEQ of the currently selected mailbox --
+ * cached from every IMSG_MBOX_SELECTED/IMSG_MBOX_RESULT reply that
+ * carries one (store.c always populates it; see imapd.h's
+ * imsg_mbox_selected/imsg_mbox_result comments), regardless of
+ * whether this session is CONDSTORE-aware yet. Exists specifically
+ * for session_condstore_enable()'s "CONDSTORE enabling command
+ * issued while a mailbox is already selected" case (RFC 7162 SS3.1:
+ * the server MUST emit an unsolicited HIGHESTMODSEQ OK response at
+ * that moment) -- without a cached value there would be nothing to
+ * report without an extra store round trip just for this edge case.
+ */
+ uint64_t mbox_highestmodseq;
+
+ /*
+ * RFC 9051 SS6.3.3: whether the currently selected mailbox was
+ * opened via EXAMINE (1) rather than SELECT (0) -- meaningful only
+ * while s->state == SESSION_SELECTED (and, transiently, SESSION_
+ * SELECTING/SESSION_EXPUNGING while a select_or_examine()/session_
+ * request_expunge() round trip is in flight). Set synchronously by
+ * select_or_examine() before the IMSG_MBOX_SELECT round trip even
+ * starts -- like s->state = SESSION_SELECTING itself, this doesn't
+ * need to wait for store.c's reply, since it's derived purely from
+ * which command the client typed, not from anything store.c knows.
+ * Consulted by session_handle_mbox_selected() (READ-ONLY/READ-WRITE
+ * response code, PERMANENTFLAGS) and by every command that would otherwise
+ * mutate the selected mailbox's permanent state -- store_do(),
+ * session_request_expunge(), copy_move_dispatch()'s is_move case --
+ * to refuse (or, for CLOSE specifically, silently no-op) per SS6.3.3
+ * ("No changes to the permanent state of the mailbox... are
+ * permitted") and SS6.4.1's CLOSE exception.
+ */
+ int mbox_readonly;
+ char selected_mailbox[MBOX_NAME_MAX]; /* which
+ * mailbox SESSION_SELECTED actually
+ * refers to -- didn't need to exist
+ * before RFC 9051 SS6.3.4-SS6.3.6's flat
+ * multi-mailbox support, since
+ * "selected" and "INBOX is selected"
+ * were the same fact. Written
+ * optimistically by select_or_examine()
+ * at the same point s->state moves to
+ * SESSION_SELECTING -- safe even though
+ * the SELECT/EXAMINE might still fail,
+ * because every reader of this field
+ * also gates on s->state ==
+ * SESSION_SELECTED first, and a failed
+ * SELECT/EXAMINE never reaches that
+ * state (RFC 9051 SS6.3.2: "the session
+ * is in authenticated state" on
+ * failure, deselected same as before
+ * the attempt). Consulted so far by
+ * session_handle_mbox_appended()'s own
+ * "is the mailbox APPEND just targeted
+ * the one actually selected right now"
+ * check -- see that function's
+ * comment. */
+
+ /*
+ * RFC 9051 SS6.3.13 (IDLE) v1 scope: EXISTS and EXPUNGE only (see
+ * imapd.h's imsg_mbox_idle_uid comment for the sourcing/reasoning
+ * behind not also pushing unsolicited FETCH). idle_known_uids is this
+ * session's own cached, ordered snapshot of "which UIDs existed as of
+ * the last time this session's view was authoritative" -- populated
+ * from an IMSG_MBOX_IDLE_REFRESH round trip, either the baseline one
+ * cmd_idle() triggers right after sending "+ idling" (idle_baseline_
+ * valid is 0 until that first one lands, so session_handle_idle_
+ * refreshed() knows not to diff against garbage), or a later one
+ * triggered by session_notify_idle_peers() when some *other* session
+ * belonging to the same uid successfully mutates the mailbox.
+ * Diffing this array against a freshly streamed IMSG_MBOX_IDLE_UID
+ * list is what lets session_handle_idle_refreshed() compute correct
+ * seqno-based "* N EXPUNGE" lines for whatever UIDs dropped out --
+ * same "position in the shrinking list, removed in ascending order"
+ * algorithm handle_mbox_expunge() already needs on the store side,
+ * just recomputed here in listener.c from two UID arrays instead of
+ * from the index file directly.
+ *
+ * idle_refresh_pending/idle_refresh_again exist because a refresh is
+ * itself an async imsg round trip: if a second trigger arrives while
+ * one is already in flight for this session (e.g. two sibling
+ * sessions both mutate in quick succession), there's no way to ask
+ * store.c to hurry up or to safely start a second overlapping
+ * request on the same store_iev channel -- idle_refresh_again just
+ * remembers to immediately re-request once the in-flight one's
+ * IMSG_MBOX_IDLE_REFRESHED reply lands, rather than either blocking
+ * or silently dropping the second trigger.
+ */
+ uint32_t *idle_known_uids;
+ uint32_t idle_known_nuids;
+ uint32_t idle_known_cap;
+ int idle_baseline_valid;
+ int idle_refresh_pending;
+ int idle_refresh_again;
+
+ /*
+ * Accumulates the freshly streamed IMSG_MBOX_IDLE_UID list for an
+ * in-flight refresh -- deliberately a *separate* array from idle_
+ * known_uids above, not built in place over it, since session_
+ * handle_idle_refreshed() needs both the old and the new list at
+ * once to diff them. Same "held back until the terminal reply"
+ * shape as s->vanished_ranges/s->qresync_fetches during a QRESYNC
+ * SELECT resync.
+ */
+ uint32_t *idle_incoming_uids;
+ uint32_t idle_incoming_n;
+ uint32_t idle_incoming_cap;
+
+ /*
+ * Accumulates VANISHED (EARLIER) ranges streamed in via zero or more
+ * IMSG_MBOX_SELECT_VANISHED during an in-flight QRESYNC SELECT
+ * resync (s->state == SESSION_SELECTING) -- store.c already reports
+ * these pre-compacted into ranges (see imsg_mbox_select_vanished's
+ * comment in imapd.h), so this array just collects them in
+ * arrival order (already ascending -- store.c streams its single
+ * forward pass in increasing-UID order) for session_handle_mbox_
+ * selected() to join into one combined "* VANISHED (EARLIER) ..."
+ * line once IMSG_MBOX_SELECTED arrives -- RFC 7162 SS3.2.6 requires
+ * every VANISHED (EARLIER) response to precede any FETCH response
+ * in the same resync, which is only guaranteed by holding all of it
+ * (and the FETCH data below) until the terminal reply, not by
+ * relaying each piece to the client as it streams in.
+ */
+ struct vanished_range *vanished_ranges;
+ uint32_t vanished_nranges;
+ uint32_t vanished_cap;
+
+ /*
+ * Accumulates the QRESYNC resync's per-message FETCH-with-UID-and-
+ * MODSEQ data, streamed in via IMSG_MBOX_FETCH_META while s->state
+ * == SESSION_SELECTING (see session_store_dispatch()'s IMSG_MBOX_
+ * FETCH_META case) -- held back for the same "VANISHED must precede
+ * FETCH" ordering reason vanished_ranges is. Holds full struct
+ * imsg_mbox_fetch_meta copies (not just seqno/uid, unlike search_
+ * matches) because session_send_qresync_fetch_response() needs
+ * FLAGS too -- heavier per-entry than search_matches, but v1-scale
+ * resyncs are small (this project's usual "personal use, modest
+ * mailbox size" tolerance).
+ */
+ struct imsg_mbox_fetch_meta *qresync_fetches;
+ uint32_t qresync_nfetches;
+ uint32_t qresync_fetches_cap;
+
+ /*
+ * COPY/MOVE (RFC 9051 SS6.4.7/SS6.4.8), SESSION_COPYING. Two parallel
+ * growable arrays accumulating each IMSG_MBOX_COPY_MAPPING streamed
+ * in during the round trip -- src_uid/dest_uid pairs, already
+ * ascending (store.c streams its single forward pass in increasing
+ * order, same guarantee vanished_ranges above relies on), range-
+ * compacted independently via format_seq_list() into COPYUID's two
+ * UID sets once the terminal reply arrives. cmd_is_move distinguishes
+ * which of COPY/MOVE is in flight (same session-state-plus-flag
+ * pattern as s->close_after_expunge for EXPUNGE/CLOSE) -- both share
+ * SESSION_COPYING since their in-flight bookkeeping is otherwise
+ * identical, differing only in the terminal formatting (see session_
+ * finish_copy_or_move()). move_expunged buffers each IMSG_MBOX_
+ * EXPUNGED that arrives during a MOVE specifically (rather than
+ * writing it to the client immediately, the way a real EXPUNGE/CLOSE
+ * does) -- SS6.4.8 requires COPYUID to precede any EXPUNGE/VANISHED
+ * for the same operation, which (like vanished_ranges/qresync_fetches
+ * above) is only guaranteed by holding everything until the terminal
+ * reply, not by relaying each piece as it streams in.
+ */
+ uint32_t *copy_src_uids;
+ uint32_t *copy_dest_uids;
+ uint32_t copy_n;
+ uint32_t copy_cap;
+ int copy_alloc_failed; /* same "don't silently drop
+ * a mapping" reasoning as
+ * s->search_alloc_failed */
+ int cmd_is_move;
+ struct imsg_mbox_expunged *move_expunged;
+ uint32_t move_expunged_n;
+ uint32_t move_expunged_cap;
+
+ /*
+ * Accumulates messages that failed a STORE's UNCHANGEDSINCE test,
+ * streamed in via zero or more IMSG_MBOX_STORE_MODIFIED while
+ * s->state == SESSION_STORING, for session_handle_mbox_result() to
+ * range-compact (format_seq_list(), same helper SEARCH/ESEARCH
+ * already use) into the tagged response's MODIFIED response code
+ * (RFC 7162 SS3.1.3). Holds sequence numbers for a plain STORE, UIDs
+ * for a UID STORE (SS3.1.3: "the message number (or unique
+ * identifier in the case of the UID STORE command)") -- a single
+ * array, not a parallel uid array, since exactly one of the two
+ * values is ever meaningful for a given STORE; see session_handle_
+ * store_modified()'s use of s->cmd_by_uid to pick which.
+ */
+ uint32_t *store_modified;
+ uint32_t store_modified_n;
+ uint32_t store_modified_cap;
+
+ /*
+ * RFC 9051 SS6.4.9 (UID command), added this pass: 1 if the async
+ * operation currently in flight (SESSION_FETCHING/STORING/SEARCHING/
+ * EXPUNGING) was dispatched via UID <cmd> rather than the bare
+ * command. Every entry point that starts one of those four states
+ * sets this explicitly (fetch_dispatch()/store_do()/search_dispatch()/
+ * session_request_expunge()), so a stale value from a previous,
+ * different-mode command can never leak into the next one -- there is
+ * no "clear it back to 0" step anywhere else, matching this struct's
+ * existing condstore_enabled/qresync_enabled precedent of "every
+ * setter is unconditional, so no separate reset is needed."
+ *
+ * Consulted by: session_send_store_fetch_response() (UID STORE's
+ * FETCH echo must include UID, SS6.4.9's "MUST implicitly include
+ * the UID message data item"), session_handle_mbox_search_match()/
+ * session_finish_search() (UID SEARCH reports UIDs instead of
+ * sequence numbers, plus the "UID" ESEARCH correlator token),
+ * session_handle_store_modified() (UID STORE's MODIFIED response
+ * code lists UIDs, not sequence numbers -- RFC 7162 SS3.1.3: "the
+ * message number (or unique identifier in the case of the UID STORE
+ * command)"), and session_handle_mbox_result() (every one of the four
+ * commands' tagged completion text becomes "UID <CMD> completed" --
+ * RFC 9051's own SS6.4.9 example shows "UID FETCH completed"
+ * verbatim; UID EXPUNGE's own worked example shows "UID EXPUNGE
+ * completed"; UID STORE/UID SEARCH aren't shown as literal worked
+ * examples but follow the same "UID <cmd> completed" pattern by
+ * direct symmetry with those two, not fabricated). Plain FETCH
+ * doesn't need to consult this at all -- forcing MBOX_FETCH_UID into
+ * s->fetch_attrs at dispatch time (see fetch_dispatch()) already
+ * makes session_send_fetch_response()'s existing attrs-driven UID
+ * printing do the right thing with no further changes there.
+ */
+ int cmd_by_uid;
+
+ TAILQ_ENTRY(session) entry;
+};
+
+/*
+ * Named (not anonymous) so the type matches between this extern
+ * declaration and listener.c's actual definition -- TAILQ_HEAD(, session)
+ * with an empty name expands to a distinct anonymous struct at each use,
+ * which the original single-file listener.c got away with (only one use
+ * existed at all) but which is a hard redefinition error once that one
+ * use is split across a header and a .c file.
+ */
+TAILQ_HEAD(session_list, session);
+
+/*
+ * Globals shared across the files this process's source is split into
+ * (definitions live in listener.c's core; see that file for why each
+ * exists).
+ */
+extern struct session_list sessions;
+extern struct imsgev iev_auth;
+extern struct imsgev iev_parent;
+extern struct tls *listener_tls_ctx;
+
+/*
+ * Cross-file entry points. This is the same set of forward declarations
+ * listener.c carried as `static` prototypes before the file was split
+ * into listener.c (core) + auth_cmd.c + mailbox_cmd.c + append_cmd.c +
+ * fetch_cmd.c + search_cmd.c + store_cmd.c + store_ipc.c -- moved here
+ * verbatim (minus `static`, which no longer applies once callers and
+ * callees can live in different translation units) rather than
+ * redesigned, so this split is a pure move with no behavior change.
+ */
+ void listener_accept(int, short, void *);
+ void listener_dispatch_auth(int, short, void *);
+ void listener_dispatch_parent(int, short, void *);
+ void listener_reload_tls(const char *, size_t, const char *,
+ size_t);
+ void session_dispatch_client(int, short, void *);
+ void session_store_dispatch(int, short, void *);
+ void session_handle_mbox_selected(struct session *,
+ const struct imsg_mbox_selected *);
+ void session_handle_mbox_status_result(struct session *,
+ const struct imsg_mbox_status_result *);
+ void session_send_fetch_response(struct session *,
+ struct imsg_mbox_fetch_meta *);
+ void session_send_store_fetch_response(struct session *,
+ const struct imsg_mbox_fetch_meta *);
+ void session_send_expunge_response(struct session *,
+ const struct imsg_mbox_expunged *);
+ void session_handle_mbox_result(struct session *,
+ struct imsg_mbox_result *);
+ int session_request_expunge(struct session *, const char *,
+ int, int, uint32_t, uint32_t, int, int);
+ int session_finish_append(struct session *);
+ void session_handle_mbox_appended(struct session *,
+ const struct imsg_mbox_appended *);
+/*
+ * Real, initialized definition lives in fetch_cmd.c alongside
+ * format_internaldate() itself; append_cmd.c's parse_date_time() and
+ * search_cmd.c's parse_search_date() both reuse it for the reverse
+ * (name-to-index) direction. The original single-file listener.c got
+ * away with a same-file tentative "const char *fetch_month_names[12];"
+ * forward declaration ahead of its real, initialized definition further
+ * down -- valid C, but only within one translation unit; split across
+ * files, that trick doesn't reach across them, so this needs to be a
+ * real extern instead.
+ */
+extern const char *fetch_month_names[12];
+ void format_internaldate(int64_t, char *, size_t);
+ int parse_nz_number(const char *, uint32_t *);
+ int parse_seq_range(const char *, uint32_t *, uint32_t *,
+ int *, int *);
+ int parse_fetch_atts(char *, uint32_t *, int *, int *, char *,
+ size_t, char *, size_t, int *, char *, size_t, int *,
+ uint32_t *, uint32_t *, const char **);
+ int parse_header_fields_att(const char *, int *, char *, size_t);
+ int section_part_valid(const char *);
+ int parse_partial_suffix(const char *, int *, uint32_t *,
+ uint32_t *);
+ int parse_store_flags(char *, uint32_t *, char *, size_t,
+ const char **);
+ int parse_date_time(const char *, int64_t *);
+struct append_parsed;
+ int parse_append_args(char *, struct append_parsed *,
+ const char **);
+ int parse_search_date(const char *, int64_t *);
+struct search_parse_ctx;
+ int parse_search_key(char **, struct search_parse_ctx *,
+ const char **);
+ int parse_search_key_inner(char **, struct search_parse_ctx *,
+ const char **);
+ int parse_search_key_list(char **, struct search_parse_ctx *,
+ const char **, int);
+ int parse_search_return_opts(char **, uint32_t *, const char **);
+ void session_handle_mbox_search_match(struct session *,
+ struct imsg_mbox_search_match *);
+ void session_finish_search(struct session *,
+ struct imsg_mbox_result *);
+ void session_send_greeting(struct session *);
+ void session_teardown(struct session *);
+ struct session *session_find(uint32_t);
+
+/* RFC 7162 (CONDSTORE/QRESYNC) helpers, added this pass. */
+ void session_condstore_enable(struct session *);
+ size_t format_seq_list(char *, size_t, const uint32_t *, uint32_t,
+ int *);
+ size_t format_range_list(char *, size_t,
+ const struct vanished_range *, uint32_t, int *);
+ void session_send_qresync_fetch_response(struct session *,
+ const struct imsg_mbox_fetch_meta *);
+ void session_handle_select_vanished(struct session *,
+ const struct imsg_mbox_select_vanished *);
+ void session_handle_select_fetch(struct session *,
+ const struct imsg_mbox_fetch_meta *);
+ void session_handle_store_modified(struct session *,
+ struct imsg_mbox_store_modified *);
+ void session_handle_idle_uid(struct session *,
+ const struct imsg_mbox_idle_uid *);
+ void session_handle_idle_refreshed(struct session *,
+ const struct imsg_mbox_idle_refreshed *);
+ void session_request_idle_refresh(struct session *);
+ void session_push_idle_expunges(struct session *,
+ const uint32_t *, uint32_t, const uint32_t *, uint32_t);
+ void session_notify_idle_peers(const struct session *);
+ int session_handle_idle_continuation(struct session *, const char *);
+ int parse_select_params(char *, struct imsg_mbox_select *,
+ const struct session *, int *, const char **);
+ int parse_qresync_group(char *, struct imsg_mbox_select *,
+ const char **);
+ char *split_trailing_modifiers(char *);
+ int parse_fetch_modifiers(char *, struct imsg_mbox_fetch *,
+ const struct session *, int, int *, const char **);
+ int parse_store_modifiers(char *, struct imsg_mbox_store *,
+ const char **);
+
+/* RFC 9051 SS6.4.9 (UID command) helpers, added this pass. */
+ int fetch_dispatch(struct session *, const char *, char *, int);
+ int store_do(struct session *, const char *, char *, int);
+ int search_dispatch(struct session *, const char *, char *, int);
+ int uid_expunge_dispatch(struct session *, const char *, const char *);
+ void session_handle_fetch_vanished(struct session *,
+ const struct imsg_mbox_select_vanished *);
+
+/* RFC 9051 SS6.4.7/SS6.4.8 (COPY/MOVE) helpers, added this pass. */
+ 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 list_pattern_match(const char *, const char *, int);
+ int parse_list_token(char **, char *, size_t, const char **);
+ void session_finish_mbox_op(struct session *,
+ const struct imsg_mbox_result *);
+ void session_finish_list(struct session *,
+ const struct imsg_mbox_result *);
+ void session_handle_mbox_list_item(struct session *,
+ const struct imsg_mbox_list_item *);
+ void session_handle_mbox_copy_mapping(struct session *,
+ const struct imsg_mbox_copy_mapping *);
+ void session_finish_copy_or_move(struct session *,
+ const struct imsg_mbox_result *);
+
+ void session_tls_start(struct session *);
+ void session_tls_handshake(int, short, void *);
+ void session_arm_client_read(struct session *);
+
+ void session_write(struct session *, const char *, size_t);
+ void session_reply(struct session *, const char *, const char *,
+ const char *);
+ void session_untagged(struct session *, const char *);
+ int parse_command_line(char *, char **, char **, char **);
+ int session_handle_line(struct session *, char *);
+ int session_dequeue_next(struct session *);
+ int session_handle_auth_continuation(struct session *, const char *);
+ int sasl_plain_finish(struct session *, const char *,
+ const char *, int);
+
+ int cmd_capability(struct session *, const char *, char *);
+ int cmd_noop(struct session *, const char *, char *);
+ int cmd_logout(struct session *, const char *, char *);
+ int cmd_id(struct session *, const char *, char *);
+ int cmd_login(struct session *, const char *, char *);
+ int cmd_starttls(struct session *, const char *, char *);
+ int cmd_authenticate(struct session *, const char *, char *);
+
+/* command-auth (RFC 9051 SS6.3 -- valid in Authenticated or Selected
+ * state): ENABLE is real (see cmd_enable()'s comment for why that's a
+ * complete implementation, not a stub, for v1). Everything else here
+ * needs a mailbox layer that doesn't exist yet -- store.c's IMSG_MBOX_*
+ * family is entirely unimplemented (see store.c's own header comment),
+ * and its wire payload shapes aren't designed, so these all reply a
+ * generic NO via stub_not_implemented() rather than doing anything real.
+ * They're in the dispatch table anyway, correctly gated by session
+ * state, because "recognized command, can't do it right now" (NO) and
+ * "not a real IMAP command at all" (BAD, session_handle_line()'s unknown-
+ * command fallback) are different, both spec-meaningful responses. */
+ int stub_not_implemented(struct session *, const char *,
+ const char *);
+ int cmd_enable(struct session *, const char *, char *);
+ int cmd_select(struct session *, const char *, char *);
+ int cmd_examine(struct session *, const char *, char *);
+ int cmd_create(struct session *, const char *, char *);
+ int cmd_delete(struct session *, const char *, char *);
+ int cmd_rename(struct session *, const char *, char *);
+ int cmd_subscribe(struct session *, const char *, char *);
+ int cmd_unsubscribe(struct session *, const char *, char *);
+ int cmd_list(struct session *, const char *, char *);
+ int cmd_lsub(struct session *, const char *, char *);
+ int cmd_namespace(struct session *, const char *, char *);
+ int cmd_status(struct session *, const char *, char *);
+ int cmd_append(struct session *, const char *, char *);
+ int cmd_idle(struct session *, const char *, char *);
+
+/* command-select (RFC 9051 SS6.4 -- valid only in Selected state).
+ * SESSION_SELECTED is currently unreachable (SELECT itself is a stub
+ * above), so none of these can be dispatched to yet in practice -- listed
+ * for the same "correctly gated, honestly stubbed" reason as command-auth
+ * above. */
+ int cmd_close(struct session *, const char *, char *);
+ int cmd_unselect(struct session *, const char *, char *);
+ int cmd_expunge(struct session *, const char *, char *);
+ int cmd_search(struct session *, const char *, char *);
+ int cmd_fetch(struct session *, const char *, char *);
+ int cmd_store_cmd(struct session *, const char *, char *);
+ int cmd_copy(struct session *, const char *, char *);
+ int cmd_move(struct session *, const char *, char *);
+ int cmd_uid(struct session *, const char *, char *);
+
+#endif /* LISTENER_H */
blob - d53b32ebb2cc3d19a0d7784be92978875707d759
blob + b0c7909da499fde858f7d85ef79e114888e3e7dc
--- src/store.c
+++ src/store.c
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
-/*
- * store.c -- mailbox-store, filesystem process. Implements the
- * "mailbox-store" section of openimap-privsep-design.md's fork-per-session
- * design: a fresh process per authenticated session, privilege-dropped to
- * that session's own uid/gid (via IMSG_STORE_INIT, received before any
- * chroot/pledge/unveil -- see store_main() below), then wired to listener
- * over a dedicated peer channel per the same SETUP_PEER/SETUP_DONE
- * mechanism used at boot for listener<->auth.
- *
- * IMSG_MBOX_SELECT is now real: index_load()/index_append()/index_save()
- * implement the line-oriented UIDVALIDITY:UIDNEXT + UID:basename:keywords
- * index format resolved in openimap-storage-backend.md (flock(2)-guarded,
- * full-rewrite-to-temp-file-then-rename(2) on every mutation), and
- * handle_mbox_select() -- v1 is INBOX-only, openimap-v1-dispatch.md --
- * scans new/ for not-yet-indexed messages and assigns them UIDs on every
- * SELECT, per that document's own "notice externally-delivered files in
- * new/ ... assign UID via index" note. IMSG_MBOX_FETCH (message metadata:
- * FLAGS/UID/INTERNALDATE/RFC822.SIZE) and IMSG_MBOX_STORE (set/add/remove
- * flags, RFC 9051 SS6.4.6) are real too now -- handle_mbox_fetch() and
- * handle_mbox_store(), the latter also responsible for keeping a message's
- * maildir flag-suffix letters and cur//new/ placement in sync with its
- * flags, since the filename is maildir's sole source of truth for system
- * flags (openimap-storage-backend.md's "no standard-flag caching in the
- * index" decision). IMSG_MBOX_EXPUNGE (RFC 9051 SS6.4.3 -- also used, with
- * silent=1, by CLOSE) is real too -- handle_mbox_expunge() permanently
- * unlinks every \Deleted message and compacts the index in a single
- * lower-numbered-first pass, which is also what produces SS7.5.1's
- * "immediately decremented" per-message sequence numbers for free
- * (verified against both of SS6.4.3/SS7.5.1's own worked examples).
- * IMSG_MBOX_APPEND (RFC 9051 SS6.3.12) is real too now -- handle_mbox_
- * append() delivers a new message via maildir's tmp/-then-cur/ atomic
- * rename(2), assigns it a UID from the index (index_append(), same path
- * handle_mbox_select() already uses for externally-delivered mail), and
- * writes the index BEFORE the tmp->cur rename so a crash between the two
- * leaves an indexed-but-not-yet-visible message rather than a delivered-
- * but-unindexed one -- consistent with this file's existing tolerance
- * elsewhere for "indexed but missing on disk" over the reverse. The
- * message body itself arrives as variable-length trailing data on the
- * same imsg as its struct imsg_mbox_append header (see that struct's
- * comment in imapd.h for the imsg_get_buf()/imsg_get_len() wire-
- * protocol verification this relies on); listener.c caps what it will
- * ever send to APPEND_LITERAL_MAX (12000 bytes) to guarantee that fits.
- * IMSG_MBOX_SEARCH (RFC 9051 SS6.4.4) is real too now -- handle_mbox_
- * search() reads a flat postfix bytecode (struct search_node array,
- * compiled by listener.c's parse_search_key()/parse_search_key_list()
- * from the client's search-program, same variable-length-trailing-data
- * imsg technique as IMSG_MBOX_APPEND) and evaluates it against every
- * message via search_eval()/search_eval_leaf(), streaming matches back
- * as IMSG_MBOX_SEARCH_MATCH. Covers flag-based (ANSWERED/DELETED/DRAFT/
- * FLAGGED/SEEN and their UN- forms), KEYWORD/UNKEYWORD, BEFORE/ON/SINCE
- * (internal date), LARGER/SMALLER (size), sequence-set and UID-range
- * search keys, plus NOT/OR/parenthesized-AND-list combination -- not
- * the content-and-header-based keys (BCC/BODY/CC/FROM/HEADER/
- * SENTBEFORE/SENTON/SENTSINCE/SUBJECT/TEXT/TO), which listener.c rejects
- * before this imsg is ever built (see imapd.h's imsg_mbox_search
- * comment for the full v1-scope reasoning). This paragraph is a snapshot
- * of the state as of the SEARCH pass; every other IMSG_MBOX_* case in
- * store_dispatch()'s switch below (COPY/MOVE -- including cross-mailbox
- * -- STATUS, UID forms, CONDSTORE/QRESYNC, NAMESPACE, CREATE/DELETE/
- * RENAME, EXAMINE, IDLE) has since been implemented in later passes; see
- * README.skeleton for the full history rather than trusting this
- * comment's age. Privilege drop uses the
- * actual uid/gid/spool_root/maildir delivered at runtime (maildir being
- * new this pass too -- see imapd.h's imsg_store_init comment for the
- * gap that closed), and unveil(2) is now scoped to this session's own
- * maildir subdirectory specifically, not the whole shared chroot.
- *
- * API NAMES: checked against the real src/imsg.h this session -- see
- * parent.c's header comment for the full verification note.
- */
+/* store.c -- mailbox-store process: one per session, privilege-dropped, handles IMSG_MBOX_*. */
#include <sys/types.h>
#include <sys/file.h>
#include "imapd.h"
#include "log.h"
+#include "store_internal.h"
static struct imsgev iev_listener;
-static uint32_t session_id;
-static uint32_t bodystructure_read_max; /* from IMSG_STORE_INIT --
- * see imapd.h's struct imsg_store_init
- * comment and BODYSTRUCTURE_READ_
- * DEFAULT's comment for what this
- * gates and where the operator-
- * configured value comes from. */
-/*
- * Per-store-child monotonic counter feeding the maildir basename uniquer
- * (see handle_mbox_append()'s header comment for the full `<timestamp>.
- * <pid>_<counter>.<hostname>` scheme). Was originally a function-local
- * static inside handle_mbox_append() alone; hoisted to file scope this
- * pass so handle_mbox_copy() -- COPY's own message-duplication path, which
- * mints a brand new basename per copied message the same way APPEND does
- * -- shares the same monotonic sequence rather than starting its own at 0,
- * which could otherwise collide with an APPEND's basename minted in the
- * same second by the same pid.
- */
-static uint32_t append_counter;
+/* definitions for store_internal.h's extern globals, shared across the split store_*.c units */
+uint32_t session_id;
+uint32_t append_counter;
+char current_mailbox_dir[MBOX_NAME_MAX];
+uint32_t bodystructure_read_max;
-/*
- * RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 addition (flat multi-mailbox support,
- * see docs/openimap-storage-backend.md's "Open items" #10): which named
- * mailbox (if any) this store child's cwd is currently chdir'd into,
- * relative to the session's own maildir root. Empty string means "at the
- * root" -- i.e. INBOX, or nothing selected yet -- the same state every
- * store child has always implicitly been in before this pass, when INBOX
- * was the only mailbox that could ever exist. handle_mbox_select() is the
- * only function that ever changes this; every other IMSG_MBOX_* handler
- * (FETCH/STORE/EXPUNGE/IDLE-refresh/QRESYNC/...) keeps using bare
- * relative paths exactly as before, unmodified, since cwd is always
- * "wherever the currently selected mailbox is" by construction.
- */
-static char current_mailbox_dir[MBOX_NAME_MAX];
-
-static void store_dispatch(int, short, void *);
-static void store_shutdown(void);
-static void handle_mbox_select(struct imsg_mbox_select *, struct imsgev *);
-static void handle_mbox_fetch(struct imsg_mbox_fetch *, struct imsgev *);
-static void handle_mbox_store(struct imsg_mbox_store *, struct imsgev *);
-static void handle_mbox_expunge(struct imsg_mbox_expunge *, struct imsgev *);
-static void handle_mbox_append(struct imsg_mbox_append *, const char *,
- size_t, struct imsgev *);
-static void handle_mbox_search(struct imsg_mbox_search *,
- struct search_node *, uint32_t, struct imsgev *);
-static void handle_mbox_status(struct imsg_mbox_status *, struct imsgev *);
-static void handle_mbox_copy(struct imsg_mbox_copy *, struct imsgev *);
-static void handle_mbox_move(struct imsg_mbox_copy *, struct imsgev *);
-static void handle_mbox_create(struct imsg_mbox_create *, struct imsgev *);
-static void handle_mbox_delete(struct imsg_mbox_delete *, struct imsgev *);
-static void handle_mbox_rename(struct imsg_mbox_rename *, struct imsgev *);
-static void handle_mbox_list(struct imsgev *);
-static int mailbox_name_valid(const char *);
-static int mailbox_name_is_inbox(const char *);
-static int select_mailbox_dir(const char *);
-static int locate_message_file(const char *, off_t *, char *, size_t);
-static int open_message_file(const char *);
-static int read_message_header(const char *, char **, uint32_t *);
-static int read_message_body(const char *, int, size_t, const char *,
- char **, uint32_t *);
-static int header_field_name_matches(const char *, size_t, const char *);
-static int read_message_header_fields(const char *, const char *, int,
- char **, uint32_t *);
-static void build_flags_string(const char *, const char *, char *,
- size_t);
-static int extract_header_field(const char *, size_t, const char *,
- char **, size_t *);
-static int envbuf_append(char *, size_t, size_t *, const char *, size_t);
-static int envbuf_append_str(char *, size_t, size_t *, const char *);
-static int envbuf_append_nstring(char *, size_t, size_t *, const char *,
- size_t);
-static int envbuf_append_one_address(char *, size_t, size_t *,
- const char *, size_t);
-static int envbuf_append_address_list(char *, size_t, size_t *,
- const char *, size_t);
-static int append_field_nstring(char *, size_t, size_t *, const char *,
- uint32_t, const char *);
-static int build_envelope(const char *, char **, uint32_t *);
-static int find_header_body_split(const char *, size_t, size_t *);
-static int mime_is_tspecial(char);
-static int mime_read_token_or_qstring(const char *, size_t, size_t *,
- char *, size_t);
-static void mime_str_upper(char *);
-static int parse_content_type(const char *, size_t, char *, size_t,
- char *, size_t, char *, size_t, char *, size_t, int *);
-static int split_multipart(const char *, size_t, const char *,
- size_t *, size_t *, int *, int);
-static int build_body_structure(int, int *, const char *, size_t,
- const char *, size_t, char *, size_t, size_t *);
-static int build_bodystructure(const char *, char **, uint32_t *);
-static int parse_section_part(const char *, int *, int);
-static int find_mime_part(int, const char *, size_t, const char *,
- size_t, const int *, int, const char **, size_t *);
-static int locate_mime_part(const char *, size_t, const char *, size_t,
- const int *, int, const char **, size_t *);
-static void apply_partial_range(const char *, size_t, int, uint32_t,
- uint32_t, const char **, size_t *);
-static int extract_mime_part(const char *, const int *, int, int,
- uint32_t, uint32_t, char **, uint32_t *);
-
-/*
- * The maildir+index format resolved in openimap-storage-backend.md's
- * "Recommendation" section: stock maildir (tmp/new/cur) for message
- * bodies, plus one small line-oriented text index per mailbox holding
- * UIDVALIDITY/UIDNEXT and the UID<->basename(<->keywords) map. v1 is
- * INBOX-only (openimap-v1-dispatch.md), and that index lives directly in
- * the maildir root store.c is now chdir'd into -- a sibling of tmp/new/
- * cur, not inside any of them. Name is this implementation's own choice
- * (the design doc never picked one): plain, `ls`-visible, matching the
- * document's own stated preference for boring/inspectable formats over
- * hidden dotfiles.
- */
-#define STORE_INDEX_NAME "imapd.index"
-#define STORE_INDEX_TMP_NAME "imapd.index.tmp"
-#define STORE_INDEX_LINE_MAX 1024 /* matches auth.c's cred_lookup()
- * line-buffer precedent for the same
- * kind of small, personal-scope flat
- * text file */
-
-/*
- * In-memory copy of one mailbox's index while a single IMSG_MBOX_SELECT
- * (or, later, any other mutating mbox op) is being handled -- built fresh
- * from the on-disk file, mutated, and rewritten in full per the design
- * doc's resolved "whole file rewritten to a temp file and rename(2)'d
- * over on every mutation" rule, then discarded. Never kept around between
- * imsg messages.
- */
-struct mbox_index {
- uint32_t uidvalidity;
- uint32_t uidnext;
- uint64_t highestmodseq; /* RFC 7162 SS3.1: per-mailbox highest
- * mod-sequence, persisted as the
- * index header's third field (see
- * index_load()/index_save()). v1's
- * only mailbox always supports this
- * (there is no on-disk format that
- * predates it in a real deployment of
- * this not-yet-released server), so
- * the NOMODSEQ response code
- * (RFC 7162 SS3.1.2.2) is simply
- * unreachable in this implementation. */
- char **lines; /* raw "UID:basename:keywords:MODSEQ"
- * lines, no trailing newline, one
- * malloc(3) each -- the MODSEQ field
- * is this pass's addition; see
- * index_parse_line() */
- size_t nlines;
- size_t cap;
-};
-
-/*
- * One index message line, parsed. Introduced this pass (RFC 7162) to stop
- * handle_mbox_fetch()/handle_mbox_store()/handle_mbox_expunge() from each
- * hand-rolling their own "UID:basename:keywords" strchr() chain -- adding
- * a fourth colon-delimited field (MODSEQ) to every one of those independently
- * would have tripled the size of this change and the chance of one of them
- * drifting out of sync with the on-disk format. basename/keywords are
- * fixed-size copies (same 512-byte bound index_load()'s own STORE_INDEX_
- * LINE_MAX line buffer already implies is generous for either field), not
- * pointers into the original line -- callers are free to mutate or discard
- * the line string after parsing.
- *
- * Backward compatibility: the MODSEQ field is new this pass. A line with
- * only three colon-delimited fields (written by a pre-CONDSTORE build of
- * this server) parses successfully with modseq defaulted to 1 -- this
- * server has never had a real deployment to be compatible *with*, so this
- * is a defensive nicety, not a migration guarantee this project is making
- * any promise about.
- */
-struct index_rec {
- uint32_t uid;
- char basename[512];
- char keywords[512];
- uint64_t modseq;
-};
-
-static int index_load(int, struct mbox_index *);
-static int index_has_basename(struct mbox_index *, const char *);
-static int index_append(struct mbox_index *, uint32_t, const char *);
-static int index_save(struct mbox_index *);
-static void index_free(struct mbox_index *);
-static int index_parse_line(const char *, struct index_rec *);
-static uint32_t index_max_uid(struct mbox_index *);
-static void send_vanished_range(struct mbox_index *, uint32_t, uint32_t,
- struct imsgev *);
-static int refresh_index(struct mbox_index *, int);
-static void handle_mbox_idle_refresh(struct imsgev *);
-
__dead void
store_main(void)
{
int peer_fd;
gid_t gid;
- /* store children don't use imapd.conf directly -- no struct
- * openimap_config * parameter at all, same as listener_main()/
- * auth_main() now (see main.c's comment and imapd.h's prototype
- * comment). Everything store needs (uid/gid/spool_root) arrives via
- * IMSG_STORE_INIT below instead. */
+ /* store children take no imapd.conf; uid/gid/spool_root arrive via IMSG_STORE_INIT below instead */
if (imsgbuf_init(&ibuf3, 3) == -1)
fatal("imsgbuf_init");
- imsgbuf_allow_fdpass(&ibuf3); /* receives the fd-passed
- * IMSG_SETUP_PEER peer fd below -- see
- * imsgev.c's imsgev_init() comment. */
+ imsgbuf_allow_fdpass(&ibuf3); /* for the SETUP_PEER peer fd below */
- /*
- * IMSG_STORE_INIT must be the first message read -- this is the
- * one departure from the generic setup_recv_*() helpers used by
- * listener.c/auth.c: a per-session store child's privilege target
- * is a runtime value, not fixed by its role, so it needs this
- * extra message before it can even chroot(). See the design doc's
- * "Departure #2" for the "why".
- */
- /*
- * imsg_get() before imsgbuf_read() -- see imsgev.c's
- * setup_recv_one_peer() header comment for the real deadlock this
- * ordering caused elsewhere (two back-to-back sends on the same
- * SOCK_STREAM socketpair coalescing into one recvmsg(), leaving a
- * complete message sitting unread in userspace while a later,
- * naive imsgbuf_read()-first loop blocks forever for bytes that
- * already arrived). Applied here defensively for the same reason
- * auth.c's IMSG_AUTH_INIT read was: nothing currently forces
- * IMSG_STORE_INIT and this session's IMSG_SETUP_PEER into separate
- * reads.
- */
+ /* IMSG_STORE_INIT must be read first: the privilege target is a runtime value, needed before chroot() */
for (;;) {
if ((n = imsg_get(&ibuf3, &imsg)) == -1)
fatal("imsg_get");
if (chdir("/") == -1)
fatal("chdir /");
- /*
- * The actual privilege drop this whole per-session design exists
- * for: setresuid/setresgid to the AUTHENTICATED USER's own uid/gid
- * (not a fixed service account), so a bug in this process's
- * FETCH/STORE handling -- once that exists -- is confined by the
- * kernel's own uid permission checks to this one user's files, per
- * openimap-privsep-design.md's fork-per-session resolution.
- */
+ /* drop to the authenticated user's own uid/gid so a bug here is confined to this user's files */
gid = init.gid;
if (setgroups(1, &gid) == -1 ||
setresgid(init.gid, init.gid, init.gid) == -1 ||
fatal("session %u: cannot drop privileges to uid %u gid %u",
session_id, init.uid, init.gid);
- /* Now privilege-dropped: finish the handshake exactly like a
- * boot-time child (one peer -- listener -- then SETUP_DONE+ack). */
+ /* privilege-dropped now; finish handshake like a boot-time child (one peer, then SETUP_DONE+ack) */
peer_fd = setup_recv_one_peer(&ibuf3);
setup_recv_done_and_ack(&ibuf3);
event_init();
imsgev_init(&iev_listener, peer_fd, store_dispatch, NULL);
- /*
- * unveil() scoped to THIS session's own mailbox subdirectory, not
- * the whole (shared, multi-user) chroot -- tightened this pass now
- * that init.maildir actually arrives here (see imapd.h's
- * imsg_store_init comment: it didn't, until now). The chroot alone
- * was already shared across every store child regardless of user
- * (one global spool_root from imapd.conf); unveil is this
- * project's own established tool for narrowing a process's
- * filesystem view further than chroot can (auth.c does the same
- * thing -- unveils only its one credential-file path, not its
- * whole chroot directory) and the fork-per-session privilege drop
- * this file's header comment describes is exactly the kind of
- * "confine a bug in this one session's handling to this one
- * session's own files" reasoning unveil is for. This is this
- * implementation's own judgment call, not something openimap-
- * privsep-design.md's unveil discussion spells out explicitly --
- * that document argues for keeping unveil() at all (vs. relying on
- * chroot alone), not for which specific path to scope it to.
- */
+ /* unveil() scoped to THIS session's own mailbox subdir, narrowing the view past chroot alone */
{
char unveil_path[sizeof(init.maildir) + 1];
if (unveil(unveil_path, "rwc") == -1)
fatal("unveil %s", unveil_path);
- /*
- * chdir() into the unveiled directory now, once, so every
- * IMSG_MBOX_* handler below can use bare relative paths
- * ("imapd.index", "new/<basename>", ...) instead of
- * threading init.maildir through every single one of them.
- * unveil(2)'s restriction is on the resolved path, not the
- * literal argument a syscall is given, so a relative open()
- * against this cwd still resolves inside the path just
- * unveiled above -- same reasoning parent.c/auth.c/listener.c
- * already rely on after their own chroot()+chdir("/") pairs,
- * just one directory deeper here.
- */
+ /* chdir in once so every handler below can use bare relative paths under the unveiled dir */
if (chdir(unveil_path) == -1)
fatal("chdir %s", unveil_path);
}
if (unveil(NULL, NULL) == -1)
fatal("unveil lock");
- /*
- * Resolved pledge string (openimap-privsep-design.md, checked
- * against smtpd's queue.c and the fork-per-session concurrency
- * argument in openimap-storage-backend.md): flock for the index
- * read-modify-write cycle, rpath/wpath/cpath for maildir's
- * rename(2)-based delivery and flag-suffix changes, no fattr
- * (nothing here calls chmod/utimes/chflags).
- */
+ /* flock for index r-m-w, rpath/wpath/cpath for delivery+renames; no fattr (no chmod/utimes here) */
#ifdef __OpenBSD__
if (pledge("stdio rpath wpath cpath flock recvfd sendfd", NULL)
== -1)
event_dispatch();
fatalx("store: exited event loop");
-}
-
-/*
- * Reads the index at STORE_INDEX_NAME (already open on fd, already
- * flock(2)'d LOCK_EX by the caller) into *idx. An empty file (including
- * one that was just created by handle_mbox_select()'s O_CREAT, i.e. this
- * mailbox has never been indexed before) is not an error -- it means a
- * fresh UIDVALIDITY/UIDNEXT get assigned, per RFC 9051 SS2.3.1.1: "A good
- * UIDVALIDITY value to use is a 32-bit representation of the current
- * date/time when the value is assigned: this ensures that the value is
- * unique and always increases." UIDNEXT starts at 1, since UIDs are
- * "unsigned non-zero" (same section).
- *
- * Reads via a dup(2)'d fd wrapped in stdio -- fclose() below closes the
- * dup, not the caller's original fd, so the caller's flock(2) (which is
- * associated with the open file description, and would be dropped by
- * closing every fd referencing it) survives this function returning.
- */
-static int
-index_load(int fd, struct mbox_index *idx)
-{
- FILE *fp;
- char line[STORE_INDEX_LINE_MAX];
- int dupfd;
- int first = 1;
-
- memset(idx, 0, sizeof(*idx));
-
- if (lseek(fd, 0, SEEK_SET) == -1) {
- log_warn("session %u: lseek %s", session_id, STORE_INDEX_NAME);
- return (-1);
- }
- if ((dupfd = dup(fd)) == -1) {
- log_warn("session %u: dup %s", session_id, STORE_INDEX_NAME);
- return (-1);
- }
- if ((fp = fdopen(dupfd, "r")) == NULL) {
- log_warn("session %u: fdopen %s", session_id, STORE_INDEX_NAME);
- close(dupfd);
- return (-1);
- }
-
- while (fgets(line, sizeof(line), fp) != NULL) {
- line[strcspn(line, "\n")] = '\0';
- if (line[0] == '\0')
- continue;
-
- if (first) {
- char *colon, *colon2, *ep;
-
- first = 0;
- if ((colon = strchr(line, ':')) == NULL) {
- log_warnx("session %u: malformed index "
- "header: %s", session_id, line);
- fclose(fp);
- return (-1);
- }
- *colon = '\0';
- errno = 0;
- idx->uidvalidity = (uint32_t)strtoul(line, &ep, 10);
- if (*ep != '\0' || errno != 0) {
- log_warnx("session %u: malformed "
- "UIDVALIDITY: %s", session_id, line);
- fclose(fp);
- return (-1);
- }
-
- /*
- * RFC 7162 addition: an optional third header field,
- * HIGHESTMODSEQ. colon2 == NULL means a header
- * written before this pass (two fields only) --
- * tolerated, not an error, per this function's header
- * comment on backward compatibility; defaults to 1,
- * the same "start of the world" value a brand-new
- * index gets below.
- */
- if ((colon2 = strchr(colon + 1, ':')) != NULL)
- *colon2 = '\0';
-
- errno = 0;
- idx->uidnext = (uint32_t)strtoul(colon + 1, &ep, 10);
- if (*ep != '\0' || errno != 0) {
- log_warnx("session %u: malformed UIDNEXT: %s",
- session_id, colon + 1);
- fclose(fp);
- return (-1);
- }
-
- if (colon2 != NULL) {
- errno = 0;
- idx->highestmodseq = strtoull(colon2 + 1, &ep,
- 10);
- if (*ep != '\0' || errno != 0) {
- log_warnx("session %u: malformed "
- "HIGHESTMODSEQ: %s", session_id,
- colon2 + 1);
- fclose(fp);
- return (-1);
- }
- } else
- idx->highestmodseq = 1;
- 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);
- fclose(fp);
- return (-1);
- }
- idx->lines = newlines;
- idx->cap = newcap;
- }
- if ((idx->lines[idx->nlines] = strdup(line)) == NULL) {
- log_warn("session %u: strdup index line", session_id);
- fclose(fp);
- return (-1);
- }
- idx->nlines++;
- }
- if (ferror(fp)) {
- log_warn("session %u: fgets %s", session_id, STORE_INDEX_NAME);
- fclose(fp);
- return (-1);
- }
- fclose(fp);
-
- if (first) {
- idx->uidvalidity = (uint32_t)time(NULL);
- idx->uidnext = 1;
- idx->highestmodseq = 1;
- }
-
- return (0);
-}
-
-/*
- * Parses one "UID:basename:keywords[:MODSEQ]" index line -- see struct
- * index_rec's comment above for why this exists (one shared parser instead
- * of three-plus independent strchr() chains). Returns -1 (logged) on a
- * corrupt line, matching the existing "log and skip/keep, don't fail the
- * whole operation over one bad line" tolerance every caller below already
- * had before this pass.
- */
-static int
-index_parse_line(const char *line, struct index_rec *rec)
-{
- const char *p, *q, *r;
- char *ep;
-
- memset(rec, 0, sizeof(*rec));
-
- errno = 0;
- rec->uid = (uint32_t)strtoul(line, &ep, 10);
- if (*ep != ':') {
- log_warnx("session %u: corrupt index line: %s", session_id,
- line);
- return (-1);
- }
- p = ep + 1;
- if ((q = strchr(p, ':')) == NULL) {
- log_warnx("session %u: corrupt index line: %s", session_id,
- line);
- return (-1);
- }
- if ((size_t)(q - p) >= sizeof(rec->basename)) {
- log_warnx("session %u: basename too long in index line",
- session_id);
- return (-1);
- }
- memcpy(rec->basename, p, (size_t)(q - p));
- rec->basename[q - p] = '\0';
-
- p = q + 1;
- if ((r = strchr(p, ':')) != NULL) {
- /* keywords field ends at the MODSEQ separator */
- if ((size_t)(r - p) >= sizeof(rec->keywords)) {
- log_warnx("session %u: keywords too long in index "
- "line", session_id);
- return (-1);
- }
- memcpy(rec->keywords, p, (size_t)(r - p));
- rec->keywords[r - p] = '\0';
-
- errno = 0;
- rec->modseq = strtoull(r + 1, &ep, 10);
- if (*ep != '\0' || errno != 0) {
- log_warnx("session %u: malformed per-message MODSEQ "
- "in index line: %s", session_id, line);
- return (-1);
- }
- } else {
- /* no MODSEQ field -- pre-CONDSTORE line, see struct
- * index_rec's backward-compatibility comment */
- strlcpy(rec->keywords, p, sizeof(rec->keywords));
- rec->modseq = 1;
- }
-
- return (0);
-}
-
-/*
- * Highest UID currently assigned to a *present* message in idx, or 0 if
- * the mailbox has never held one -- idx->lines is maintained in ascending
- * UID order (see struct mbox_index's own comment), so the last line has
- * the highest UID. This is "*" for UID SEARCH (SS9's `seq-number = nz-
- * number / "*"`, "*" meaning "the largest number in use") and, this pass,
- * for UID FETCH/UID STORE/UID EXPUNGE too -- deliberately NOT idx->uidnext
- * - 1, which is the highest UID *ever assigned*, not the highest UID of a
- * message that still exists (those differ whenever the highest-UID
- * message has since been expunged). Factored out of what was previously
- * inline duplicated logic in handle_mbox_search() alone; now shared by
- * every UID-space "*" resolution in this file.
- */
-static uint32_t
-index_max_uid(struct mbox_index *idx)
-{
- const char *line;
- char *ep;
- uint32_t v;
-
- if (idx->nlines == 0)
- return (0);
-
- line = idx->lines[idx->nlines - 1];
- errno = 0;
- v = (uint32_t)strtoul(line, &ep, 10);
- if (*ep != ':')
- return (0); /* corrupt last line -- treat as "no UIDs in
- * use" rather than guessing; a hi_is_star
- * range simply won't match anything in that
- * case, which is safer than an arbitrary
- * wrong bound */
- return (v);
-}
-
-/*
- * Reports every UID in [lo, hi] not currently present in idx as one or
- * more IMSG_MBOX_SELECT_VANISHED ranges -- the same "walk the present-
- * message list once, report gaps" computation qresync_send_resync() uses
- * for QRESYNC SELECT resync (see that function's own comment and
- * imsg_mbox_select_vanished's SS5.1 minimal-state comment in imapd.h),
- * factored out as an independent, simpler helper (no interleaved FETCH-
- * resync emission) for RFC 7162 SS3.2.6's VANISHED UID FETCH modifier,
- * which just needs the gap-reporting half against a UID FETCH's own
- * seq_lo/seq_hi range rather than QRESYNC's known-uids range. Kept
- * separate from qresync_send_resync() rather than sharing code with it,
- * to avoid risking a regression in that already-working, RFC-example-
- * verified path for the sake of a few dozen shared lines.
- */
-static void
-send_vanished_range(struct mbox_index *idx, uint32_t lo, uint32_t hi,
- struct imsgev *iev)
-{
- uint32_t want, i;
-
- if (hi < lo)
- return;
-
- want = lo;
- for (i = 0; i < idx->nlines && want <= hi; i++) {
- struct index_rec rec;
-
- if (index_parse_line(idx->lines[i], &rec) == -1)
- continue;
- if (rec.uid < want)
- continue;
- if (rec.uid > hi)
- break;
-
- if (rec.uid > want) {
- struct imsg_mbox_select_vanished van;
-
- memset(&van, 0, sizeof(van));
- van.uid_lo = want;
- van.uid_hi = rec.uid - 1;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECT_VANISHED,
- 0, 0, -1, &van, sizeof(van)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_SELECT_VANISHED", session_id);
- }
-
- want = rec.uid + 1;
- }
-
- if (want <= hi) {
- struct imsg_mbox_select_vanished van;
-
- memset(&van, 0, sizeof(van));
- van.uid_lo = want;
- van.uid_hi = hi;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECT_VANISHED, 0, 0,
- -1, &van, sizeof(van)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_SELECT_VANISHED", session_id);
- }
-}
-
-/*
- * Linear scan for a basename already present in the index's UID<->basename
- * map -- O(n) per lookup, O(n^2) total when called once per new/ entry
- * during a scan, same "fine for v1's personal-use, modest-mailbox-size
- * scope" reasoning openimap-storage-backend.md already applies to the
- * full-file-rewrite cost of index_save() below; revisit only if that
- * assumption stops holding.
- */
-static int
-index_has_basename(struct mbox_index *idx, const char *basename)
-{
- size_t i;
-
- for (i = 0; i < idx->nlines; i++) {
- const char *p = idx->lines[i];
- const char *basefield, *end;
- size_t len;
-
- if ((basefield = strchr(p, ':')) == NULL)
- continue;
- basefield++;
- end = strchr(basefield, ':');
- len = (end != NULL) ? (size_t)(end - basefield) :
- strlen(basefield);
- if (strlen(basename) == len &&
- strncmp(basefield, basename, len) == 0)
- return (1);
- }
- return (0);
-}
-
-/*
- * Appends one new "UID:basename::MODSEQ" record (no keywords -- a freshly
- * noticed message has none yet) to the in-memory index. Does not touch
- * idx->uidnext itself; the caller owns incrementing it, since the caller
- * (handle_mbox_select()) is the one that knows whether this was the last
- * new/ entry or more remain.
- *
- * RFC 7162 addition: bumps idx->highestmodseq and assigns the new value to
- * this message, per SS3.1's "When a message is appended to a mailbox (via
- * the IMAP APPEND command, COPY to the mailbox, or using an external
- * mechanism), the server generates a new modification sequence that is
- * higher than the highest modification sequence of all messages in the
- * mailbox and assigns it to the appended message." Both of this function's
- * two callers (handle_mbox_select()'s new/-scan and handle_mbox_append())
- * are exactly "external mechanism" and "APPEND command" respectively, so
- * this single shared bump covers both per-message, even when a SELECT
- * discovers several externally-delivered messages in the same scan (each
- * gets its own distinct value, not one shared across the batch -- v1's own
- * choice where the RFC doesn't say either way for multiple simultaneous
- * arrivals; see this function's caller-side comments for why a STORE or
- * EXPUNGE affecting several messages at once is different and shares one
- * value instead).
- */
-static int
-index_append(struct mbox_index *idx, uint32_t uid, const char *basename)
-{
- char line[STORE_INDEX_LINE_MAX];
- int len;
-
- /*
- * F10 fix (defense in depth): refuse a basename containing the
- * index field separator ':' or a newline. refresh_index() already
- * pre-skips these when scanning new/; server-generated basenames
- * (APPEND/COPY) never contain them.
- */
- if (strpbrk(basename, ":\r\n") != NULL) {
- log_warnx("session %u: refusing index entry with unsafe "
- "basename: %s", session_id, basename);
- return (-1);
- }
-
- idx->highestmodseq++;
-
- len = snprintf(line, sizeof(line), "%u:%s::%llu", uid, basename,
- (unsigned long long)idx->highestmodseq);
- if (len < 0 || (size_t)len >= sizeof(line)) {
- log_warnx("session %u: index line too long for %s",
- session_id, basename);
- 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 ((idx->lines[idx->nlines] = strdup(line)) == NULL) {
- log_warn("session %u: strdup index line", session_id);
- return (-1);
- }
- idx->nlines++;
- return (0);
}
-/*
- * Rewrites the whole index to STORE_INDEX_TMP_NAME and rename(2)s it over
- * STORE_INDEX_NAME -- openimap-storage-backend.md's resolved mutation
- * rule, and the same atomicity guarantee (confirmed against the real
- * rename(2) man page during that design pass: "an instance of the
- * destination name will always exist even if the system crashes
- * mid-rename") already relied on for maildir delivery and flag-suffix
- * renames elsewhere in this design -- so a reader can never observe a
- * torn or partially-written index, only the old version or the new one.
- */
-static int
-index_save(struct mbox_index *idx)
-{
- FILE *fp;
- int fd;
- size_t i;
-
- /*
- * F9 fix: create the temp exclusively so a pre-planted symlink at
- * this fixed path cannot be followed on open. Unlink any stale temp
- * left by a previous crash first (it is ours to replace), ignoring
- * ENOENT.
- */
- if (unlink(STORE_INDEX_TMP_NAME) == -1 && errno != ENOENT) {
- log_warn("session %u: unlink %s", session_id,
- STORE_INDEX_TMP_NAME);
- return (-1);
- }
- if ((fd = open(STORE_INDEX_TMP_NAME, O_WRONLY | O_CREAT | O_EXCL,
- 0600)) == -1) {
- log_warn("session %u: open %s", session_id,
- STORE_INDEX_TMP_NAME);
- return (-1);
- }
- if ((fp = fdopen(fd, "w")) == NULL) {
- log_warn("session %u: fdopen %s", session_id,
- STORE_INDEX_TMP_NAME);
- close(fd);
- return (-1);
- }
-
- if (fprintf(fp, "%u:%u:%llu\n", idx->uidvalidity, idx->uidnext,
- (unsigned long long)idx->highestmodseq) < 0) {
- log_warnx("session %u: write index header failed",
- session_id);
- fclose(fp);
- return (-1);
- }
- for (i = 0; i < idx->nlines; i++) {
- if (fprintf(fp, "%s\n", idx->lines[i]) < 0) {
- log_warnx("session %u: write index line failed",
- session_id);
- fclose(fp);
- return (-1);
- }
- }
- if (fclose(fp) != 0) {
- log_warn("session %u: fclose %s", session_id,
- STORE_INDEX_TMP_NAME);
- return (-1);
- }
-
- if (rename(STORE_INDEX_TMP_NAME, STORE_INDEX_NAME) == -1) {
- log_warn("session %u: rename %s -> %s", session_id,
- STORE_INDEX_TMP_NAME, STORE_INDEX_NAME);
- return (-1);
- }
- return (0);
-}
-
-static void
-index_free(struct mbox_index *idx)
-{
- size_t i;
-
- for (i = 0; i < idx->nlines; i++)
- free(idx->lines[i]);
- free(idx->lines);
- memset(idx, 0, sizeof(*idx));
-}
-
-/*
- * IMSG_MBOX_SELECT handling. v1 scope: INBOX is the only mailbox that
- * exists at all (openimap-v1-dispatch.md's SELECT row; see listener.c's
- * cmd_namespace() comment for why a real multi-mailbox hierarchy isn't
- * designed yet) -- anything else is answered ok=0 (listener.c turns that
- * into a tagged NO, RFC 9051 SS6.3.2's own "no such mailbox" result),
- * matched case-insensitively per RFC 9051 SS5.1 ("The special name INBOX
- * is case-insensitive").
- *
- * On a real INBOX select: open/flock/parse the index, scan new/ for
- * basenames the index doesn't know about yet and assign each one the
- * next UID (openimap-storage-backend.md: "A store child does still need
- * to notice externally-delivered files in new/ ... and assign them a UID
- * via the index"), rewrite the index, and report back exists/uidvalidity/
- * uidnext. Deliberately does NOT move anything from new/ to cur/ -- RFC
- * 9051 deprecated \Recent (message flag), the untagged RECENT response,
- * and RECENT STATUS entirely (SS8 erratum note 12), and this is a pure
- * IMAP4rev2 server (no IMAP4rev1 back-compat), so there is no protocol
- * reason left to track "recent" status via a new/->cur/ move at all --
- * that migration is purely a maildir-hygiene/interop concern, tied to
- * \Seen handling once FETCH/STORE exist, not something SELECT needs to
- * do itself.
- *
- * reply->exists is currently exactly "every basename ever indexed" --
- * correct for now since nothing in this codebase can remove an index
- * entry yet (EXPUNGE is still a stub_not_implemented() in listener.c);
- * will need to become "indexed minus expunged" once that changes.
- *
- * RFC 7162 additions this pass: reply.highestmodseq is always populated
- * (see imsg_mbox_selected's comment in imapd.h). If req->qresync is set
- * and req->qresync_uidvalidity matches the mailbox's real UIDVALIDITY,
- * performs the QRESYNC resync described in RFC 7162 SS3.2.5.1 -- streams
- * IMSG_MBOX_SELECT_VANISHED range(s) for the requested known-uids range (or
- * the default 1:<uidnext-1>) followed by IMSG_MBOX_FETCH_META for every
- * still-present message in that range whose mod-sequence exceeds req->
- * qresync_modseq -- via qresync_send_resync(), before composing the
- * terminal IMSG_MBOX_SELECTED. A UIDVALIDITY mismatch is not an error
- * (SS3.2.5: "the server MUST ignore the remaining parameters and behave as
- * if no dynamic message data changed") -- resync is simply skipped and a
- * normal SELECT reply goes out.
- */
-static void
-qresync_send_resync(struct imsg_mbox_select *req, struct mbox_index *idx,
- struct imsgev *iev)
-{
- uint32_t uid_lo, uid_hi, want, i;
-
- if (req->qresync_has_uids) {
- uid_lo = req->qresync_uid_lo;
- uid_hi = req->qresync_uid_hi;
- } else {
- /* SS3.2.5.1: "the server acts as if the client has specified
- * '1:<maxuid>' ... If the mailbox is empty and never had any
- * messages in it, then lack of the list of UIDs is
- * interpreted as an empty set of UIDs." maxuid is uidnext-1;
- * uidnext == 1 means no message has ever been assigned a UID
- * (index_load()'s fresh-file default), i.e. exactly that
- * empty case. */
- if (idx->uidnext <= 1)
- return;
- uid_lo = 1;
- uid_hi = idx->uidnext - 1;
- }
- if (uid_hi < uid_lo)
- return; /* empty requested range -- nothing to do */
-
- /*
- * Single forward pass over idx->lines (bounded by mailbox size, not
- * by the requested UID range's numeric span -- see imsg_mbox_
- * select_vanished's comment in imapd.h for why iterating the
- * range itself, one UID at a time, isn't safe). want tracks the
- * lowest UID not yet accounted for; any present-message UID greater
- * than want means everything in [want, that UID - 1] vanished.
- */
- want = uid_lo;
- for (i = 0; i < idx->nlines && want <= uid_hi; i++) {
- struct index_rec rec;
-
- if (index_parse_line(idx->lines[i], &rec) == -1)
- continue; /* corrupt line, already logged --
- * conservatively not reported either
- * way, same tolerance handle_mbox_
- * fetch() etc. already apply */
- if (rec.uid < want)
- continue; /* below the requested range, or
- * already accounted for */
- if (rec.uid > uid_hi)
- break;
-
- if (rec.uid > want) {
- struct imsg_mbox_select_vanished van;
-
- memset(&van, 0, sizeof(van));
- van.uid_lo = want;
- van.uid_hi = rec.uid - 1;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECT_VANISHED,
- 0, 0, -1, &van, sizeof(van)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_SELECT_VANISHED", session_id);
- }
-
- if (rec.modseq > req->qresync_modseq) {
- struct imsg_mbox_fetch_meta meta;
- char suffix[64];
- off_t size;
-
- memset(&meta, 0, sizeof(meta));
- meta.seqno = i + 1;
- meta.uid = rec.uid;
- meta.modseq = rec.modseq;
- if (locate_message_file(rec.basename, &size, suffix,
- sizeof(suffix)) == 0) {
- build_flags_string(suffix, rec.keywords,
- meta.flags, sizeof(meta.flags));
- }
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_FETCH_META, 0,
- 0, -1, &meta, sizeof(meta)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_FETCH_META (qresync)",
- session_id);
- }
-
- want = rec.uid + 1;
- }
-
- if (want <= uid_hi) {
- struct imsg_mbox_select_vanished van;
-
- memset(&van, 0, sizeof(van));
- van.uid_lo = want;
- van.uid_hi = uid_hi;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECT_VANISHED, 0, 0,
- -1, &van, sizeof(van)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_SELECT_VANISHED", session_id);
- }
-}
-
-/*
- * Loads the mailbox index (STORE_INDEX_NAME, already open on fd and
- * already flock(2)'d LOCK_EX by the caller) and discovers any message
- * files sitting in new/ that the index doesn't know about yet -- mail
- * delivered directly into the maildir by something other than this
- * daemon's own APPEND (migrating new/ to cur/ isn't done here or anywhere
- * else in this codebase yet), appending each with the next UID and
- * re-saving the index in full. Originally handle_mbox_select()'s own
- * inline logic; factored out this pass so handle_mbox_idle_refresh()
- * (RFC 9051 SS6.3.13) can be exactly as authoritative as a fresh SELECT
- * would be, rather than just re-reading whatever the index file happened
- * to say on disk before this call -- including picking up mail placed
- * directly in new/ since the last time anything looked, regardless of
- * which of the two callers triggered this particular refresh.
- *
- * On success, idx is populated and the caller owns it (index_free() when
- * done, same as index_load() itself). On failure, idx has already been
- * index_free()'d by this function -- the caller must not call index_free()
- * itself in that case, matching every existing "index_free() already ran
- * on this error path" precedent elsewhere in this file.
- */
-static int
-refresh_index(struct mbox_index *idx, int fd)
-{
- DIR *dp;
- struct dirent *de;
-
- if (index_load(fd, idx) == -1)
- return (-1);
-
- dp = opendir("new");
- if (dp == NULL) {
- if (errno == ENOENT)
- return (0); /* no new/ yet on a never-used
- * mailbox -- not an error, just
- * nothing to discover */
- log_warn("session %u: opendir new", session_id);
- index_free(idx);
- return (-1);
- }
- while ((de = readdir(dp)) != NULL) {
- if (de->d_name[0] == '.')
- continue; /* ".", "..", and dotfiles -- maildir
- * delivery never creates the latter
- * in new/ */
- /*
- * F10 fix: never index a new/ filename containing the index
- * field separator ':' or a newline -- it would corrupt the
- * "uid:basename:keywords:modseq" line (splitting the basename
- * field, or a newline injecting a spurious record). A well-
- * formed maildir new/ name never contains either.
- */
- if (strpbrk(de->d_name, ":\r\n") != NULL) {
- log_warnx("session %u: skipping new/ file with unsafe "
- "name (contains ':' or newline): %s", session_id,
- de->d_name);
- continue;
- }
- if (index_has_basename(idx, de->d_name))
- continue;
- if (index_append(idx, idx->uidnext, de->d_name) == -1) {
- closedir(dp);
- index_free(idx);
- return (-1);
- }
- idx->uidnext++;
- }
- closedir(dp);
-
- if (index_save(idx) == -1) {
- index_free(idx);
- return (-1);
- }
- return (0);
-}
-
-/*
- * RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 additions (flat multi-mailbox support,
- * see docs/openimap-storage-backend.md's "Open items" #10). Re-validated
- * here independently of listener.c's own client-side check on the same
- * rules -- defense in depth across the privsep boundary, the same "don't
- * trust the other side of an imsg channel" posture every other mailbox-
- * name-carrying request in this file already gets.
- */
-static int
+int
mailbox_name_valid(const char *name)
{
size_t i, len;
for (i = 0; i < len; i++) {
unsigned char c = (unsigned char)name[i];
- /*
- * RFC 9051 SS5.1.1: hierarchy levels are separated by a
- * single reserved delimiter character ("/" -- see docs/
- * openimap-storage-backend.md item 9). v1 is flat, so a
- * name containing it can never be created or addressed --
- * not a hard spec violation to refuse, since SS5.1.1's
- * hierarchy support is itself conditional ("if it is
- * desired to export hierarchical mailbox names").
- */
+ /* RFC 9051 SS5.1.1: "/" is the hierarchy delimiter; v1 is flat, so it's never addressable */
if (c == '/')
return (0);
- /*
- * SS5.1 point 2: "CTL and other non-graphic characters...
- * Servers MAY refuse to create mailbox names containing
- * Unicode CTL characters." Taking that MAY for the ASCII
- * C0/DEL range; full Net-Unicode validation is an
- * explicitly accepted gap (see the design doc).
- */
+ /* SS5.1 point 2: MAY refuse CTL/non-graphic names; taking that MAY for ASCII C0/DEL */
if (c < 0x20 || c == 0x7f)
return (0);
}
- /*
- * Reserved: "tmp"/"new"/"cur" are INBOX's own maildir internals,
- * living as siblings of any named-mailbox subdirectory at the same
- * level (the session's maildir root). Refusing these as mailbox
- * names outright, at validation time, closes a real hazard rather
- * than just working around it at LIST-enumeration time: without
- * this, CREATE "tmp" issued before INBOX's own tmp/ has ever been
- * lazily created (ensure_maildir_dirs()) would succeed, and a
- * later ensure_maildir_dirs("") for INBOX itself would then treat
- * that user-created directory as INBOX's own tmp/ (EEXIST is
- * tolerated there by design) -- silent data corruption across two
- * unrelated mailboxes. Case-sensitive, matching maildir's own
- * lowercase convention exactly (Courier maildir(5)).
- */
+ /* "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);
- /*
- * F3/F4 fix: also reject "." and ".." -- otherwise DELETE "."
- * resolves to the maildir root and destroys INBOX (unveil does not
- * contain ".", which stays inside the maildir) -- and reject the
- * on-disk index filenames, since a mailbox directory colliding with
- * them makes INBOX unusable.
- */
+ /* 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 ||
return (1);
}
-static int
+
+int
mailbox_name_is_inbox(const char *name)
{
return (strcasecmp(name, "INBOX") == 0);
}
-/*
- * Changes this store child's cwd to match `target` (the empty string for
- * INBOX/the session's own maildir root, or an already mailbox_name_valid()
- * name), tracking the transition in current_mailbox_dir so every other
- * IMSG_MBOX_* handler can keep using bare relative paths unmodified. A
- * no-op if `target` is already the currently selected mailbox (RFC 9051
- * has no prohibition against re-SELECTing the same mailbox). On failure
- * (target doesn't exist, or a chdir(2) itself fails), restores cwd to
- * wherever it was before this call, so a failed SELECT never leaves this
- * store child sitting somewhere unexpected for whatever command the
- * client sends next.
- */
-static int
+/* chdir's to `target` (empty string = INBOX root), tracked in current_mailbox_dir; restores cwd on failure */
+int
select_mailbox_dir(const char *target)
{
if (strcmp(current_mailbox_dir, target) == 0)
exists = (stat(target, &st) == 0 && S_ISDIR(st.st_mode));
if (!exists || chdir(target) == -1) {
- /*
- * A missing/non-directory target is the common,
- * expected "no such mailbox" case -- not warning-
- * level. A chdir(2) failure on a target that does
- * exist (permissions, ENOTDIR race, ...) is
- * genuinely unexpected and worth a real log line.
- */
+ /* missing/non-directory is the expected "no such mailbox" case; existing-but-failed is not */
if (exists)
log_warn("session %u: chdir %s", session_id,
target);
}
}
- strlcpy(current_mailbox_dir, target, sizeof(current_mailbox_dir));
- return (0);
-}
-
-static void
-handle_mbox_select(struct imsg_mbox_select *req, struct imsgev *iev)
-{
- struct mbox_index idx;
- struct imsg_mbox_selected reply;
- int fd;
- const char *target;
-
- memset(&reply, 0, sizeof(reply));
-
- if (mailbox_name_is_inbox(req->mailbox))
- target = "";
- else if (mailbox_name_valid(req->mailbox))
- target = req->mailbox;
- else {
- log_debug("session %u: SELECT %s: invalid mailbox name",
- session_id, req->mailbox);
- reply.ok = 0;
- goto send;
- }
-
- if (select_mailbox_dir(target) == -1) {
- log_debug("session %u: SELECT %s: no such mailbox",
- session_id, req->mailbox);
- reply.ok = 0;
- goto send;
- }
-
- if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
- log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
- reply.ok = 0;
- goto send;
- }
- /*
- * flock(2) -- already in store.c's pledge string specifically for
- * this: more than one store child can run for the same user at
- * once (fork-per-session, not fork-per-user), so this read-modify-
- * write cycle needs mutual exclusion across processes, not just
- * within this one.
- */
- if (flock(fd, LOCK_EX) == -1) {
- log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
- close(fd);
- reply.ok = 0;
- goto send;
- }
-
- if (refresh_index(&idx, fd) == -1) {
- flock(fd, LOCK_UN);
- close(fd);
- reply.ok = 0;
- goto send;
- }
-
- reply.ok = 1;
- reply.exists = (uint32_t)idx.nlines;
- reply.uidvalidity = idx.uidvalidity;
- reply.uidnext = idx.uidnext;
- reply.highestmodseq = idx.highestmodseq;
-
- if (req->qresync && req->qresync_uidvalidity == idx.uidvalidity)
- qresync_send_resync(req, &idx, iev);
-
- index_free(&idx);
- flock(fd, LOCK_UN);
- close(fd);
-
-send:
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECTED, 0, 0, -1, &reply,
- sizeof(reply)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_SELECTED",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * IMSG_MBOX_IDLE_REFRESH (RFC 9051 SS6.3.13, IDLE): no request payload --
- * listener.c already knows which session's mailbox to check purely from
- * which store child this arrived on. Streams one IMSG_MBOX_IDLE_UID per
- * currently-existing message (ascending UID order, straight from refresh_
- * index()'s idx.lines, same order handle_mbox_select()/handle_mbox_fetch()
- * already rely on), then one terminal IMSG_MBOX_IDLE_REFRESHED.
- *
- * v1 scope (confirmed with the user): EXISTS/EXPUNGE only, not per-message
- * FETCH -- so unlike handle_mbox_fetch()/handle_mbox_search(), this has no
- * per-message flags/modseq to report, just the bare UID each message
- * currently has. listener.c does its own diffing against whatever UID list
- * it cached from this session's last SELECT or last idle-refresh -- this
- * function has no idea whether that's the first refresh of a new IDLE or a
- * change-triggered one, and doesn't need to: it just reports current,
- * authoritative state, identically either way.
- *
- * ok=0 on any failure (index open/lock/load error, same failure modes as
- * handle_mbox_select()) -- listener.c treats that as "nothing to report
- * this round" rather than tearing anything down, same leniency reasoning
- * as a single skipped message elsewhere in this file: a mailbox that's
- * momentarily locked by another one of this same user's sessions shouldn't
- * abort the IDLE session over it.
- */
-static void
-handle_mbox_idle_refresh(struct imsgev *iev)
-{
- struct mbox_index idx;
- struct imsg_mbox_idle_refreshed reply;
- struct imsg_mbox_idle_uid item;
- int fd;
- size_t i;
- struct index_rec rec;
-
- memset(&reply, 0, sizeof(reply));
-
- if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
- log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
- goto send;
- }
- if (flock(fd, LOCK_EX) == -1) {
- log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
- close(fd);
- goto send;
- }
- if (refresh_index(&idx, fd) == -1) {
- flock(fd, LOCK_UN);
- close(fd);
- goto send;
- }
-
- for (i = 0; i < idx.nlines; i++) {
- if (index_parse_line(idx.lines[i], &rec) == -1)
- continue; /* same "skip a malformed line rather
- * than fail the whole request"
- * leniency index_parse_line()'s own
- * callers already use elsewhere */
- memset(&item, 0, sizeof(item));
- item.uid = rec.uid;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_UID, 0, 0, -1,
- &item, sizeof(item)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_IDLE_UID", session_id);
- }
-
- reply.ok = 1;
- reply.exists = (uint32_t)idx.nlines;
- reply.uidvalidity = idx.uidvalidity;
- reply.uidnext = idx.uidnext;
- reply.highestmodseq = idx.highestmodseq;
-
- index_free(&idx);
- flock(fd, LOCK_UN);
- close(fd);
-
-send:
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_REFRESHED, 0, 0, -1,
- &reply, sizeof(reply)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_IDLE_REFRESHED",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * Finds basename's on-disk message file: new/<basename> exactly (the only
- * place anything in this codebase currently puts a message -- see
- * handle_mbox_select()'s comment on why new/->cur/ migration isn't done
- * here), falling back to a "<basename>:2,*" prefix scan of cur/ for
- * robustness against a message an external tool or administrator moved
- * there by hand. Returns 0 and fills size_out and suffix_out (the matched
- * file's maildir flag-suffix, e.g. ":2,FS", or "" if found in new/ --
- * unflagged, per maildir convention) on success, -1 if the message can't
- * be found in either place (logged, not fatal -- handle_mbox_fetch()
- * skips just that one message rather than failing the whole FETCH).
- */
-static int
-locate_message_file(const char *basename, off_t *size_out, char *suffix_out,
- size_t suffix_out_size)
-{
- struct stat st;
- char path[600];
-
- suffix_out[0] = '\0';
-
- if (snprintf(path, sizeof(path), "new/%s", basename) >=
- (int)sizeof(path)) {
- log_warnx("session %u: basename too long: %s", session_id,
- basename);
- return (-1);
- }
- if (stat(path, &st) == 0) {
- *size_out = st.st_size;
- return (0);
- }
- if (errno != ENOENT) {
- log_warn("session %u: stat %s", session_id, path);
- return (-1);
- }
-
- {
- DIR *dp;
- struct dirent *de;
- size_t baselen = strlen(basename);
- int rv = -1;
-
- if ((dp = opendir("cur")) == NULL) {
- if (errno != ENOENT)
- log_warn("session %u: opendir cur", session_id);
- return (-1);
- }
- while ((de = readdir(dp)) != NULL) {
- if (strncmp(de->d_name, basename, baselen) != 0 ||
- de->d_name[baselen] != ':')
- continue;
- if (snprintf(path, sizeof(path), "cur/%s", de->d_name)
- >= (int)sizeof(path))
- break;
- if (stat(path, &st) == 0) {
- *size_out = st.st_size;
- strlcpy(suffix_out, de->d_name + baselen,
- suffix_out_size);
- rv = 0;
- }
- break;
- }
- closedir(dp);
- return (rv);
- }
-}
-
-/*
- * Same new/->cur/ fallback lookup as locate_message_file() (Courier
- * maildir(5): a message lives in new/ until some client/MDA moves it to
- * cur/ and appends its flag suffix), but opens the file and returns a
- * readable fd instead of just stat()'ing it -- locate_message_file()
- * itself isn't changed to also return a path/fd, to avoid touching its
- * eight existing call sites (RFC822.SIZE/FLAGS lookups across FETCH,
- * STORE, COPY, MOVE, SEARCH) for a capability only BODY.PEEK[HEADER]
- * needs. The duplication is small and self-contained; see read_message_
- * header() below for the one caller.
- */
-static int
-open_message_file(const char *basename)
-{
- char path[600];
- int fd;
-
- if (snprintf(path, sizeof(path), "new/%s", basename) >=
- (int)sizeof(path)) {
- log_warnx("session %u: basename too long: %s", session_id,
- basename);
- return (-1);
- }
- if ((fd = open(path, O_RDONLY)) != -1)
- return (fd);
- if (errno != ENOENT) {
- log_warn("session %u: open %s", session_id, path);
- return (-1);
- }
-
- {
- DIR *dp;
- struct dirent *de;
- size_t baselen = strlen(basename);
- int rv = -1;
-
- if ((dp = opendir("cur")) == NULL) {
- if (errno != ENOENT)
- log_warn("session %u: opendir cur", session_id);
- return (-1);
- }
- while ((de = readdir(dp)) != NULL) {
- if (strncmp(de->d_name, basename, baselen) != 0 ||
- de->d_name[baselen] != ':')
- continue;
- if (snprintf(path, sizeof(path), "cur/%s", de->d_name)
- >= (int)sizeof(path))
- break;
- rv = open(path, O_RDONLY);
- break;
- }
- closedir(dp);
- return (rv);
- }
-}
-
-/*
- * Reads basename's on-disk message file (open_message_file() above) and
- * returns just its raw RFC 5322 header block: everything from the start of
- * the file through and including the blank-line separator between headers
- * and body. Including the separator itself in the returned bytes is this
- * implementation's own judgment call -- RFC 9051 SS6.4.5 only says HEADER
- * means "the [RFC5322] header of the message", without spelling out the
- * exact byte boundary -- but it matches that section's own SS8 worked
- * example (a blank line appears inside the {342}-octet literal, right
- * before the response's closing paren) and is what lets BODY[HEADER] +
- * BODY[TEXT] concatenate back into the original message with nothing
- * missing, which is the common expectation among real IMAP servers.
- * Accepts either "\r\n\r\n" or bare "\n\n" as the separator, since this
- * implementation's APPEND stores literal bytes exactly as the client sent
- * them, unnormalized -- a maildir message here isn't guaranteed to be
- * strictly CRLF-terminated.
- *
- * Returns 0 and a malloc(3)'d *buf_out (caller frees) + *len_out on
- * success. Returns -1 (nothing left to free) if: the message file can't be
- * located; no blank-line separator is found within the first
- * FETCH_HEADER_MAX+1 bytes read (treated as "can't confidently say where
- * the header ends" rather than guessing -- the same "give up rather than
- * guess" principle this file's CRLF-literal parsing already follows); the
- * separator itself lands past FETCH_HEADER_MAX (the header is simply too
- * big for this pass's inline-imsg design, same size-cap precedent as
- * APPEND_LITERAL_MAX); or a NUL byte appears within the header region
- * (defensive -- nothing downstream expects message content read this way
- * to flow through a NUL-terminated C string, so this rejects the unusual
- * case outright rather than risk truncating it somewhere later).
- */
-static int
-read_message_header(const char *basename, char **buf_out, uint32_t *len_out)
-{
- char readbuf[FETCH_HEADER_MAX + 1];
- int fd;
- ssize_t n, total = 0;
- size_t i;
- int sepindex = -1;
-
- if ((fd = open_message_file(basename)) == -1)
- return (-1);
-
- while (total < (ssize_t)sizeof(readbuf)) {
- n = read(fd, readbuf + total, sizeof(readbuf) - total);
- if (n == -1) {
- log_warn("session %u: read message header (%s)",
- session_id, basename);
- close(fd);
- return (-1);
- }
- if (n == 0)
- break;
- total += n;
- }
- close(fd);
-
- for (i = 0; i + 1 < (size_t)total; i++) {
- if (readbuf[i] == '\0') {
- log_warnx("session %u: message %s has a NUL byte in "
- "its header -- BODY.PEEK[HEADER] skipped",
- session_id, basename);
- return (-1);
- }
- if (i + 3 < (size_t)total && readbuf[i] == '\r' &&
- readbuf[i + 1] == '\n' && readbuf[i + 2] == '\r' &&
- readbuf[i + 3] == '\n') {
- sepindex = (int)i + 4;
- break;
- }
- if (readbuf[i] == '\n' && readbuf[i + 1] == '\n') {
- sepindex = (int)i + 2;
- break;
- }
- }
-
- if (sepindex == -1 || sepindex > FETCH_HEADER_MAX) {
- log_warnx("session %u: message %s: no header/body separator "
- "found within %d bytes -- BODY.PEEK[HEADER] skipped",
- session_id, basename, FETCH_HEADER_MAX);
- return (-1);
- }
-
- if ((*buf_out = malloc((size_t)sepindex)) == NULL) {
- log_warn("session %u: malloc message header (%s)",
- session_id, basename);
- return (-1);
- }
- memcpy(*buf_out, readbuf, (size_t)sepindex);
- *len_out = (uint32_t)sepindex;
- return (0);
-}
-
-/*
- * Reads basename's on-disk message file (open_message_file() above) and
- * returns either the entire raw RFC 5322 message (text_only == 0) or
- * everything after the same header/body blank-line separator read_
- * message_header() scans for (text_only == 1) -- SS6.4.5.1: "The TEXT part
- * specifier refers to the text body of the message, omitting the
- * [RFC5322] header." Unlike read_message_header(), which only needs to
- * read far enough to find that separator, this has to read the whole
- * file: BODY.PEEK[] wants every byte, BODY.PEEK[TEXT] can't know how much
- * to return without first knowing the total length, and build_
- * bodystructure() needs the whole file to find every MIME part's own
- * boundary, even though its own *output* stays small.
- *
- * maxlen and label are supplied per call site rather than hardcoded,
- * because this function's two callers have genuinely different size
- * realities: BODY.PEEK[]/BODY.PEEK[TEXT] (handle_mbox_fetch()) pass
- * APPEND_LITERAL_MAX, since whatever comes out of this function still has
- * to fit whole on a single imsg to reach listener.c (struct imsg_mbox_
- * fetch_body's own bodylen comment, imapd.h) -- raising that would need
- * real multi-imsg streaming, out of scope this pass. build_bodystructure()
- * passes the much larger bodystructure_read_max instead (operator-
- * configurable via imapd.conf's "attachment max" directive, received
- * from parent over IMSG_STORE_INIT -- see BODYSTRUCTURE_READ_DEFAULT's
- * comment in imapd.h for the default and full rationale), since it only
- * derives a small, MIME_MAX_PARTS/
- * MIME_MAX_DEPTH/BODYSTRUCTURE_MAX-bounded structure summary from these
- * bytes -- it never sends the raw bytes themselves over the wire, so it
- * isn't limited by imsg's own size ceiling the way BODY.PEEK[]/[TEXT] is.
- * (This split was added after real-hardware testing showed a genuine
- * Apple Mail message with a small image attachment -- unsurprisingly,
- * larger than 12000 bytes once base64-encoded -- silently failed
- * BODYSTRUCTURE entirely, because this function used to share one
- * hardcoded APPEND_LITERAL_MAX-sized buffer across both callers.) label
- * (e.g. "BODY[]", "BODY[TEXT]", "BODYSTRUCTURE") is used only so a
- * failure here is logged accurately regardless of which caller hit it,
- * rather than this function unconditionally assuming it's always being
- * called for a BODY.PEEK[...] request.
- *
- * readbuf is heap-allocated (maxlen + 1 bytes) rather than a fixed-size
- * stack array, sized per-call to the caller's own maxlen -- necessary now
- * that the two callers want very different sizes, and BODYSTRUCTURE_READ_
- * MAX is far too large for a stack allocation anyway.
- *
- * Returns 0 and a malloc(3)'d *buf_out (caller frees; left NULL if
- * *len_out comes back 0 -- an empty body is legitimately different from
- * "not found", same distinction APPEND already makes for a zero-length
- * message) + *len_out on success. Returns -1 (nothing left to free) if:
- * the message file can't be located; the file is larger than maxlen (same
- * "reject rather than truncate" precedent as read_message_header()/APPEND
- * itself); the content contains a NUL byte (same defensive reasoning as
- * read_message_header()); or (text_only only) no header/body separator
- * can be found anywhere in the file, which this implementation treats as
- * "nothing to give" rather than guessing that an entire header-less file
- * is all body -- a deliberate simplification, not a claim that's the only
- * reasonable reading of SS6.4.5.1 for a malformed or genuinely header-less
- * message.
- */
-static int
-read_message_body(const char *basename, int text_only, size_t maxlen,
- const char *label, char **buf_out, uint32_t *len_out)
-{
- char *readbuf;
- size_t readbuf_size = maxlen + 1;
- int fd;
- ssize_t n, total = 0;
- size_t i;
- int sepindex = -1;
-
- *buf_out = NULL;
- *len_out = 0;
-
- if ((readbuf = malloc(readbuf_size)) == NULL) {
- log_warn("session %u: malloc message body readbuf (%s)",
- session_id, basename);
- return (-1);
- }
-
- if ((fd = open_message_file(basename)) == -1) {
- free(readbuf);
- return (-1);
- }
-
- while (total < (ssize_t)readbuf_size) {
- n = read(fd, readbuf + total, readbuf_size - total);
- if (n == -1) {
- log_warn("session %u: read message body (%s)",
- session_id, basename);
- close(fd);
- free(readbuf);
- return (-1);
- }
- if (n == 0)
- break;
- total += n;
- }
- close(fd);
-
- if (total > (ssize_t)maxlen) {
- log_warnx("session %u: message %s exceeds %zu bytes -- "
- "%s skipped", session_id, basename, maxlen, label);
- free(readbuf);
- return (-1);
- }
-
- for (i = 0; i < (size_t)total; i++) {
- if (readbuf[i] == '\0') {
- log_warnx("session %u: message %s has a NUL byte -- "
- "%s skipped", session_id, basename, label);
- free(readbuf);
- return (-1);
- }
- }
-
- if (text_only) {
- for (i = 0; i + 1 < (size_t)total; i++) {
- if (i + 3 < (size_t)total && readbuf[i] == '\r' &&
- readbuf[i + 1] == '\n' && readbuf[i + 2] == '\r' &&
- readbuf[i + 3] == '\n') {
- sepindex = (int)i + 4;
- break;
- }
- if (readbuf[i] == '\n' && readbuf[i + 1] == '\n') {
- sepindex = (int)i + 2;
- break;
- }
- }
- if (sepindex == -1) {
- log_warnx("session %u: message %s: no header/body "
- "separator found -- %s skipped",
- session_id, basename, label);
- free(readbuf);
- return (-1);
- }
- } else {
- sepindex = 0;
- }
-
- *len_out = (uint32_t)((size_t)total - (size_t)sepindex);
- if (*len_out > 0) {
- if ((*buf_out = malloc(*len_out)) == NULL) {
- log_warn("session %u: malloc message body (%s)",
- session_id, basename);
- *len_out = 0;
- free(readbuf);
- return (-1);
- }
- memcpy(*buf_out, readbuf + sepindex, *len_out);
- }
- free(readbuf);
- return (0);
-}
-
-/*
- * True if the header field name spanning name[0..namelen) case-
- * insensitively (ASCII-range, per RFC 9051 SS6.4.5.1: "The field-matching
- * is ASCII-range case insensitive but is otherwise exact") matches one of
- * the space-separated names in list. Re-copies and re-tokenizes list on
- * every call -- called once per header field in a message, and real
- * messages have at most a few dozen header fields, so the clarity of a
- * fresh strtok_r() pass each time outweighs the cost of not caching a
- * pre-split array.
- */
-static int
-header_field_name_matches(const char *name, size_t namelen, const char *list)
-{
- char listcopy[HEADER_FIELDS_MAX];
- char *tok, *save;
-
- if (namelen == 0 || namelen >= sizeof(listcopy))
- return (0);
-
- strlcpy(listcopy, list, sizeof(listcopy));
- for (tok = strtok_r(listcopy, " ", &save); tok != NULL;
- tok = strtok_r(NULL, " ", &save)) {
- if (strlen(tok) == namelen &&
- strncasecmp(tok, name, namelen) == 0)
- return (1);
- }
- return (0);
-}
-
-/*
- * BODY.PEEK[HEADER.FIELDS (fields_spec)] (want_not == 0) or BODY.PEEK
- * [HEADER.FIELDS.NOT (fields_spec)] (want_not == 1) -- RFC 9051 SS6.4.5.1.
- * fields_spec is listener.c's already-grammar-validated, space-joined
- * field-name list (see struct imsg_mbox_fetch's header_fields comment in
- * imapd.h) -- this function trusts it's well-formed rather than
- * re-validating.
- *
- * Builds on read_message_header() rather than re-deriving the header
- * block from disk: gets the same whole raw header (through and including
- * the trailing blank-line separator) that BODY.PEEK[HEADER] already
- * returns, then splits *that* into individual RFC 5322 fields itself,
- * respecting obs-fold continuation lines (RFC 5322 SS2.2.3: a field's
- * value may continue onto following lines, each of which "begins with a
- * space or tab" per section 3.2.2's WSP-based folding) -- a continuation
- * line is included as part of whichever field precedes it, never treated
- * as its own field. Each field's *complete* raw byte span (its own
- * "Name:" line through the last byte of its final continuation line) is
- * copied verbatim into the output if its name matches fields_spec (for
- * HEADER.FIELDS) or doesn't (for HEADER.FIELDS.NOT); order and every
- * repeated occurrence of a field name (e.g. multiple "Received:" lines)
- * are both preserved exactly as they appear in the source, not
- * deduplicated or reordered. The trailing blank line itself is always
- * copied unconditionally, matching SS6.4.5.1's "Subsetting does not
- * exclude the [RFC5322] delimiting blank line... the blank line is
- * included in all header fetches" -- regardless of whether any field
- * matched at all (an empty-but-valid result is legitimate: HEADER.FIELDS
- * with a field-name list that matches nothing in this particular message
- * still returns just the blank line, found == 1, not "not found").
- *
- * Returns 0 and a malloc(3)'d *buf_out (caller frees) + *len_out (always
- * at least 1, the blank line's own terminator) on success. Returns -1
- * (nothing left to free) if read_message_header() itself fails (message
- * not found, oversized, NUL byte, no separator -- see that function's
- * comment) or if the header block it returns is somehow malformed enough
- * that this function's own line-walk never reaches a blank line before
- * running off the end -- shouldn't happen, since read_message_header()
- * only ever returns a block that already ends at one, but checked
- * defensively rather than assumed.
- */
-static int
-read_message_header_fields(const char *basename, const char *fields_spec,
- int want_not, char **buf_out, uint32_t *len_out)
-{
- char *hdrbuf = NULL;
- uint32_t hdrlen = 0;
- char *out;
- size_t outlen = 0;
- size_t off = 0;
- int rc = -1;
-
- *buf_out = NULL;
- *len_out = 0;
-
- if (read_message_header(basename, &hdrbuf, &hdrlen) == -1)
- return (-1);
-
- /* Filtered output can never exceed the unfiltered header's own
- * size -- every byte copied below comes verbatim from hdrbuf. */
- if ((out = malloc(hdrlen)) == NULL) {
- log_warn("session %u: malloc HEADER.FIELDS buffer (%s)",
- session_id, basename);
- free(hdrbuf);
- return (-1);
- }
-
- while (off < hdrlen) {
- size_t field_start = off, line_end, i;
- int is_blank, matched, include;
-
- i = off;
- while (i < hdrlen && hdrbuf[i] != '\n')
- i++;
- if (i >= hdrlen)
- break; /* malformed -- see this function's comment */
- 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;
- 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;
-
- while (j < hdrlen && hdrbuf[j] != '\n')
- j++;
- if (j >= hdrlen) {
- off = hdrlen;
- break;
- }
- off = j + 1;
- }
-
- {
- 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);
-
- if (rc == -1) {
- free(out);
- return (-1);
- }
-
- *buf_out = out;
- *len_out = (uint32_t)outlen;
- return (0);
-}
-
-/*
- * Finds the first header field named `name` (ASCII-range case-insensitive,
- * same match rule as header_field_name_matches() above) in the raw header
- * block hdr[0..hdrlen), and returns its *unfolded* value: RFC 5322 SS2.2.3
- * "Unfolding is accomplished by simply removing any CRLF that is
- * immediately followed by WSP" -- the WSP itself is retained, so "Subject:
- * foo\r\n bar" unfolds to "foo bar", not "foobar". Every fold point inside
- * the value is guaranteed to be followed by WSP by construction: this
- * function walks continuation lines with the exact same "starts with SP or
- * HTAB" loop read_message_header_fields() already uses, so every internal
- * line break in the span it collects is, by definition, one of those
- * fold points. Leading FWS immediately after the colon is trimmed (SS3.6.5's
- * conventional single-space separator is not itself part of the field
- * body); the field's own final line terminator is trimmed from the end.
- *
- * Only the header block itself needs to be well-formed for this to work --
- * hdr is trusted to already be NUL-free (read_message_header() guarantees
- * that, rejecting any header containing one) and to end at a header/body
- * blank-line separator, same preconditions read_message_header_fields()
- * already relies on.
- *
- * Returns 0 and a malloc(3)'d *val_out (caller frees; may be a valid
- * zero-length allocation for a present-but-empty field, e.g. "Subject:\r\n"
- * -- RFC 9051 SS7.5.2 distinguishes "absent" (NIL) from "present but
- * empty" (empty string) for exactly this reason) + *vallen_out on success.
- * Returns -1 (nothing to free) if the field is not present in the header
- * at all.
- */
-static int
-extract_header_field(const char *hdr, size_t hdrlen, const char *name,
- char **val_out, size_t *vallen_out)
-{
- size_t namelen = strlen(name);
- size_t off = 0;
-
- *val_out = NULL;
- *vallen_out = 0;
-
- while (off < hdrlen) {
- size_t field_start = off, line_end, i, k;
-
- 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;
-
- 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 this field's obs-fold continuation lines
- * regardless of whether its name matches below -- off has
- * to land past them either way to keep scanning correctly
- * 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)
- continue;
-
- {
- size_t vstart = k + 1;
- size_t vend = off;
- size_t p;
- char *out;
- size_t outlen = 0;
-
- 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);
-
- 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++;
- }
-
- *val_out = out;
- *vallen_out = outlen;
- return (0);
- }
- }
-
- return (-1);
-}
-
-/*
- * envbuf_append()/envbuf_append_str(): bounded-buffer append primitives
- * shared by every ENVELOPE formatting function below. Unlike strlcat(3),
- * these reject (return -1, buffer left as it was before the call) rather
- * than truncate on overflow -- matching this project's usual "reject rather
- * than silently do something the client didn't ask for" precedent (see
- * HEADER_FIELDS_MAX's comment in imapd.h for the same reasoning applied
- * elsewhere), and letting build_envelope() propagate that as ENVELOPE_MAX
- * exceeded (found = 0) rather than ever emitting truncated, syntactically-
- * broken envelope text.
- */
-static int
-envbuf_append(char *buf, size_t bufsize, size_t *outlen, const char *data,
- size_t datalen)
-{
- if (*outlen + datalen > bufsize)
- return (-1);
- memcpy(buf + *outlen, data, datalen);
- *outlen += datalen;
- return (0);
-}
-
-static int
-envbuf_append_str(char *buf, size_t bufsize, size_t *outlen, const char *s)
-{
- return (envbuf_append(buf, bufsize, outlen, s, strlen(s)));
-}
-
-/*
- * Appends one RFC 9051 nstring: `NIL` if val is NULL, else an IMAP quoted
- * string with backslash and double-quote escaped (SS9's `quoted-specials =
- * DQUOTE / "\"` -- a quoted string's QUOTED-CHAR is "any TEXT-CHAR except
- * quoted-specials" or "\" followed by a quoted-special, so exactly those
- * two bytes need escaping, nothing else). Deliberately does not decode RFC
- * 2047 encoded-words (e.g. "=?UTF-8?B?...?=") in val -- RFC 9051 SS7.5.2
- * doesn't require it (ENVELOPE fields are described as extracted from the
- * RFC 5322 header, not MIME-decoded), and skipping it keeps this pass
- * scoped to RFC 5322 header parsing rather than also pulling in RFC 2047
- * decoding; a client sees the raw encoded-word text verbatim, same as it
- * would from the header itself, and can decode it exactly the same way it
- * always has to for extension fields. 8-bit bytes (raw unencoded UTF-8 in a
- * technically-non-conformant header) are passed through as-is rather than
- * rejected -- this server has no CHARSET negotiation for ENVELOPE and RFC
- * 9051 does not define an error path for it here, so passing the bytes
- * through unmodified (same as most real-world server implementations do)
- * is the more useful behavior than refusing the whole field.
- */
-static int
-envbuf_append_nstring(char *buf, size_t bufsize, size_t *outlen,
- const char *val, size_t vallen)
-{
- size_t i;
-
- if (val == NULL)
- return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
-
- if (envbuf_append(buf, bufsize, outlen, "\"", 1) == -1)
- return (-1);
- for (i = 0; i < vallen; i++) {
- if ((val[i] == '"' || val[i] == '\\') &&
- envbuf_append(buf, bufsize, outlen, "\\", 1) == -1)
- return (-1);
- if (envbuf_append(buf, bufsize, outlen, &val[i], 1) == -1)
- return (-1);
- }
- return (envbuf_append(buf, bufsize, outlen, "\"", 1));
-}
-
-/*
- * Parses and formats a single RFC 5322 mailbox (one entry from an address-
- * list field, already isolated by envbuf_append_address_list()'s top-level-
- * comma split below) into one IMAP `address` tuple, `"(" addr-name SP
- * addr-adl SP addr-mailbox SP addr-host ")"` (RFC 9051 SS9). This is a
- * deliberately scoped-down RFC 5322 address parser, not a complete one --
- * consistent with this project's smaller-feature-set-over-completeness
- * philosophy (see openimap-privsep-design.md's design philosophy section)
- * and the explicit v1 ENVELOPE-only scope decision (see this file's header
- * comment and imapd.h's MBOX_FETCH_ENVELOPE comment). Specifically NOT
- * supported, by design:
- *
- * - RFC 5322 `group` syntax ("Undisclosed-recipients:;") -- rare in
- * modern mail; addr-host's NIL-marks-a-group convention (SS7.5.2's
- * "If the mailbox name field is also NIL, this is an end-of-group
- * marker") is simply never produced by this implementation.
- * - obs-route / addr-adl -- obsolete since RFC 2822 (2001); always
- * formatted as NIL, which addr-adl's own ABNF comment in imapd.h
- * confirms is spec-legal ("Holds route from RFC5322 obs-route if
- * non-NIL").
- * - A quoted local-part containing an unescaped "@" (e.g.
- * `"foo@bar"@host.example`) -- this function finds the mailbox/host
- * split at the *last* unquoted "@", which is correct for the
- * overwhelming majority of real addresses but not that specific edge
- * case.
- * - RFC 5322 `comment` ("(...)") stripping -- comments are not
- * specially recognized or removed; since this parser never uses "("/
- * ")" as a structural delimiter anywhere, an address containing a
- * comment doesn't break parsing, it's just included verbatim as part
- * of whichever component (display name or mailbox/host) it falls
- * within, which may look odd but is not a correctness hazard.
- *
- * A mailbox this function can't make sense of (no "@" found in the
- * addr-spec portion, or a `<...>` with no matching closing angle bracket)
- * is reported via -1 -- the caller skips it and continues with the rest of
- * the list, rather than failing the whole ENVELOPE for one malformed
- * address (see envbuf_append_address_list()'s comment).
- *
- * Writes into a fixed local stack buffer sized generously for any
- * realistic single address; a single address whose formatted form
- * (including escaping) would exceed that buffer is also treated as -1 --
- * an even more defensible simplification than the ones above, since a
- * multi-kilobyte single address is already well outside anything a real
- * mail client would ever produce.
- */
-static int
-envbuf_append_one_address(char *buf, size_t bufsize, size_t *outlen,
- const char *tok, size_t toklen)
-{
- char addrbuf[1024];
- size_t addrlen = 0;
- const char *name = NULL;
- size_t namelen = 0;
- const char *spec;
- size_t speclen;
- const char *mailbox, *host;
- size_t mailboxlen, hostlen;
- size_t at;
- int found_at;
- size_t lt;
-
- while (toklen > 0 && (tok[0] == ' ' || tok[0] == '\t')) {
- tok++;
- toklen--;
- }
- while (toklen > 0 && (tok[toklen - 1] == ' ' || tok[toklen - 1] == '\t'))
- toklen--;
- if (toklen == 0)
- return (-1);
-
- /* Find an unquoted '<' -- if present, everything before it is the
- * display-name, and the addr-spec is the (still unquoted-tracked)
- * span up to the matching unquoted '>'. */
- lt = toklen;
- {
- size_t i;
- int q = 0;
-
- for (i = 0; i < toklen; i++) {
- if (tok[i] == '"')
- q = !q;
- else if (!q && tok[i] == '<') {
- lt = i;
- break;
- }
- }
- }
-
- if (lt < toklen) {
- size_t gt = toklen, i;
- int q = 0;
-
- for (i = lt + 1; i < toklen; i++) {
- if (tok[i] == '"')
- q = !q;
- else if (!q && tok[i] == '>') {
- gt = i;
- break;
- }
- }
- if (gt >= toklen)
- return (-1); /* unmatched '<' -- malformed, skip */
-
- {
- const char *disp = tok;
- size_t displen = lt;
-
- while (displen > 0 && (disp[0] == ' ' || disp[0] == '\t')) {
- disp++;
- displen--;
- }
- while (displen > 0 &&
- (disp[displen - 1] == ' ' || disp[displen - 1] == '\t'))
- displen--;
-
- if (displen >= 2 && disp[0] == '"' &&
- disp[displen - 1] == '"') {
- /* quoted-string display-name: strip the
- * surrounding quotes; the emission loop
- * below (the "if (name != NULL)" block)
- * undoes RFC 5322 quoted-pair escaping
- * ("\" + escaped octet) as it walks name[],
- * then re-escapes for the IMAP wire
- * independently, so this round-trips
- * correctly without a separate unescape pass
- * here. Applying that same backslash-aware
- * walk to an *unquoted* phrase (the else
- * branch below) is harmless -- RFC 5322's
- * `atext`/`word` grammar for an unquoted
- * phrase has no quoted-pair mechanism, so a
- * literal backslash there would already be
- * non-conformant input, not a real case this
- * needs to get right. */
- disp++;
- displen -= 2;
- }
- if (displen > 0) {
- name = disp;
- namelen = displen;
- }
- }
-
- spec = tok + lt + 1;
- speclen = gt - (lt + 1);
- } else {
- spec = tok;
- speclen = toklen;
- }
-
- while (speclen > 0 && (spec[0] == ' ' || spec[0] == '\t')) {
- spec++;
- speclen--;
- }
- while (speclen > 0 && (spec[speclen - 1] == ' ' || spec[speclen - 1] == '\t'))
- speclen--;
-
- found_at = 0;
- at = 0;
- {
- size_t i;
- int q = 0;
-
- for (i = 0; i < speclen; i++) {
- if (spec[i] == '"')
- q = !q;
- else if (!q && spec[i] == '@') {
- at = i;
- found_at = 1;
- }
- }
- }
- if (!found_at || at == 0 || at + 1 >= speclen)
- return (-1); /* no usable local-part@domain split */
-
- mailbox = spec;
- mailboxlen = at;
- host = spec + at + 1;
- hostlen = speclen - at - 1;
-
- /* strip surrounding quotes from a quoted local-part, same
- * unescaping as the display-name case above -- deliberately not
- * handling an unescaped "@" inside a quoted local-part, see this
- * function's header comment */
- if (mailboxlen >= 2 && mailbox[0] == '"' && mailbox[mailboxlen - 1] == '"') {
- mailbox++;
- mailboxlen -= 2;
- }
-
- if (name != NULL) {
- size_t j;
-
- if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "\"", 1) == -1)
- return (-1);
- for (j = 0; j < namelen; j++) {
- char c = name[j];
-
- if (c == '\\' && j + 1 < namelen) {
- j++;
- c = name[j];
- }
- if ((c == '"' || c == '\\') &&
- envbuf_append(addrbuf, sizeof(addrbuf), &addrlen,
- "\\", 1) == -1)
- return (-1);
- if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen,
- &c, 1) == -1)
- return (-1);
- }
- if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "\"", 1) == -1)
- return (-1);
- } else {
- if (envbuf_append_str(addrbuf, sizeof(addrbuf), &addrlen, "NIL") == -1)
- return (-1);
- }
-
- if (envbuf_append_str(addrbuf, sizeof(addrbuf), &addrlen, " NIL ") == -1)
- return (-1);
- if (envbuf_append_nstring(addrbuf, sizeof(addrbuf), &addrlen, mailbox,
- mailboxlen) == -1)
- return (-1);
- if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, " ", 1) == -1)
- return (-1);
- if (envbuf_append_nstring(addrbuf, sizeof(addrbuf), &addrlen, host,
- hostlen) == -1)
- return (-1);
-
- if (envbuf_append(buf, bufsize, outlen, "(", 1) == -1)
- return (-1);
- if (envbuf_append(buf, bufsize, outlen, addrbuf, addrlen) == -1)
- return (-1);
- return (envbuf_append(buf, bufsize, outlen, ")", 1));
-}
-
-/*
- * Formats an RFC 5322 address-list field value (From/Sender/Reply-To/To/
- * Cc/Bcc's raw, already-unfolded value) as an IMAP `env-from`/`env-to`/etc.
- * production: `"(" 1*address ")"` if at least one address parses, `NIL`
- * otherwise (RFC 9051 SS7.5.2: "If the ... header fields are absent ... or
- * are present but empty, the corresponding member of the envelope is
- * NIL" -- this implementation extends that to "or contains nothing this
- * parser could make an address out of", rather than distinguishing
- * "empty" from "unparseable" at the wire level, since RFC 9051 gives no
- * separate response for the latter).
- *
- * Splits val on top-level commas -- respecting RFC 5322 quoted-string and
- * angle-addr nesting, so a quoted display name containing a literal comma
- * ("Doe, John" <j@example.com>) is not mistaken for a list separator.
- * Adjacent `address` tuples are written back-to-back with no separator
- * between them (`"(" 1*address ")"`'s own ABNF has none -- each `address`
- * is already self-delimiting via its own parens, confirmed against RFC
- * 9051 SS7.5.2's own worked example, which shows
- * `((NIL NIL "minutes" "..." )("John Klensin" NIL "KLENSIN" "MIT.EDU"))`
- * with no space or comma between the two address tuples).
- */
-static int
-envbuf_append_address_list(char *buf, size_t bufsize, size_t *outlen,
- const char *val, size_t vallen)
-{
- size_t save = *outlen;
- size_t i = 0;
- int any = 0;
-
- while (vallen > 0 && (val[0] == ' ' || val[0] == '\t')) {
- val++;
- vallen--;
- }
- while (vallen > 0 && (val[vallen - 1] == ' ' || val[vallen - 1] == '\t'))
- vallen--;
-
- if (vallen == 0)
- return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
-
- if (envbuf_append(buf, bufsize, outlen, "(", 1) == -1)
- return (-1);
-
- while (i < vallen) {
- size_t tok_start;
- size_t tok_len;
- int in_quotes = 0, in_angle = 0;
-
- while (i < vallen && (val[i] == ' ' || val[i] == '\t' ||
- val[i] == ','))
- i++;
- tok_start = i;
- while (i < vallen) {
- char c = val[i];
-
- if (c == '"')
- in_quotes = !in_quotes;
- else if (!in_quotes && c == '<')
- in_angle = 1;
- else if (!in_quotes && c == '>')
- in_angle = 0;
- else if (!in_quotes && !in_angle && c == ',')
- break;
- i++;
- }
- tok_len = i - tok_start;
- while (tok_len > 0 && (val[tok_start + tok_len - 1] == ' ' ||
- val[tok_start + tok_len - 1] == '\t'))
- tok_len--;
-
- if (tok_len > 0) {
- if (envbuf_append_one_address(buf, bufsize, outlen,
- val + tok_start, tok_len) == 0)
- any = 1;
- else if (*outlen > bufsize) {
- /* can't actually happen -- envbuf_append()
- * never leaves *outlen past bufsize -- but
- * checked defensively rather than assumed */
- return (-1);
- }
- /* malformed single address: envbuf_append_one_
- * address() left the buffer exactly as it found it
- * on failure (see that function's own local addrbuf
- * staging, which is only ever flushed to buf/outlen
- * as one atomic append), so skipping it here is
- * safe -- not a hard failure for the whole list */
- }
- }
-
- if (!any) {
- *outlen = save;
- return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
- }
- return (envbuf_append(buf, bufsize, outlen, ")", 1));
-}
-
-/*
- * Looks up header field `name`, appends its nstring form (see envbuf_
- * append_nstring()) -- NIL if the field is absent, an escaped quoted
- * string (possibly empty, `""`) if present. Shared by ENVELOPE's four
- * plain-string members (date, subject, in-reply-to, message-id).
- */
-static int
-append_field_nstring(char *out, size_t outsize, size_t *outlen,
- const char *hdrbuf, uint32_t hdrlen, const char *name)
-{
- char *val;
- size_t vallen;
- int rc;
-
- if (extract_header_field(hdrbuf, hdrlen, name, &val, &vallen) == 0) {
- rc = envbuf_append_nstring(out, outsize, outlen, val, vallen);
- free(val);
- } else {
- rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0);
- }
- return (rc);
-}
-
-/*
- * Builds the complete RFC 9051 SS7.5.2 ENVELOPE parenthesized-list text for
- * one message: `"(" env-date SP env-subject SP env-from SP env-sender SP
- * env-reply-to SP env-to SP env-cc SP env-bcc SP env-in-reply-to SP
- * env-message-id ")"`, field order taken directly from SS7.5.2 ("The
- * fields of the envelope structure are in the following order: date,
- * subject, from, sender, reply-to, to, cc, bcc, in-reply-to, and
- * message-id"). Reads the header via read_message_header() independently
- * of any other requested FETCH content item -- same "each content item's
- * store.c handler does its own read_message_header()/read_message_body()
- * call" pattern already used for BODY.PEEK[HEADER] vs. BODY.PEEK[HEADER.
- * FIELDS...] vs. BODY.PEEK[]/[TEXT] (see handle_mbox_fetch()'s comment);
- * not sharing one read across FETCH items in the same request is a known,
- * accepted minor inefficiency, not a new one introduced here.
- *
- * Sender/Reply-To default to the already-formatted From value when their
- * own header field is absent or present-but-completely-empty (RFC 9051
- * SS7.5.2: "If the Sender or Reply-To header fields are absent ..., or are
- * present but empty, the server sets the corresponding member of the
- * envelope to be the same value as the from member"). "Present but empty"
- * here specifically means the raw header value is zero bytes after
- * unfolding (e.g. "Sender:\r\n") -- a value that's present but entirely
- * whitespace is treated as a normal (if unusual) address-list value that
- * happens to parse to no addresses, which independently produces the same
- * NIL rather than the from-value fallback; a narrow, documented difference
- * from a fully literal reading of "empty", not expected to matter for any
- * real message.
- *
- * Returns 0 and a malloc(3)'d *buf_out (caller frees) + *len_out on
- * success. Returns -1 (nothing to free) if the message's header can't be
- * read at all, or if the fully-formatted envelope text would exceed
- * ENVELOPE_MAX -- both cases are "ENVELOPE not found" for this message,
- * same as every other content item's own not-found handling.
- */
-static int
-build_envelope(const char *basename, char **buf_out, uint32_t *len_out)
-{
- char *hdrbuf = NULL;
- uint32_t hdrlen = 0;
- char out[ENVELOPE_MAX];
- size_t outlen = 0;
- char from_formatted[ENVELOPE_MAX];
- size_t from_len = 0;
-
- *buf_out = NULL;
- *len_out = 0;
-
- if (read_message_header(basename, &hdrbuf, &hdrlen) == -1)
- return (-1);
-
- if (envbuf_append(out, sizeof(out), &outlen, "(", 1) == -1)
- goto fail;
-
- if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
- "Date") == -1)
- goto fail;
- if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
- goto fail;
- if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
- "Subject") == -1)
- goto fail;
- if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
- goto fail;
-
- /* from */
- {
- char *val;
- size_t vallen;
-
- if (extract_header_field(hdrbuf, hdrlen, "From", &val,
- &vallen) == 0) {
- int rc = envbuf_append_address_list(from_formatted,
- sizeof(from_formatted), &from_len, val, vallen);
- free(val);
- if (rc == -1)
- goto fail;
- } else {
- if (envbuf_append_str(from_formatted,
- sizeof(from_formatted), &from_len, "NIL") == -1)
- goto fail;
- }
- }
- if (envbuf_append(out, sizeof(out), &outlen, from_formatted,
- from_len) == -1)
- goto fail;
- if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
- goto fail;
-
- /* sender, reply-to: default to from_formatted per SS7.5.2 */
- {
- static const char *const fallback_fields[] =
- { "Sender", "Reply-To" };
- size_t fi;
-
- for (fi = 0; fi < 2; fi++) {
- char *val;
- size_t vallen;
- int used_value = 0;
-
- if (extract_header_field(hdrbuf, hdrlen,
- fallback_fields[fi], &val, &vallen) == 0) {
- if (vallen > 0) {
- int rc = envbuf_append_address_list(
- out, sizeof(out), &outlen, val,
- vallen);
- used_value = 1;
- free(val);
- if (rc == -1)
- goto fail;
- } else
- free(val);
- }
- if (!used_value &&
- envbuf_append(out, sizeof(out), &outlen,
- from_formatted, from_len) == -1)
- goto fail;
- if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
- goto fail;
- }
- }
-
- /* to, cc, bcc */
- {
- static const char *const addr_fields[] = { "To", "Cc", "Bcc" };
- size_t fi;
-
- for (fi = 0; fi < 3; fi++) {
- char *val;
- size_t vallen;
-
- if (extract_header_field(hdrbuf, hdrlen,
- addr_fields[fi], &val, &vallen) == 0) {
- int rc = envbuf_append_address_list(out,
- sizeof(out), &outlen, val, vallen);
- free(val);
- if (rc == -1)
- goto fail;
- } else {
- if (envbuf_append_str(out, sizeof(out),
- &outlen, "NIL") == -1)
- goto fail;
- }
- if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
- goto fail;
- }
- }
-
- if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
- "In-Reply-To") == -1)
- goto fail;
- if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
- goto fail;
- if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
- "Message-Id") == -1)
- goto fail;
-
- if (envbuf_append(out, sizeof(out), &outlen, ")", 1) == -1)
- goto fail;
-
- free(hdrbuf);
-
- if ((*buf_out = malloc(outlen)) == NULL) {
- log_warn("session %u: malloc ENVELOPE buffer (%s)",
- session_id, basename);
- return (-1);
- }
- memcpy(*buf_out, out, outlen);
- *len_out = (uint32_t)outlen;
- return (0);
-
-fail:
- log_warnx("session %u: message %s: formatted ENVELOPE exceeds "
- "ENVELOPE_MAX -- ENVELOPE skipped", session_id, basename);
- free(hdrbuf);
- return (-1);
-}
-
-/*
- * ---------------------------------------------------------------------
- * BODYSTRUCTURE (RFC 9051 SS7.5.2) -- recursive MIME structure parsing.
- * Scope, sourced from RFC 2045/RFC 2046 (fetched and read directly this
- * pass, saved under research/) and an explicit user choice (AskUserQuestion,
- * "full recursive, depth-capped" over "single-part only" or "one level of
- * multipart, no nesting"): parses Content-Type/Content-Transfer-Encoding/
- * Content-Id/Content-Description and recurses into multipart bodies via
- * RFC 2046 SS5.1.1's boundary-delimited body-part grammar, bounded by
- * MIME_MAX_DEPTH/MIME_MAX_PARTS (imapd.h). Deliberately does NOT
- * implement: RFC 9051's optional extension data (body MD5/disposition/
- * language/location -- SS7.5.2 says this "can be returned... if present"
- * with BODYSTRUCTURE, not that it MUST be, so omitting it entirely means
- * BODYSTRUCTURE and the non-extensible "BODY" macro produce identical
- * output here, which is spec-legal); message/rfc822 and message/global
- * parts (body-type-msg needs a full nested ENVELOPE plus a nested BODY
- * structure of the *embedded* message, i.e. recursively re-deriving
- * everything this file already does for a top-level message, just against
- * an inner message extracted from a part's own body -- scoped out as a
- * separate, larger unit of work, not attempted this pass; a message
- * containing one anywhere in its structure gets found=0 for its whole
- * BODYSTRUCTURE, not a partially-correct structure); and RFC 2231
- * parameter-value continuations/charset encoding (SS7.5.2's own "Servers
- * SHOULD decode parameter-value continuations" language is a SHOULD, not a
- * MUST -- unhandled continuation parameters like "name*0"/"name*1" are
- * passed through as literal, un-joined attribute names instead, a cosmetic
- * rather than structural gap).
- * ---------------------------------------------------------------------
- */
-
-/*
- * Same header/body blank-line-separator scan as read_message_header()'s own
- * (see that function's comment), just operating on a buffer already fully
- * in memory rather than reading fresh from a file descriptor -- needed
- * because BODYSTRUCTURE's recursive walk works entirely off of one whole-
- * message read (build_bodystructure()'s read_message_body() call) and has
- * to re-locate this same separator both for the top-level message and for
- * every multipart sub-part carved out of it along the way.
- */
-static int
-find_header_body_split(const char *buf, size_t len, size_t *hdrend_out)
-{
- size_t i;
-
- for (i = 0; i + 1 < len; i++) {
- if (i + 3 < len && buf[i] == '\r' && buf[i + 1] == '\n' &&
- buf[i + 2] == '\r' && buf[i + 3] == '\n') {
- *hdrend_out = i + 4;
- return (0);
- }
- if (buf[i] == '\n' && buf[i + 1] == '\n') {
- *hdrend_out = i + 2;
- return (0);
- }
- }
- return (-1);
-}
-
-/*
- * RFC 2045 SS5.1: `tspecials := "(" / ")" / "<" / ">" / "@" / "," / ";" /
- * ":" / "\" / <"> / "/" / "[" / "]" / "?" / "="` -- the RFC 822 `specials`
- * set plus "/", "?", "=", minus ".". A `token` is any US-ASCII CHAR except
- * SPACE, CTLs, or one of these.
- */
-static int
-mime_is_tspecial(char c)
-{
- return (strchr("()<>@,;:\\\"/[]?=", c) != NULL);
-}
-
-/*
- * Reads one RFC 2045 `token` or `quoted-string` starting at s[*pos],
- * advancing *pos past it, into a NUL-terminated out[]. A leading '"'
- * switches to quoted-string mode: reads until the matching unescaped '"',
- * undoing RFC 822 quoted-pair escaping ("\" + one CHAR) as it goes --
- * needed in practice because most real message generators quote the
- * multipart "boundary" parameter's value even though a bare token would be
- * legal, precisely because boundary strings very often contain characters
- * (like "=", part of base64-derived boundary strings such as
- * "----=_NextPart_...") that are tspecials and thus illegal in a bare,
- * unquoted token. Shared by every Content-Type token/value this file reads
- * (type, subtype, attribute names, and parameter values) -- type/subtype/
- * attribute are always plain `token`s in practice (RFC 2045's grammar
- * never allows them to be quoted-strings in the first place, so a leading
- * '"' at those positions would itself be a tspecial and simply fail to
- * read as a valid bare token, correctly rejected).
- *
- * Returns 0 on success (out[] is NUL-terminated, possibly the empty string
- * only in the quoted-string case). Returns -1 or a zero-length result for
- * a bare token (RFC 2045 tokens are 1*<...>, never zero-length) if nothing
- * valid could be read, an unterminated quoted-string, or a value too long
- * for outsize -- callers treat any of these as "stop parsing further
- * parameters, keep what's already been parsed" rather than failing the
- * whole Content-Type field over one malformed trailing parameter.
- */
-static int
-mime_read_token_or_qstring(const char *s, size_t len, size_t *pos,
- char *out, size_t outsize)
-{
- size_t outlen = 0;
-
- if (outsize == 0)
- return (-1);
-
- if (*pos < len && s[*pos] == '"') {
- (*pos)++;
- while (*pos < len && s[*pos] != '"') {
- char c = s[*pos];
-
- if (c == '\\' && *pos + 1 < len) {
- (*pos)++;
- c = s[*pos];
- }
- if (outlen + 1 >= outsize)
- return (-1);
- out[outlen++] = c;
- (*pos)++;
- }
- if (*pos >= len)
- return (-1); /* unterminated quoted-string */
- (*pos)++;
- out[outlen] = '\0';
- return (0);
- }
-
- while (*pos < len && !mime_is_tspecial(s[*pos]) &&
- s[*pos] != ' ' && s[*pos] != '\t' &&
- (unsigned char)s[*pos] >= 0x20 && (unsigned char)s[*pos] != 0x7f) {
- if (outlen + 1 >= outsize)
- return (-1);
- out[outlen++] = s[*pos];
- (*pos)++;
- }
- out[outlen] = '\0';
- if (outlen == 0)
- return (-1);
- return (0);
-}
-
-/* In-place ASCII-range uppercase -- used to canonicalize MIME type/
- * subtype/attribute names for output, matching RFC 9051 SS7.5.2's own
- * worked examples ("TEXT" "PLAIN" ("CHARSET" ...)). RFC 2045 SS5.1 says
- * type/subtype/attribute matching is "ALWAYS case-insensitive", so this is
- * a display-canonicalization choice, not a correctness requirement --
- * parameter *values* (e.g. a filename) are deliberately left as-is,
- * un-uppercased, since those are often case-sensitive in practice
- * (filenames, charset aliases some clients treat case-sensitively, etc). */
-static void
-mime_str_upper(char *s)
-{
- for (; *s != '\0'; s++) {
- if (*s >= 'a' && *s <= 'z')
- *s -= ('a' - 'A');
- }
-}
-
-/*
- * RFC 2045 SS5.1: `content := "Content-Type" ":" type "/" subtype
- * *(";" parameter)`, `parameter := attribute "=" value`. Parses the
- * Content-Type header field of hdr[0..hdrlen) (a message or MIME body-
- * part's own header block) into type_out/subtype_out (both uppercased --
- * see mime_str_upper()'s comment) and params_fmt_out (the RFC 9051
- * body-fld-param list, already formatted as IMAP wire text: `("CHARSET"
- * "US-ASCII")`-style, or "NIL" if there were no parameters). Separately
- * captures the "boundary" parameter's raw (unescaped) value in
- * boundary_out / has_boundary_out if present, needed by split_multipart()
- * for multipart types -- extracted in the same single pass rather than
- * re-parsing the field twice.
- *
- * RFC 2045 SS5.2: "Default RFC 822 messages without a MIME Content-Type
- * header are taken by this protocol to be plain text in the US-ASCII
- * character set... It is also recommended that this default be assumed
- * when a syntactically invalid Content-Type header field is encountered."
- * -- both the absent case and the type/subtype-unparseable case fall back
- * to this exact default. A malformed *parameter* partway through an
- * otherwise-valid "type/subtype" (e.g. a parameter with no "=", or an
- * unterminated quoted-string value) does NOT trigger this fallback --
- * parameter parsing simply stops there, keeping type/subtype and whatever
- * parameters were already successfully parsed, rather than discarding a
- * perfectly good type/subtype over one bad trailing parameter.
- *
- * Returns 0 on success (always -- the RFC 2045 SS5.2 default means there's
- * always *something* valid to report) or -1 only if writing params_fmt_out
- * itself overflows params_fmt_outsize (propagated from envbuf_append*()),
- * which the caller treats as a hard BODYSTRUCTURE_MAX-exceeded-style
- * failure for this part.
- */
-static int
-parse_content_type(const char *hdr, size_t hdrlen, char *type_out,
- size_t typesize, char *subtype_out, size_t subtypesize,
- char *params_fmt_out, size_t params_fmt_outsize, char *boundary_out,
- size_t boundary_outsize, int *has_boundary_out)
-{
- char *val = NULL;
- size_t vallen = 0;
- size_t pos = 0;
- size_t plen = 0;
- int nparams = 0;
- int use_default = 0;
- /*
- * envbuf_append()/envbuf_append_str()/envbuf_append_nstring() track
- * length purely via the outlen pointer and never write a
- * terminating NUL -- correct for their original ENVELOPE callers,
- * which only ever consume the tracked length, never strlen(). This
- * function's caller (build_body_structure()) does call
- * strlen(params_fmt) on the finished buffer, so one byte of
- * params_fmt_out's capacity is reserved here for an explicit NUL
- * written at every return point below, rather than leaving that
- * byte to whatever uninitialized stack content the caller's buffer
- * happened to start with. (Found via real-hardware testing: a
- * genuine multipart message's BODYSTRUCTURE came back with a run of
- * stray bytes -- leftover stack content from the message's own
- * base64 attachment data sitting in scope earlier -- spliced in
- * right after the params list, because strlen() ran past the
- * intended content looking for a NUL that was never written.)
- */
- size_t pfsize = (params_fmt_outsize > 0) ? params_fmt_outsize - 1 : 0;
-
- *has_boundary_out = 0;
- boundary_out[0] = '\0';
-
- if (pfsize == 0)
- return (-1);
-
- if (extract_header_field(hdr, hdrlen, "Content-Type", &val,
- &vallen) == -1 || vallen == 0)
- use_default = 1;
-
- if (!use_default && (mime_read_token_or_qstring(val, vallen, &pos,
- type_out, typesize) == -1 || pos >= vallen || val[pos] != '/'))
- use_default = 1;
- if (!use_default) {
- pos++;
- if (mime_read_token_or_qstring(val, vallen, &pos, subtype_out,
- subtypesize) == -1)
- use_default = 1;
- }
-
- if (use_default) {
- free(val);
- strlcpy(type_out, "TEXT", typesize);
- strlcpy(subtype_out, "PLAIN", subtypesize);
- if (envbuf_append_str(params_fmt_out, pfsize, &plen,
- "(\"CHARSET\" \"US-ASCII\")") == -1)
- return (-1);
- params_fmt_out[plen] = '\0';
- return (0);
- }
-
- mime_str_upper(type_out);
- mime_str_upper(subtype_out);
-
- for (;;) {
- char attr[64], value[256];
-
- while (pos < vallen && (val[pos] == ' ' || val[pos] == '\t'))
- pos++;
- if (pos >= vallen || val[pos] != ';')
- break;
- pos++;
- while (pos < vallen && (val[pos] == ' ' || val[pos] == '\t'))
- pos++;
- if (mime_read_token_or_qstring(val, vallen, &pos, attr,
- sizeof(attr)) == -1)
- break;
- while (pos < vallen && (val[pos] == ' ' || val[pos] == '\t'))
- pos++;
- if (pos >= vallen || val[pos] != '=')
- break;
- pos++;
- while (pos < vallen && (val[pos] == ' ' || val[pos] == '\t'))
- pos++;
- if (mime_read_token_or_qstring(val, vallen, &pos, value,
- sizeof(value)) == -1)
- break;
-
- mime_str_upper(attr);
- if (envbuf_append_str(params_fmt_out, pfsize,
- &plen, nparams == 0 ? "(" : " ") == -1 ||
- envbuf_append_nstring(params_fmt_out, pfsize,
- &plen, attr, strlen(attr)) == -1 ||
- envbuf_append(params_fmt_out, pfsize, &plen,
- " ", 1) == -1 ||
- envbuf_append_nstring(params_fmt_out, pfsize,
- &plen, value, strlen(value)) == -1) {
- free(val);
- return (-1);
- }
- nparams++;
-
- if (strcasecmp(attr, "BOUNDARY") == 0) {
- strlcpy(boundary_out, value, boundary_outsize);
- *has_boundary_out = 1;
- }
- }
-
- free(val);
- if (nparams > 0) {
- if (envbuf_append_str(params_fmt_out, pfsize, &plen,
- ")") == -1)
- return (-1);
- params_fmt_out[plen] = '\0';
- return (0);
- }
- if (envbuf_append_str(params_fmt_out, pfsize, &plen, "NIL") == -1)
- return (-1);
- params_fmt_out[plen] = '\0';
- return (0);
-}
-
-/*
- * RFC 2046 SS5.1.1: splits body[0..bodylen) -- a multipart Content-Type's
- * own body, i.e. everything after that part/message's header/body blank
- * line -- into its `body-part`s, per `multipart-body := [preamble CRLF]
- * dash-boundary transport-padding CRLF body-part *encapsulation close-
- * delimiter transport-padding [CRLF epilogue]`. Each returned span
- * part_starts[i]..part_ends[i] is one body-part's raw bytes (its own
- * MIME-part-headers through the end of its content, not yet split into
- * header/body -- the caller does that separately per sub-part, via
- * find_header_body_split(), since a sub-part's header/body separator is
- * unrelated to this function's own boundary-scanning).
- *
- * A `dash-boundary` ("--" + the Content-Type's boundary parameter value)
- * only counts as a real delimiter if it appears at the start of a line
- * (position 0, or immediately after a CRLF or bare LF -- RFC 2046's own
- * requirement that "Lines in a body-part must not start with the
- * specified dash-boundary" means a conformant generator guarantees this
- * check is sufficient) and is immediately followed -- after optional
- * `transport-padding` (spaces/tabs) -- by either a CRLF/LF (a normal
- * delimiter, more parts follow) or "--" then CRLF/LF/end-of-body (the
- * close-delimiter, no more parts). Anything else at a boundary-shaped
- * line start is treated as a coincidental non-match, not a delimiter.
- *
- * Returns 0 and *nparts_out >= 1 on success (a valid multipart body has at
- * least the preamble's dash-boundary and a close-delimiter bracketing at
- * least one body-part -- RFC 2046's own `1*body-part` isn't quite right
- * here since this implementation doesn't distinguish "zero-body-part
- * multipart" as separately illegal from "no close-delimiter found", both
- * simply fail). Returns -1 (nothing found or usable) if the boundary
- * parameter is malformed/oversized, no close-delimiter is ever found, or
- * more than maxparts body-parts would result -- all treated by the caller
- * as "can't build a BODYSTRUCTURE for this multipart part", not a partial
- * result.
- */
-static int
-split_multipart(const char *body, size_t bodylen, const char *boundary,
- size_t *part_starts, size_t *part_ends, int *nparts_out, int maxparts)
-{
- char needle[2 + 70 + 1]; /* "--" + boundary; RFC 2046 SS5.1.1
- * caps boundary at 70 characters */
- size_t needlelen;
- size_t pos = 0;
- int found_first = 0;
- int n = 0;
- int closed = 0;
-
- strlcpy(needle, "--", sizeof(needle));
- needlelen = strlcat(needle, boundary, sizeof(needle));
- if (needlelen >= sizeof(needle))
- return (-1); /* boundary too long -- non-conformant */
-
- *nparts_out = 0;
-
- while (pos < bodylen) {
- int at_bol = (pos == 0) ||
- (pos >= 2 && body[pos - 2] == '\r' &&
- body[pos - 1] == '\n') ||
- (pos >= 1 && body[pos - 1] == '\n');
- size_t after;
- int is_close = 0;
-
- if (!at_bol || pos + needlelen > bodylen ||
- memcmp(body + pos, needle, needlelen) != 0) {
- pos++;
- continue;
- }
-
- after = pos + needlelen;
- if (after + 1 < bodylen && body[after] == '-' &&
- body[after + 1] == '-') {
- is_close = 1;
- after += 2;
- }
- while (after < bodylen &&
- (body[after] == ' ' || body[after] == '\t'))
- after++;
- if (after < bodylen && body[after] != '\r' &&
- body[after] != '\n') {
- /* not actually followed by CRLF/LF/end-of-body --
- * coincidental match inside real content, not a
- * delimiter */
- pos++;
- continue;
- }
-
- if (found_first) {
- size_t content_end = pos;
- size_t part_start = part_starts[n - 1];
-
- /*
- * Bug found during manual security review: an empty
- * body-part (this boundary line immediately following
- * the previous one, with no content line between them)
- * previously let this strip read/subtract bytes that
- * actually belong to the *previous* boundary's own
- * line-ending, not this (empty) part's content --
- * driving content_end below part_start. Every caller
- * computes part_ends[i] - part_starts[i] as unsigned
- * size_t arithmetic, so that underflowed to a
- * near-SIZE_MAX length, later used as a memcpy/scan
- * bound over attacker-controlled MIME content.
- * Confirmed empirically with a standalone harness
- * against body "--X\r\n--X--\r\n": content_end came out
- * to pos-2 = 3, less than part_start = 5. Bounding the
- * strip to never go below part_start keeps an empty
- * part's length at a legitimate 0 instead.
- */
- if (content_end >= part_start + 2 &&
- body[content_end - 2] == '\r' &&
- body[content_end - 1] == '\n')
- content_end -= 2;
- else if (content_end >= part_start + 1 &&
- body[content_end - 1] == '\n')
- content_end -= 1;
- part_ends[n - 1] = content_end;
- }
-
- if (is_close) {
- closed = 1;
- break;
- }
-
- if (after < bodylen && body[after] == '\r' &&
- after + 1 < bodylen && body[after + 1] == '\n')
- after += 2;
- else if (after < bodylen && body[after] == '\n')
- after += 1;
- else
- break; /* delimiter runs to end of body with no
- * CRLF -- no body-part can follow */
-
- if (n >= maxparts)
- return (-1);
- part_starts[n] = after;
- n++;
- found_first = 1;
- pos = after;
- }
-
- if (!closed || !found_first)
- return (-1);
-
- *nparts_out = n;
- return (0);
-}
-
-/*
- * Recursively builds one RFC 9051 SS7.5.2 `body` -- `"(" (body-type-1part /
- * body-type-mpart) ")"` -- for the MIME entity whose header is hdr[0..
- * hdrlen) and whose (still-encoded) content is body[0..bodylen), appending
- * the formatted text directly onto out/outlen (the same shared buffer the
- * whole recursive walk writes into, so a deeply nested structure is built
- * with a single top-level BODYSTRUCTURE_MAX size check rather than N
- * separate per-part buffers).
- *
- * depth/nparts_used together enforce MIME_MAX_DEPTH/MIME_MAX_PARTS
- * (imapd.h's comment on those constants has the full "message content
- * is attacker/sender-controlled, both need a hard ceiling" reasoning):
- * depth increases by exactly one per multipart nesting level (checked
- * before doing any work at this level); nparts_used is a single counter
- * threaded through the *entire* recursive call tree by pointer (not a
- * per-multipart-parent count), incremented once per call regardless of
- * whether this call turns out to be a leaf or another multipart container,
- * so it bounds total part count across the whole structure, not just
- * immediate siblings.
- *
- * Three shapes, matching RFC 9051's body-type-1part/body-type-mpart
- * grammar:
- *
- * - MULTIPART: requires a "boundary" Content-Type parameter (RFC 2046
- * SS5.1.1: "The only mandatory global parameter for the multipart
- * media type is the boundary parameter"); split_multipart() carves
- * body into its body-parts, each of which is itself split into
- * header/body (find_header_body_split()) and recursed into. Formatted
- * as `"(" body body ... SP media-subtype ")"` -- RFC 9051's own
- * SS7.5.2 example shows adjacent address/body tuples with no
- * separator between them, since each is already self-delimiting via
- * its own parens.
- * - message/rfc822 or message/global: scoped out entirely (see this
- * section's header comment) -- returns -1, which propagates as
- * "BODYSTRUCTURE not found" for the whole top-level message, not a
- * partially-correct structure with a fake or missing part.
- * - Everything else (body-type-basic / body-type-text): a single,
- * non-multipart part. body-fields (RFC 9051 SS9: `body-fld-param SP
- * body-fld-id SP body-fld-desc SP body-fld-enc SP body-fld-octets`)
- * are always emitted in full -- these are required basic fields, not
- * the optional extension data this implementation omits (see this
- * section's header comment) -- followed by body-fld-lines only for
- * "TEXT" parts (body-type-text's own extra field). body-fld-octets is
- * simply bodylen: RFC 9051 SS7.5.2 "the size in its transfer encoding
- * and not the resulting size after any decoding" means the *encoded*
- * byte count is exactly right, with no need to ever actually decode
- * base64/quoted-printable content. body-fld-lines similarly counts
- * raw '\n' occurrences in the still-encoded body bytes, not decoded
- * text lines -- same "size in its content transfer encoding" framing,
- * and the counting convention real servers commonly use.
- *
- * Returns 0 on success, -1 (propagated all the way up to build_
- * bodystructure()'s caller as "not found") on any depth/part-count/
- * malformed-multipart/message-rfc822/output-overflow failure.
- */
-static int
-build_body_structure(int depth, int *nparts_used, const char *hdr,
- size_t hdrlen, const char *body, size_t bodylen, char *out,
- size_t outsize, size_t *outlen)
-{
- char type[64], subtype[64];
- char params_fmt[600];
- char boundary[70 + 1]; /* RFC 2046 SS5.1.1 caps boundary at
- * 70 characters, +1 for NUL */
- int has_boundary;
-
- if (depth > MIME_MAX_DEPTH)
- return (-1);
- if (++*nparts_used > MIME_MAX_PARTS)
- return (-1);
-
- params_fmt[0] = '\0';
- if (parse_content_type(hdr, hdrlen, type, sizeof(type), subtype,
- sizeof(subtype), params_fmt, sizeof(params_fmt), boundary,
- sizeof(boundary), &has_boundary) == -1)
- return (-1);
-
- if (strcasecmp(type, "MULTIPART") == 0) {
- size_t part_starts[MIME_MAX_PARTS], part_ends[MIME_MAX_PARTS];
- int n, i;
-
- if (!has_boundary)
- return (-1);
- if (split_multipart(body, bodylen, boundary, part_starts,
- part_ends, &n, MIME_MAX_PARTS) == -1)
- return (-1);
-
- if (envbuf_append(out, outsize, outlen, "(", 1) == -1)
- return (-1);
- for (i = 0; i < n; i++) {
- const char *pbuf = body + part_starts[i];
- size_t plen = part_ends[i] - part_starts[i];
- size_t phdrend;
-
- /*
- * A genuinely empty body-part (RFC 2046 SS5.1.1's
- * body-part := MIME-part-headers [CRLF *OCTET] --
- * the CRLF-and-octets half is itself optional, so
- * zero bytes between two boundary delimiters is
- * spec-legal, not malformed) has no header/body
- * separator to find at all, by construction. Found
- * via real-hardware testing once the split_multipart()
- * empty-part fix (above) started correctly producing
- * plen == 0 for a part like this, instead of the
- * huge underflowed length that used to reach here
- * first: find_header_body_split() correctly reports
- * "not found" for a zero-length buffer (there IS no
- * separator in zero bytes), but treating that as this
- * whole part's failure -- and this loop returning -1
- * for the *entire* multipart structure the moment any
- * single part fails -- meant one legitimately empty
- * part broke BODYSTRUCTURE for the whole message.
- * Treat plen == 0 as hdrlen == 0 / bodylen == 0
- * directly instead: parse_content_type() with
- * hdrlen == 0 already falls through its own
- * "Content-Type field absent" path to the RFC 2045
- * SS5.2 default (text/plain; charset=us-ascii), so
- * this recurses into an ordinary empty leaf part
- * rather than aborting.
- */
- if (plen == 0)
- phdrend = 0;
- else if (find_header_body_split(pbuf, plen,
- &phdrend) == -1)
- return (-1);
- if (build_body_structure(depth + 1, nparts_used, pbuf,
- phdrend, pbuf + phdrend, plen - phdrend, out,
- outsize, outlen) == -1)
- return (-1);
- }
- if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
- return (-1);
- if (envbuf_append_nstring(out, outsize, outlen, subtype,
- strlen(subtype)) == -1)
- return (-1);
- return (envbuf_append(out, outsize, outlen, ")", 1));
- }
-
- if (strcasecmp(type, "MESSAGE") == 0 &&
- (strcasecmp(subtype, "RFC822") == 0 ||
- strcasecmp(subtype, "GLOBAL") == 0))
- return (-1); /* scoped out -- see this section's header
- * comment */
-
- {
- char *idval = NULL, *descval = NULL, *encval = NULL;
- size_t idlen = 0, desclen = 0, enclen = 0;
- char encstr[40];
- int is_text = (strcasecmp(type, "TEXT") == 0);
- int rc = 0;
-
- if (envbuf_append(out, outsize, outlen, "(", 1) == -1)
- return (-1);
- if (envbuf_append_nstring(out, outsize, outlen, type,
- strlen(type)) == -1)
- return (-1);
- if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
- return (-1);
- if (envbuf_append_nstring(out, outsize, outlen, subtype,
- strlen(subtype)) == -1)
- return (-1);
- if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
- return (-1);
- if (envbuf_append(out, outsize, outlen, params_fmt,
- strlen(params_fmt)) == -1)
- return (-1);
- if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
- return (-1);
-
- if (extract_header_field(hdr, hdrlen, "Content-Id", &idval,
- &idlen) == 0)
- rc = envbuf_append_nstring(out, outsize, outlen, idval,
- idlen);
- else
- rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0);
- free(idval);
- if (rc == -1)
- return (-1);
- if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
- return (-1);
-
- if (extract_header_field(hdr, hdrlen, "Content-Description",
- &descval, &desclen) == 0)
- rc = envbuf_append_nstring(out, outsize, outlen,
- descval, desclen);
- else
- rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0);
- free(descval);
- if (rc == -1)
- return (-1);
- if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
- return (-1);
-
- if (extract_header_field(hdr, hdrlen,
- "Content-Transfer-Encoding", &encval, &enclen) == 0 &&
- enclen > 0 && enclen < sizeof(encstr)) {
- memcpy(encstr, encval, enclen);
- encstr[enclen] = '\0';
- } else {
- strlcpy(encstr, "7BIT", sizeof(encstr));
- }
- free(encval);
- {
- static const char *const known[] = { "7BIT", "8BIT",
- "BINARY", "BASE64", "QUOTED-PRINTABLE" };
- size_t ki;
-
- for (ki = 0; ki < 5; ki++) {
- if (strcasecmp(encstr, known[ki]) == 0) {
- strlcpy(encstr, known[ki],
- sizeof(encstr));
- break;
- }
- }
- }
- if (envbuf_append_nstring(out, outsize, outlen, encstr,
- strlen(encstr)) == -1)
- return (-1);
- if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
- return (-1);
-
- {
- char numbuf[32];
-
- snprintf(numbuf, sizeof(numbuf), "%zu", bodylen);
- if (envbuf_append_str(out, outsize, outlen, numbuf) == -1)
- return (-1);
- }
-
- if (is_text) {
- size_t lines = 0, li;
- char numbuf2[32];
-
- for (li = 0; li < bodylen; li++) {
- if (body[li] == '\n')
- lines++;
- }
- snprintf(numbuf2, sizeof(numbuf2), " %zu", lines);
- if (envbuf_append_str(out, outsize, outlen, numbuf2) == -1)
- return (-1);
- }
-
- return (envbuf_append(out, outsize, outlen, ")", 1));
- }
-}
-
-/*
- * Top-level entry point: reads the whole message once (read_message_body(),
- * text_only=0 -- same underlying whole-message reader BODY.PEEK[] uses,
- * but capped at the much larger bodystructure_read_max rather than
- * APPEND_LITERAL_MAX -- see read_message_body()'s own comment for why
- * these two callers need different caps), locates its own header/body
- * split, and kicks off the recursive walk at depth 0. Returns 0 and a
- * malloc(3)'d *buf_out (caller frees) + *len_out on success; -1 (nothing
- * to free) if the message can't be read at all, has no header/body
- * separator, or build_body_structure() failed for any of its own
- * documented reasons (depth/part-count exceeded, malformed multipart, a
- * message/rfc822 part, or BODYSTRUCTURE_MAX exceeded) -- all "not found"
- * for this message's BODYSTRUCTURE, same failure handling as every other
- * content item in this file.
- */
-static int
-build_bodystructure(const char *basename, char **buf_out, uint32_t *len_out)
-{
- char *wholebuf = NULL;
- uint32_t wholelen = 0;
- size_t hdrend;
- char out[BODYSTRUCTURE_MAX];
- size_t outlen = 0;
- int nparts_used = 0;
-
- *buf_out = NULL;
- *len_out = 0;
-
- if (read_message_body(basename, 0, bodystructure_read_max,
- "BODYSTRUCTURE", &wholebuf, &wholelen) == -1)
- return (-1);
- if (wholelen == 0 ||
- find_header_body_split(wholebuf, wholelen, &hdrend) == -1) {
- free(wholebuf);
- return (-1);
- }
-
- if (build_body_structure(0, &nparts_used, wholebuf, hdrend,
- wholebuf + hdrend, wholelen - hdrend, out, sizeof(out),
- &outlen) == -1) {
- free(wholebuf);
- return (-1);
- }
- free(wholebuf);
-
- if ((*buf_out = malloc(outlen)) == NULL) {
- log_warn("session %u: malloc BODYSTRUCTURE buffer (%s)",
- session_id, basename);
- return (-1);
- }
- memcpy(*buf_out, out, outlen);
- *len_out = (uint32_t)outlen;
- return (0);
-}
-
-/*
- * Parses a dotted-numeric section-part string (RFC 9051 SS6.4.5.1's
- * section-part = nz-number *("." nz-number), e.g. "3.1") into an array of
- * 1-based part numbers. listener.c's tokenizer has already validated this
- * exact grammar before ever setting struct imsg_mbox_fetch's section_part
- * (see that field's comment in imapd.h) -- this function re-validates
- * anyway, cheap insurance rather than trusting a cross-process field
- * unconditionally, matching how every other store.c parser in this file
- * (parse_content_type(), the index line parser, etc.) treats its own input
- * as untrusted regardless of what already checked it upstream.
- *
- * Returns the number of parts on success (>= 1), or -1 if s is empty,
- * contains anything but digits and '.', has a leading-zero or otherwise
- * non-nz-number component, or has more components than maxpath (the
- * caller passes MIME_MAX_DEPTH, the same bound build_body_structure()'s
- * own recursion enforces -- a path deeper than that could never match
- * anything build_bodystructure() would have described in the first
- * place).
- */
-static int
-parse_section_part(const char *s, int *path, int maxpath)
-{
- int n = 0;
-
- if (s == NULL || *s == '\0')
- return (-1);
-
- while (*s != '\0') {
- long val;
- char *end;
-
- if (*s < '1' || *s > '9') /* nz-number: digit-nz first */
- return (-1);
- if (n >= maxpath)
- return (-1);
-
- errno = 0;
- val = strtol(s, &end, 10);
- if (val <= 0 || val > INT_MAX || errno != 0)
- return (-1);
- path[n++] = (int)val;
-
- s = end;
- if (*s == '\0')
- break;
- if (*s != '.')
- return (-1);
- s++;
- if (*s == '\0')
- return (-1); /* trailing dot */
- }
- return (n);
-}
-
-/*
- * Recursive descent used only once path[0] has already been established to
- * select among hdr/body's own children -- i.e. hdr/body is known-MULTIPART
- * before this is ever called (see locate_mime_part() below, the only
- * caller). Mirrors build_body_structure()'s own MULTIPART branch almost
- * exactly (same parse_content_type()/split_multipart()/find_header_body_
- * split() calls, same depth cap), but walks down exactly one child per
- * level -- the one path[0] names -- instead of visiting every child to
- * format output text.
- *
- * path[0..pathlen) is remaining, relative to hdr/body: path[0] selects
- * which child of *this* multipart to descend into; the rest resolves
- * relative to that child. pathlen is always >= 1 on entry (locate_mime_
- * part() guarantees this before the first call, and this function only
- * ever recurses with pathlen - 1 after confirming the next level is
- * itself MULTIPART, so a pathlen of 0 here would be a caller bug, not a
- * legal "arrived" state -- unlike build_body_structure(), there is no
- * separate depth-0-only entry point to special-case, since that's
- * locate_mime_part()'s job).
- *
- * Returns 0 and sets *part_out / *partlen_out to the target leaf part's raw
- * (still transfer-encoded) body bytes on success. Returns -1 if: depth
- * exceeds MIME_MAX_DEPTH; path[0] names a child that doesn't exist at
- * this level; the named child fails to parse as a header/body pair; or
- * (recursing further) the child path continues into isn't itself
- * MULTIPART where more path remains, or is a MULTIPART/MESSAGE-typed part
- * where no path remains (a container, not a leaf -- see MBOX_FETCH_BODY_
- * PART's imapd.h comment for why only leaf parts are returned) --
- * detected next call in either case, since every call re-parses its own
- * hdr/body's Content-Type first.
- */
-static int
-find_mime_part(int depth, const char *hdr, size_t hdrlen, const char *body,
- size_t bodylen, const int *path, int pathlen, const char **part_out,
- size_t *partlen_out)
-{
- char type[64], subtype[64];
- char params_fmt[600];
- char boundary[70 + 1];
- int has_boundary;
- size_t part_starts[MIME_MAX_PARTS], part_ends[MIME_MAX_PARTS];
- int n, want;
- const char *pbuf;
- size_t plen, phdrend;
-
- if (depth > MIME_MAX_DEPTH)
- return (-1);
-
- params_fmt[0] = '\0';
- if (parse_content_type(hdr, hdrlen, type, sizeof(type), subtype,
- sizeof(subtype), params_fmt, sizeof(params_fmt), boundary,
- sizeof(boundary), &has_boundary) == -1)
- return (-1);
- if (strcasecmp(type, "MULTIPART") != 0 || !has_boundary)
- return (-1);
- if (split_multipart(body, bodylen, boundary, part_starts, part_ends,
- &n, MIME_MAX_PARTS) == -1)
- return (-1);
-
- want = path[0];
- if (want < 1 || want > n)
- return (-1); /* no such part at this level */
-
- pbuf = body + part_starts[want - 1];
- plen = part_ends[want - 1] - part_starts[want - 1];
- /* Same empty-part handling as build_body_structure() above -- see
- * its comment. */
- if (plen == 0)
- phdrend = 0;
- else if (find_header_body_split(pbuf, plen, &phdrend) == -1)
- return (-1);
-
- if (pathlen == 1) {
- /*
- * path fully consumed by selecting this child. It must
- * itself be a genuine leaf (not MULTIPART, not MESSAGE/
- * RFC822|GLOBAL) to be returnable -- both remain out of
- * v1 scope, same cut build_body_structure() already makes
- * for MESSAGE/RFC822|GLOBAL, extended here to MULTIPART
- * containers too, since there is no "combined raw bytes of
- * all my children" concept to return for one.
- */
- char ctype[64], csub[64], cparams[600];
- char cboundary[70 + 1];
- int chb;
-
- cparams[0] = '\0';
- if (parse_content_type(pbuf, phdrend, ctype, sizeof(ctype),
- csub, sizeof(csub), cparams, sizeof(cparams), cboundary,
- sizeof(cboundary), &chb) == -1)
- return (-1);
- if (strcasecmp(ctype, "MULTIPART") == 0 ||
- (strcasecmp(ctype, "MESSAGE") == 0 &&
- (strcasecmp(csub, "RFC822") == 0 ||
- strcasecmp(csub, "GLOBAL") == 0)))
- return (-1);
-
- *part_out = pbuf + phdrend;
- *partlen_out = plen - phdrend;
- return (0);
- }
-
- return (find_mime_part(depth + 1, pbuf, phdrend, pbuf + phdrend,
- plen - phdrend, path + 1, pathlen - 1, part_out, partlen_out));
-}
-
-/*
- * Top-level entry point for locating one leaf MIME part's raw body bytes
- * by dotted-numeric path (path[0..pathlen), from parse_section_part()).
- * Handles a case find_mime_part() itself deliberately doesn't: RFC 9051
- * SS6.4.5.1's "Every message has at least one part number... Messages
- * that do not use MIME, ... only have a part 1" -- i.e. a non-multipart
- * top-level message's *own* body is addressed as section-part "1", with
- * nothing to descend into, which is a different rule than any nested
- * level (where path[0] always selects a real child of a real MULTIPART
- * container). Folding that special case into find_mime_part() itself
- * would make its own contract asymmetric between depth 0 and every other
- * depth; splitting it out here keeps both functions' contracts simple.
- *
- * Returns 0 and sets *part_out / *partlen_out on success. Returns -1 if:
- * pathlen is 0; the top-level message is non-multipart (or itself
- * MESSAGE/RFC822|GLOBAL, also scoped out -- see find_mime_part()'s
- * comment) and path isn't exactly {1}; or find_mime_part() failed for
- * any of its own documented reasons.
- */
-static int
-locate_mime_part(const char *hdr, size_t hdrlen, const char *body,
- size_t bodylen, const int *path, int pathlen, const char **part_out,
- size_t *partlen_out)
-{
- char type[64], subtype[64];
- char params_fmt[600];
- char boundary[70 + 1];
- int has_boundary;
-
- if (pathlen < 1)
- return (-1);
-
- params_fmt[0] = '\0';
- if (parse_content_type(hdr, hdrlen, type, sizeof(type), subtype,
- sizeof(subtype), params_fmt, sizeof(params_fmt), boundary,
- sizeof(boundary), &has_boundary) == -1)
- return (-1);
-
- if (strcasecmp(type, "MULTIPART") == 0)
- return (find_mime_part(1, hdr, hdrlen, body, bodylen, path,
- pathlen, part_out, partlen_out));
-
- if (pathlen != 1 || path[0] != 1)
- return (-1);
- *part_out = body;
- *partlen_out = bodylen;
- return (0);
-}
-
-/*
- * Applies a <<partial>> range (RFC 9051 SS6.4.5's "<start.count>") to
- * content[0..contentlen), used uniformly by handle_mbox_fetch() for
- * BODY.PEEK[]/BODY.PEEK[TEXT]/BODY.PEEK[<section-part>] alike -- content
- * itself is never copied here, only *out / *outlen (a subrange of content)
- * computed; the caller does the one real memcpy(3), same "compute
- * offsets, let the caller own the copy" shape as every other length-
- * tracking helper in this file.
- *
- * !has_partial (no <<...>> suffix in the request) means "the whole
- * content, subject only to FETCH_PART_MAX" -- *outlen is silently
- * clamped down to that cap here (empty-string special case aside, RFC
- * 9051 doesn't distinguish a server-imposed cap from any other partial
- * response; a client that wants guaranteed-complete large content is
- * expected to use its own <<partial>> ranging, same real-world pattern
- * observed from Apple Mail's own "BODY.PEEK[TEXT]<0.16384>" traffic).
- * has_partial clamps the requested partial_count down to FETCH_PART_MAX
- * too, and separately clamps partial_start/partial_count against
- * contentlen itself per SS6.4.5: "If the starting octet is beyond the
- * end of the text, an empty string is returned... Any partial fetch that
- * attempts to read beyond the end of the text is truncated as
- * appropriate."
- */
-static void
-apply_partial_range(const char *content, size_t contentlen, int has_partial,
- uint32_t partial_start, uint32_t partial_count, const char **out,
- size_t *outlen)
-{
- if (!has_partial) {
- *out = content;
- *outlen = contentlen;
- if (*outlen > FETCH_PART_MAX)
- *outlen = FETCH_PART_MAX;
- return;
- }
-
- if (partial_start >= contentlen) {
- *out = content;
- *outlen = 0;
- return;
- }
-
- *out = content + partial_start;
- *outlen = contentlen - partial_start;
- if (*outlen > partial_count)
- *outlen = partial_count;
- if (*outlen > FETCH_PART_MAX)
- *outlen = FETCH_PART_MAX;
-}
-
-/*
- * Top-level entry point for BODY.PEEK[<section-part>]: reads the whole
- * message once (read_message_body(), text_only=0, bodystructure_read_max
- * -- same large cap build_bodystructure() uses, and for the same reason:
- * a message can legitimately be far larger than APPEND_LITERAL_MAX once
- * it carries an attachment, even though what's ultimately returned here
- * -- one leaf part, sliced -- is small), locates its own header/body
- * split, resolves path[0..pathlen) via locate_mime_part(), then applies
- * the caller's partial range via apply_partial_range(). Returns 0 and a
- * malloc(3)'d *buf_out (caller frees; left NULL if *len_out comes back 0)
- * + *len_out on success. Returns -1 (nothing to free) if the message
- * can't be read, has no header/body separator, or locate_mime_part()
- * failed for any of its own documented reasons -- all "not found" for
- * this content item, same failure handling as every other one in this
- * file.
- */
-static int
-extract_mime_part(const char *basename, const int *path, int pathlen,
- int has_partial, uint32_t partial_start, uint32_t partial_count,
- char **buf_out, uint32_t *len_out)
-{
- char *wholebuf = NULL;
- uint32_t wholelen = 0;
- size_t hdrend;
- const char *part = NULL;
- size_t partlen = 0;
- const char *out;
- size_t outlen;
-
- *buf_out = NULL;
- *len_out = 0;
-
- if (read_message_body(basename, 0, bodystructure_read_max,
- "BODY[<part>]", &wholebuf, &wholelen) == -1)
- return (-1);
- if (wholelen == 0 ||
- find_header_body_split(wholebuf, wholelen, &hdrend) == -1) {
- free(wholebuf);
- return (-1);
- }
-
- if (locate_mime_part(wholebuf, hdrend, wholebuf + hdrend,
- wholelen - hdrend, path, pathlen, &part, &partlen) == -1) {
- free(wholebuf);
- return (-1);
- }
-
- apply_partial_range(part, partlen, has_partial, partial_start,
- partial_count, &out, &outlen);
-
- if (outlen > 0) {
- if ((*buf_out = malloc(outlen)) == NULL) {
- log_warn("session %u: malloc BODY[<part>] buffer (%s)",
- session_id, basename);
- free(wholebuf);
- return (-1);
- }
- memcpy(*buf_out, out, outlen);
- }
- *len_out = (uint32_t)outlen;
- free(wholebuf);
- return (0);
-}
-
-/*
- * Builds the space-separated IMAP flag-atom list for one message from two
- * sources: the maildir flag-suffix letters (from locate_message_file()'s
- * suffix_out, e.g. ":2,FS" -- "" if the message is still in new/,
- * unflagged) and the index's own comma-separated keyword field. Letter ->
- * flag mapping is Courier's maildir(5) (already sourced in openimap-
- * storage-backend.md): D=Draft, F=Flagged, R=Answered (historically
- * "Replied"), S=Seen, T=Deleted (historically "Trashed").
- */
-static void
-build_flags_string(const char *maildir_suffix, const char *keywords,
- char *out, size_t outsize)
-{
- static const struct {
- char letter;
- const char *flag;
- } stdflags[] = {
- { 'D', "\\Draft" },
- { 'F', "\\Flagged" },
- { 'R', "\\Answered" },
- { 'S', "\\Seen" },
- { 'T', "\\Deleted" },
- };
- const char *letters;
- size_t i;
- int first = 1;
-
- out[0] = '\0';
-
- letters = strstr(maildir_suffix, "2,");
- letters = (letters != NULL) ? letters + 2 : "";
-
- for (i = 0; i < sizeof(stdflags) / sizeof(stdflags[0]); i++) {
- if (strchr(letters, stdflags[i].letter) == NULL)
- continue;
- if (!first)
- strlcat(out, " ", outsize);
- strlcat(out, stdflags[i].flag, outsize);
- first = 0;
- }
-
- if (keywords != NULL && keywords[0] != '\0') {
- char kwbuf[512];
- char *kw, *save;
-
- strlcpy(kwbuf, keywords, sizeof(kwbuf));
- for (kw = strtok_r(kwbuf, ",", &save); kw != NULL;
- kw = strtok_r(NULL, ",", &save)) {
- if (!first)
- strlcat(out, " ", outsize);
- strlcat(out, kw, outsize);
- first = 0;
- }
- }
-}
-
-/*
- * RFC 9051 SS2.3.1.1 INTERNALDATE is "the internal date of the message" --
- * this implementation's own choice (not something the design docs
- * resolve) is the delivery timestamp already encoded in the maildir
- * basename's own leading field (Courier maildir(5)'s unique-name format,
- * `<timestamp>.<uniquer>.<hostname>`, already sourced in openimap-
- * storage-backend.md), not the file's mtime -- more portable across
- * backups/copies that can touch mtime without touching content. Falls
- * back to the current time for a foreign/hand-placed file that doesn't
- * follow that convention, rather than failing the whole FETCH over one
- * oddly-named message.
- */
-static int64_t
-parse_maildir_timestamp(const char *basename)
-{
- char *ep;
- long long ts;
-
- errno = 0;
- ts = strtoll(basename, &ep, 10);
- if (ep == basename || *ep != '.' || errno != 0 || ts < 0)
- return ((int64_t)time(NULL));
- return ((int64_t)ts);
-}
-
-/*
- * IMSG_MBOX_FETCH handling -- v1 scope is message metadata only (FLAGS,
- * UID, INTERNALDATE, RFC822.SIZE), one sequence-set range per request; see
- * imapd.h's imsg_mbox_fetch comment and listener.c's cmd_fetch() for
- * the full reasoning. Sends one IMSG_MBOX_FETCH_META per matching message,
- * in ascending sequence order (the index is UID-ordered; meta.seqno is
- * always this message's live 1-based position within the index freshly
- * loaded a few lines below, i.e. i itself in the loop below -- never a
- * separately cached number, so it's automatically correct even though
- * EXPUNGE now exists and can renumber later messages between one FETCH
- * and the next; a correction to this comment's own earlier claim, from
- * before EXPUNGE was implemented, that index order and sequence-number
- * order were "always identical" specifically because EXPUNGE didn't exist
- * yet -- the real reason is simpler and still holds regardless: there was
- * never any separate bookkeeping to go stale in the first place), then
- * exactly one terminal IMSG_MBOX_RESULT.
- *
- * Uses LOCK_SH (not handle_mbox_select()'s LOCK_EX): FETCH only reads the
- * index, never mutates it, so multiple concurrent FETCHes (from different
- * store children for the same user -- fork-per-session, not
- * fork-per-user) can proceed together; a shared lock still blocks against
- * a concurrent SELECT's exclusive lock, so a FETCH can never observe an
- * index mid-rewrite (belt-and-suspenders on top of the rename(2)-based
- * atomicity that already prevents a torn read either way).
- *
- * RFC 7162 additions this pass: meta.modseq is always populated from the
- * index's per-message field (index_parse_line()) -- cheap, already parsed
- * -- and req->has_changedsince (CHANGEDSINCE fetch-modifier, SS3.1.4.1)
- * skips any message whose mod-sequence is not strictly greater than the
- * given value, exactly the "only returned for messages that have a
- * mod-sequence bigger than <mod-sequence>" rule. req->attrs & MBOX_FETCH_
- * MODSEQ is not consulted here at all -- meta.modseq is unconditionally
- * computed regardless (matching every other field in this struct), and it
- * is listener.c's session_send_fetch_response() that decides whether to
- * print it, same split as FLAGS/UID/INTERNALDATE/RFC822.SIZE already use.
- *
- * RFC 9051 SS6.4.9 addition (UID command): req->by_uid switches lo/hi (and
- * "*") to UID-space resolution instead of position-space, and req->want_
- * vanished (RFC 7162 SS3.2.6) reports gaps in that range as VANISHED
- * (EARLIER) via send_vanished_range() before any FETCH response -- see
- * both fields' comments in imapd.h and this function's own lo/hi-
- * resolution comment below. index order and sequence-number order are
- * still always identical regardless of by_uid (v1 has EXPUNGE now, but
- * meta.seqno = i is still each message's live, correctly-numbered
- * position in the just-loaded idx -- EXPUNGE only ever runs under its own
- * exclusive lock, never concurrently with this shared-locked read).
- */
-static void
-handle_mbox_fetch(struct imsg_mbox_fetch *req, struct imsgev *iev)
-{
- struct mbox_index idx;
- struct imsg_mbox_result result;
- int fd;
- uint32_t lo, hi, i, sent = 0;
- int ok = 1;
-
- memset(&idx, 0, sizeof(idx));
-
- if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
- log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
- ok = 0;
- goto done;
- }
- if (flock(fd, LOCK_SH) == -1) {
- log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
- close(fd);
- ok = 0;
- goto done;
- }
- if (index_load(fd, &idx) == -1) {
- flock(fd, LOCK_UN);
- close(fd);
- ok = 0;
- goto done;
- }
- flock(fd, LOCK_UN);
- close(fd);
-
- /*
- * RFC 9051 SS6.4.9: UID FETCH's sequence-set argument is UIDs, not
- * positions -- lo/hi (and "*") resolve against index_max_uid(), the
- * same UID-space upper bound SEARCH's UIDSET criterion already uses,
- * not idx.nlines. The hi-clamp-to-nlines below only makes sense in
- * position space, so it's skipped for by_uid -- the unified loop
- * below bounds itself against rec.uid instead.
- */
- if (req->by_uid) {
- uint32_t max_uid = index_max_uid(&idx);
-
- lo = req->lo_is_star ? max_uid : req->seq_lo;
- hi = req->hi_is_star ? max_uid : req->seq_hi;
- } else {
- lo = req->lo_is_star ? (uint32_t)idx.nlines : req->seq_lo;
- hi = req->hi_is_star ? (uint32_t)idx.nlines : req->seq_hi;
- }
- if (lo < 1)
- lo = 1;
- if (!req->by_uid && hi > (uint32_t)idx.nlines)
- hi = (uint32_t)idx.nlines;
-
- /*
- * RFC 7162 SS3.2.6 VANISHED UID FETCH modifier: report gaps in
- * [lo, hi] as VANISHED (EARLIER) *before* any FETCH response below
- * ("Any VANISHED (EARLIER) responses MUST be returned before any
- * FETCH responses") -- guaranteed here purely by send order, since
- * imsg messages on one channel are read by listener.c in the order
- * they're sent, and this call is textually before the loop that
- * sends IMSG_MBOX_FETCH_META below.
- */
- if (req->by_uid && req->want_vanished)
- send_vanished_range(&idx, lo, hi, iev);
-
- for (i = 1; i <= (uint32_t)idx.nlines; i++) {
- struct imsg_mbox_fetch_meta meta;
- struct index_rec rec;
- 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
- * depending on req->by_uid -- see the lo/hi resolution
- * above. Both sides are ascending (i by loop construction,
- * rec.uid because the index itself is UID-ordered), so a
- * plain "below range, skip; above range, stop" test is
- * correct and only ever makes one pass over idx.lines.
- */
- if (req->by_uid) {
- if (rec.uid < lo)
- continue;
- if (rec.uid > hi)
- break;
- } else {
- if (i < lo)
- continue;
- if (i > hi)
- break;
- }
-
- if (req->has_changedsince && rec.modseq <= req->changedsince)
- continue;
-
- memset(&meta, 0, sizeof(meta));
- meta.seqno = i;
- meta.uid = rec.uid;
- meta.modseq = rec.modseq;
-
- if (req->attrs & (MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_FLAGS)) {
- if (locate_message_file(rec.basename, &size, suffix,
- sizeof(suffix)) == -1) {
- log_warnx("session %u: message %s (uid %u) "
- "indexed but missing on disk -- skipped",
- session_id, rec.basename, meta.uid);
- continue;
- }
- have_file = 1;
- }
-
- if (req->attrs & MBOX_FETCH_RFC822_SIZE)
- meta.size = (uint64_t)size; /* F13 fix: was (uint32_t) */
- if (req->attrs & MBOX_FETCH_FLAGS)
- build_flags_string(have_file ? suffix : "",
- rec.keywords, meta.flags, sizeof(meta.flags));
- if (req->attrs & MBOX_FETCH_INTERNALDATE)
- meta.internaldate = parse_maildir_timestamp(
- rec.basename);
-
- /*
- * IMSG_MBOX_FETCH_HEADER, sent before this message's own
- * IMSG_MBOX_FETCH_META -- see that struct's comment in
- * imapd.h for why listener.c depends on this exact
- * ordering. Always sent (with found=0 on any failure) when
- * either MBOX_FETCH_BODY_HEADER or MBOX_FETCH_HEADER_FIELDS
- * was requested, rather than only sent on success, so
- * listener.c never has to distinguish "no header message
- * arrived for this seqno" from "one legitimately hasn't been
- * processed yet" -- it just checks found on whatever it
- * gets. The two requested-item kinds share this one imsg/
- * struct wholesale (see MBOX_FETCH_HEADER_FIELDS's comment
- * in imapd.h for why) -- listener.c's parse_fetch_atts()
- * already made HEADER win if a client somehow requested both
- * in the same FETCH, so at most one of the two bits is set
- * here in practice, but the check below still prefers plain
- * HEADER defensively either way.
- */
- if (req->attrs & (MBOX_FETCH_BODY_HEADER |
- MBOX_FETCH_HEADER_FIELDS)) {
- struct imsg_mbox_fetch_header hdrmeta;
- char *hdrbuf = NULL;
- uint32_t hdrlen = 0;
- char *combined;
- size_t combined_len;
- int rc;
-
- memset(&hdrmeta, 0, sizeof(hdrmeta));
- hdrmeta.seqno = i;
- hdrmeta.uid = rec.uid;
- if (req->attrs & MBOX_FETCH_BODY_HEADER)
- rc = read_message_header(rec.basename,
- &hdrbuf, &hdrlen);
- else
- rc = read_message_header_fields(rec.basename,
- req->header_fields,
- req->header_fields_not, &hdrbuf, &hdrlen);
- if (rc == 0) {
- hdrmeta.found = 1;
- hdrmeta.hdrlen = hdrlen;
- }
-
- combined_len = sizeof(hdrmeta) +
- (hdrmeta.found ? hdrlen : 0);
- if ((combined = malloc(combined_len)) == NULL) {
- log_warn("session %u: malloc "
- "IMSG_MBOX_FETCH_HEADER buffer",
- session_id);
- } else {
- memcpy(combined, &hdrmeta, sizeof(hdrmeta));
- if (hdrmeta.found)
- memcpy(combined + sizeof(hdrmeta),
- hdrbuf, hdrlen);
- if (imsg_compose(&iev->ibuf,
- IMSG_MBOX_FETCH_HEADER, 0, 0, -1, combined,
- combined_len) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_FETCH_HEADER",
- session_id);
- free(combined);
- }
- free(hdrbuf);
- }
-
- /*
- * IMSG_MBOX_FETCH_BODY, same "always sent when requested,
- * found=0 on any failure, ordered right before this
- * message's own IMSG_MBOX_FETCH_META" contract as the
- * IMSG_MBOX_FETCH_HEADER block just above -- see struct
- * imsg_mbox_fetch_body's comment in imapd.h. WHOLE wins
- * over TEXT if a client somehow requested both (see MBOX_
- * FETCH_BODY_TEXT's comment in imapd.h for why that's a
- * deliberate, not accidental, choice); listener.c's tokenizer
- * never sets more than one of WHOLE/TEXT/PART together (each
- * comes from a distinct, mutually exclusive section-spec), so
- * PART is simply a third case here, not a fourth combination
- * to disambiguate.
- *
- * Partial-range handling (<<start.count>>, req->has_partial/
- * partial_start/partial_count) applies uniformly across all
- * three: for PART, extract_mime_part() does its own read +
- * locate + range-slice internally (it needs the large
- * bodystructure_read_max read cap regardless of whether a
- * range was requested, since the target part's *offset*
- * within the message isn't known until the whole message is
- * read and walked). For WHOLE/TEXT, read_cap switches to that
- * same large cap only when a partial range was requested --
- * see apply_partial_range()'s own comment for why a *ranged*
- * request on an over-APPEND_LITERAL_MAX message should still
- * succeed (this is exactly the gap real Apple Mail traffic
- * hit: "BODY.PEEK[TEXT]<0.16384>" against a message that only
- * exceeds 12000 bytes because of its own attachment) while a
- * *whole*-content request keeps the tighter reject-not-
- * truncate behavior it always had.
- */
- if (req->attrs & (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT |
- MBOX_FETCH_BODY_PART)) {
- struct imsg_mbox_fetch_body bodymeta;
- char *bodybuf = NULL;
- uint32_t bodylen = 0;
- char *combined;
- size_t combined_len;
- int want_whole =
- (req->attrs & MBOX_FETCH_BODY_WHOLE) != 0;
- int want_part = !want_whole &&
- (req->attrs & MBOX_FETCH_BODY_PART) != 0 &&
- !(req->attrs & MBOX_FETCH_BODY_TEXT);
- int want_text = !want_whole &&
- !want_part;
-
- memset(&bodymeta, 0, sizeof(bodymeta));
- bodymeta.seqno = i;
- bodymeta.uid = rec.uid;
- bodymeta.is_text = want_text;
-
- if (want_part) {
- int path[MIME_MAX_DEPTH];
- int pathlen;
-
- pathlen = parse_section_part(req->section_part,
- path, MIME_MAX_DEPTH);
- if (pathlen != -1 &&
- extract_mime_part(rec.basename, path,
- pathlen, req->has_partial,
- req->partial_start, req->partial_count,
- &bodybuf, &bodylen) == 0)
- bodymeta.found = 1;
- } else {
- size_t read_cap = req->has_partial ?
- bodystructure_read_max : APPEND_LITERAL_MAX;
- char *wholebuf = NULL;
- uint32_t wholelen = 0;
-
- if (read_message_body(rec.basename, want_text,
- read_cap, want_text ? "BODY[TEXT]" :
- "BODY[]", &wholebuf, &wholelen) == 0) {
- const char *out;
- size_t outlen;
-
- apply_partial_range(wholebuf, wholelen,
- req->has_partial,
- req->partial_start,
- req->partial_count, &out, &outlen);
- if (outlen == 0) {
- bodymeta.found = 1;
- } else if ((bodybuf = malloc(outlen)) ==
- NULL) {
- log_warn("session %u: malloc "
- "IMSG_MBOX_FETCH_BODY "
- "slice (%s)", session_id,
- rec.basename);
- } else {
- memcpy(bodybuf, out, outlen);
- bodylen = (uint32_t)outlen;
- bodymeta.found = 1;
- }
- }
- free(wholebuf);
- }
- bodymeta.bodylen = bodylen;
-
- combined_len = sizeof(bodymeta) +
- (bodymeta.found ? bodylen : 0);
- if ((combined = malloc(combined_len)) == NULL) {
- log_warn("session %u: malloc "
- "IMSG_MBOX_FETCH_BODY buffer", session_id);
- } else {
- memcpy(combined, &bodymeta, sizeof(bodymeta));
- if (bodymeta.found && bodylen > 0)
- memcpy(combined + sizeof(bodymeta),
- bodybuf, bodylen);
- if (imsg_compose(&iev->ibuf,
- IMSG_MBOX_FETCH_BODY, 0, 0, -1, combined,
- combined_len) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_FETCH_BODY", session_id);
- free(combined);
- }
- free(bodybuf);
- }
-
- /*
- * IMSG_MBOX_FETCH_ENVELOPE, same "always sent when
- * requested, found=0 on any failure, ordered right before
- * this message's own IMSG_MBOX_FETCH_META" contract as the
- * IMSG_MBOX_FETCH_HEADER/IMSG_MBOX_FETCH_BODY blocks above --
- * see struct imsg_mbox_fetch_envelope's comment in imapd.h.
- * Unlike those two, the trailing bytes here are build_
- * envelope()'s already-formatted response text, not raw
- * message bytes -- see that function's own comment.
- */
- if (req->attrs & MBOX_FETCH_ENVELOPE) {
- struct imsg_mbox_fetch_envelope envmeta;
- char *envbuf = NULL;
- uint32_t envlen = 0;
- char *combined;
- size_t combined_len;
-
- memset(&envmeta, 0, sizeof(envmeta));
- envmeta.seqno = i;
- envmeta.uid = rec.uid;
- if (build_envelope(rec.basename, &envbuf, &envlen) == 0) {
- envmeta.found = 1;
- envmeta.envlen = envlen;
- }
-
- combined_len = sizeof(envmeta) +
- (envmeta.found ? envlen : 0);
- if ((combined = malloc(combined_len)) == NULL) {
- log_warn("session %u: malloc "
- "IMSG_MBOX_FETCH_ENVELOPE buffer", session_id);
- } else {
- memcpy(combined, &envmeta, sizeof(envmeta));
- if (envmeta.found && envlen > 0)
- memcpy(combined + sizeof(envmeta),
- envbuf, envlen);
- if (imsg_compose(&iev->ibuf,
- IMSG_MBOX_FETCH_ENVELOPE, 0, 0, -1, combined,
- combined_len) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_FETCH_ENVELOPE", session_id);
- free(combined);
- }
- free(envbuf);
- }
-
- /*
- * IMSG_MBOX_FETCH_BODYSTRUCTURE, same "always sent when
- * requested, found=0 on any failure, ordered right before
- * this message's own IMSG_MBOX_FETCH_META" contract, and same
- * "already-formatted response text" shape, as IMSG_MBOX_
- * FETCH_ENVELOPE just above -- see struct imsg_mbox_fetch_
- * bodystructure's comment in imapd.h.
- */
- if (req->attrs & MBOX_FETCH_BODYSTRUCTURE) {
- struct imsg_mbox_fetch_bodystructure bsmeta;
- char *bsbuf = NULL;
- uint32_t bslen = 0;
- char *combined;
- size_t combined_len;
-
- memset(&bsmeta, 0, sizeof(bsmeta));
- bsmeta.seqno = i;
- bsmeta.uid = rec.uid;
- if (build_bodystructure(rec.basename, &bsbuf, &bslen) == 0) {
- bsmeta.found = 1;
- bsmeta.bslen = bslen;
- }
-
- combined_len = sizeof(bsmeta) +
- (bsmeta.found ? bslen : 0);
- if ((combined = malloc(combined_len)) == NULL) {
- log_warn("session %u: malloc "
- "IMSG_MBOX_FETCH_BODYSTRUCTURE buffer",
- session_id);
- } else {
- memcpy(combined, &bsmeta, sizeof(bsmeta));
- if (bsmeta.found && bslen > 0)
- memcpy(combined + sizeof(bsmeta),
- bsbuf, bslen);
- if (imsg_compose(&iev->ibuf,
- IMSG_MBOX_FETCH_BODYSTRUCTURE, 0, 0, -1,
- combined, combined_len) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_FETCH_BODYSTRUCTURE",
- session_id);
- free(combined);
- }
- free(bsbuf);
- }
-
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_FETCH_META, 0, 0, -1,
- &meta, sizeof(meta)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_FETCH_META", session_id);
- else
- sent++;
- }
-
-done:
- index_free(&idx);
-
- memset(&result, 0, sizeof(result));
- result.ok = ok;
- result.count = sent;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
- sizeof(result)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * Inverse of build_flags_string()'s letter table: maps a maildir
- * flag-suffix's letters (the part after "2,", e.g. "FS") back to the
- * MBOX_FLAG_* bitmask. Needed only here -- FETCH only ever renders flags
- * outward and never needed to merge new ones in, but STORE has to know a
- * message's *current* system flags before it can compute what ADD/REMOVE
- * (as opposed to SET) leaves it with.
- */
-static uint32_t
-letters_to_sysflags(const char *letters)
-{
- uint32_t f = 0;
-
- if (strchr(letters, 'D') != NULL)
- f |= MBOX_FLAG_DRAFT;
- if (strchr(letters, 'F') != NULL)
- f |= MBOX_FLAG_FLAGGED;
- if (strchr(letters, 'R') != NULL)
- f |= MBOX_FLAG_ANSWERED;
- if (strchr(letters, 'S') != NULL)
- f |= MBOX_FLAG_SEEN;
- if (strchr(letters, 'T') != NULL)
- f |= MBOX_FLAG_DELETED;
- return (f);
-}
-
-/*
- * Renders sysflags back into the maildir suffix's required letter order --
- * Courier maildir(5): "The letters must be in ASCII order" -- D, F, R, S,
- * T, the same order build_flags_string()'s table already uses.
- */
-static void
-sysflags_to_letters(uint32_t sysflags, char *out, size_t outsize)
-{
- size_t i = 0;
-
- if ((sysflags & MBOX_FLAG_DRAFT) && i + 1 < outsize)
- out[i++] = 'D';
- if ((sysflags & MBOX_FLAG_FLAGGED) && i + 1 < outsize)
- out[i++] = 'F';
- if ((sysflags & MBOX_FLAG_ANSWERED) && i + 1 < outsize)
- out[i++] = 'R';
- if ((sysflags & MBOX_FLAG_SEEN) && i + 1 < outsize)
- out[i++] = 'S';
- if ((sysflags & MBOX_FLAG_DELETED) && i + 1 < outsize)
- out[i++] = 'T';
- out[i] = '\0';
-}
-
-/*
- * RFC 9051 SS6.3.11 STATUS. Modeled directly on handle_mbox_select()'s
- * open/flock/index_load/new-mail-scan/index_save sequence -- STATUS "does
- * not change the currently selected mailbox, nor does it affect the state
- * of any messages" (SS6.3.11), so unlike a real SELECT this never touches
- * s->state or holds the fd open past this one call, but it deliberately
- * reuses the *same* new/ scan SELECT does (picking up messages delivered
- * since the index was last written and appending them with fresh UIDs)
- * rather than answering from a possibly-stale on-disk index: MESSAGES/
- * UIDNEXT/UNSEEN would otherwise be able to under-report mail that already
- * arrived. This is a judgment call, not something the RFC mandates --
- * SS6.3.11 only says STATUS "MUST NOT be used as a check for new messages"
- * (i.e. clients shouldn't rely on it *instead of* EXISTS/RECENT/IDLE), which
- * is a client-behavior note, not a server prohibition on noticing new mail
- * while it's already got the index open.
- *
- * MESSAGES/UIDNEXT/UIDVALIDITY/HIGHESTMODSEQ are always computed (free --
- * index header fields once index_load() has run), matching imsg_mbox_
- * selected's own "always compute" precedent. UNSEEN/DELETED/SIZE are the
- * deliberate exception: computing any of them requires calling locate_
- * message_file() once per message, which RFC 9051 SS6.3.11 explicitly
- * flags as potentially expensive ("the STATUS command SIZE...can take a
- * significant amount of time...clients should use STATUS SIZE cautiously")
- * -- so that scan runs only when req->attrs asks for at least one of the
- * three, and once it runs, all three are computed together regardless of
- * which subset was actually requested (locate_message_file() already
- * returns both the flag suffix and the size in a single call, so there is
- * no marginal cost to computing all three vs. one).
- */
-static void
-handle_mbox_status(struct imsg_mbox_status *req, struct imsgev *iev)
-{
- struct mbox_index idx;
- struct imsg_mbox_status_result reply;
- int fd;
- DIR *dp;
- struct dirent *de;
- char saved[MBOX_NAME_MAX];
- const char *target;
- int switched = 0;
-
- memset(&reply, 0, sizeof(reply));
-
- /*
- * RFC 9051 SS6.3.11: STATUS targets a mailbox independent of
- * whatever this session currently has selected -- "without...
- * opening a mailbox" -- so this can't just answer for cwd the way
- * FETCH/STORE/EXPUNGE correctly do. select_mailbox_dir() is reused
- * here as a temporary visit-and-restore primitive rather than a
- * lasting selection change: resolve the target, remember whatever
- * was selected before, and switch back to it before replying
- * regardless of how this function exits (see the "send:" label).
- */
- strlcpy(saved, current_mailbox_dir, sizeof(saved));
- if (mailbox_name_is_inbox(req->mailbox))
- target = "";
- else if (mailbox_name_valid(req->mailbox))
- target = req->mailbox;
- else {
- log_debug("session %u: STATUS %s: invalid mailbox name",
- session_id, req->mailbox);
- reply.ok = 0;
- goto send;
- }
- if (select_mailbox_dir(target) == -1) {
- log_debug("session %u: STATUS %s: no such mailbox",
- session_id, req->mailbox);
- reply.ok = 0;
- goto send;
- }
- switched = 1;
-
- if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
- log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
- reply.ok = 0;
- goto send;
- }
- if (flock(fd, LOCK_EX) == -1) {
- log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
- close(fd);
- reply.ok = 0;
- goto send;
- }
-
- if (index_load(fd, &idx) == -1) {
- flock(fd, LOCK_UN);
- close(fd);
- reply.ok = 0;
- goto send;
- }
-
- dp = opendir("new");
- if (dp == NULL) {
- if (errno != ENOENT)
- log_warn("session %u: opendir new", session_id);
- } else {
- while ((de = readdir(dp)) != NULL) {
- if (de->d_name[0] == '.')
- continue;
- if (index_has_basename(&idx, de->d_name))
- continue;
- if (index_append(&idx, idx.uidnext, de->d_name)
- == -1) {
- closedir(dp);
- index_free(&idx);
- flock(fd, LOCK_UN);
- close(fd);
- reply.ok = 0;
- goto send;
- }
- idx.uidnext++;
- }
- closedir(dp);
- }
-
- if (index_save(&idx) == -1) {
- index_free(&idx);
- flock(fd, LOCK_UN);
- close(fd);
- reply.ok = 0;
- goto send;
- }
-
- reply.ok = 1;
- reply.messages = (uint32_t)idx.nlines;
- reply.uidnext = idx.uidnext;
- reply.uidvalidity = idx.uidvalidity;
- reply.highestmodseq = idx.highestmodseq;
-
- if (req->attrs &
- (STATUS_ATT_UNSEEN | STATUS_ATT_DELETED | STATUS_ATT_SIZE)) {
- size_t i;
-
- for (i = 0; i < idx.nlines; i++) {
- struct index_rec rec;
- const char *lp;
- uint32_t sysflags;
- char suffix[64];
- off_t size;
-
- if (index_parse_line(idx.lines[i], &rec) == -1)
- continue;
- if (locate_message_file(rec.basename, &size, suffix,
- sizeof(suffix)) == -1) {
- log_warnx("session %u: message %s indexed but "
- "missing on disk -- skipped for STATUS "
- "UNSEEN/DELETED/SIZE", session_id,
- rec.basename);
- continue;
- }
-
- lp = strstr(suffix, "2,");
- sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
- if (!(sysflags & MBOX_FLAG_SEEN))
- reply.unseen++;
- if (sysflags & MBOX_FLAG_DELETED)
- reply.deleted++;
- reply.size += (uint64_t)size;
- }
- }
-
- index_free(&idx);
- flock(fd, LOCK_UN);
- close(fd);
-
-send:
- /* Restore whatever this session actually had selected before this
- * STATUS call -- select_mailbox_dir() is a no-op if that's already
- * where we are (e.g. STATUS on the currently selected mailbox, or
- * the "switched" gate below wasn't even reached). */
- if (switched && select_mailbox_dir(saved) == -1)
- log_warnx("session %u: STATUS: failed to restore selection "
- "to %s -- session may be left in an inconsistent state",
- session_id, saved[0] != '\0' ? saved : "INBOX");
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_STATUS_RESULT, 0, 0, -1, &reply,
- sizeof(reply)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_STATUS_RESULT",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * True if comma-separated list contains kw as a whole token (not a
- * substring match) -- used by merge_keywords() below to dedupe and to
- * implement REMOVE. O(n) per lookup, same "fine at v1's scale" reasoning
- * index_has_basename() already documents for the index itself: per-message
- * keyword lists are short (a handful of entries at most), not the mailbox
- * as a whole.
- */
-static int
-kw_list_contains(const char *list, const char *kw)
-{
- char tmp[MBOX_FLAGS_MAX];
- char *tok, *save;
-
- strlcpy(tmp, list, sizeof(tmp));
- for (tok = strtok_r(tmp, ",", &save); tok != NULL;
- tok = strtok_r(NULL, ",", &save)) {
- if (strcmp(tok, kw) == 0)
- return (1);
- }
- return (0);
-}
-
-/*
- * Computes *out, a comma-separated keyword list (matching the index's own
- * on-disk delimiter -- see imapd.h's imsg_mbox_store comment), as the
- * result of applying mode/new_kws to a message's current old_kws. RFC 9051
- * SS6.4.6: "FLAGS <flag list> Replace the flags for the message with the
- * argument" -- keywords are flags too (SS2.3.2), so an unqualified FLAGS
- * STORE really does replace the entire keyword set, not just the five
- * system flags; that's why MBOX_STORE_SET ignores old_kws entirely below,
- * unlike ADD/REMOVE. Deduplicates in every mode -- a client naming a
- * keyword twice, or one already present, shouldn't produce a doubled
- * index entry.
- */
-static void
-merge_keywords(int mode, const char *old_kws, const char *new_kws,
- char *out, size_t outsize)
-{
- char tmp[MBOX_FLAGS_MAX];
- char *tok, *save;
- int first = 1;
-
- out[0] = '\0';
-
- if (mode == MBOX_STORE_REMOVE) {
- strlcpy(tmp, old_kws, sizeof(tmp));
- for (tok = strtok_r(tmp, ",", &save); tok != NULL;
- tok = strtok_r(NULL, ",", &save)) {
- if (kw_list_contains(new_kws, tok))
- continue;
- if (!first)
- strlcat(out, ",", outsize);
- strlcat(out, tok, outsize);
- first = 0;
- }
- return;
- }
-
- if (mode == MBOX_STORE_ADD) {
- strlcpy(tmp, old_kws, sizeof(tmp));
- for (tok = strtok_r(tmp, ",", &save); tok != NULL;
- tok = strtok_r(NULL, ",", &save)) {
- if (!first)
- strlcat(out, ",", outsize);
- strlcat(out, tok, outsize);
- first = 0;
- }
- }
-
- /* SET starts from nothing (old_kws is discarded); ADD continues
- * from the copy of old_kws just built above. Either way, append
- * anything in new_kws not already present. */
- strlcpy(tmp, new_kws, sizeof(tmp));
- for (tok = strtok_r(tmp, ",", &save); tok != NULL;
- tok = strtok_r(NULL, ",", &save)) {
- if (kw_list_contains(out, tok))
- continue;
- if (!first)
- strlcat(out, ",", outsize);
- strlcat(out, tok, outsize);
- first = 0;
- }
-}
-
-/*
- * Per-message context handed to search_eval()/search_eval_leaf() during
- * handle_mbox_search()'s scan below -- one message's worth of the fields
- * any in-scope search key (SEARCH_OP_*, imapd.h) might need to test.
- */
-struct search_msg_ctx {
- uint32_t seqno;
- uint32_t uid;
- uint32_t sysflags;
- const char *keywords; /* comma-separated, same convention as
- * everywhere else in this file */
- int64_t internaldate;
- uint64_t size; /* F13 fix: 64-bit for SEARCH LARGER/SMALLER */
- uint64_t modseq; /* RFC 7162 SS3.1.5 MODSEQ search key */
-};
-
-/*
- * One postfix node's worth of evaluation against a single message.
- * SEARCH_OP_AND/OR/NOT never reach here -- search_eval()'s stack machine
- * below handles those three directly, since they combine *other* nodes'
- * results rather than testing the message themselves.
- */
-static int
-search_eval_leaf(const struct search_node *n, const struct search_msg_ctx *m)
-{
- switch (n->op) {
- case SEARCH_OP_ALL:
- return (1);
- case SEARCH_OP_ANSWERED:
- return ((m->sysflags & MBOX_FLAG_ANSWERED) != 0);
- case SEARCH_OP_UNANSWERED:
- return ((m->sysflags & MBOX_FLAG_ANSWERED) == 0);
- case SEARCH_OP_DELETED:
- return ((m->sysflags & MBOX_FLAG_DELETED) != 0);
- case SEARCH_OP_UNDELETED:
- return ((m->sysflags & MBOX_FLAG_DELETED) == 0);
- case SEARCH_OP_DRAFT:
- return ((m->sysflags & MBOX_FLAG_DRAFT) != 0);
- case SEARCH_OP_UNDRAFT:
- return ((m->sysflags & MBOX_FLAG_DRAFT) == 0);
- case SEARCH_OP_FLAGGED:
- return ((m->sysflags & MBOX_FLAG_FLAGGED) != 0);
- case SEARCH_OP_UNFLAGGED:
- return ((m->sysflags & MBOX_FLAG_FLAGGED) == 0);
- case SEARCH_OP_SEEN:
- return ((m->sysflags & MBOX_FLAG_SEEN) != 0);
- case SEARCH_OP_UNSEEN:
- return ((m->sysflags & MBOX_FLAG_SEEN) == 0);
- case SEARCH_OP_KEYWORD:
- return (kw_list_contains(m->keywords, n->keyword));
- case SEARCH_OP_UNKEYWORD:
- return (!kw_list_contains(m->keywords, n->keyword));
- case SEARCH_OP_BEFORE:
- return (m->internaldate < n->num);
- case SEARCH_OP_ON:
- return (m->internaldate >= n->num &&
- m->internaldate < n->num + 86400);
- case SEARCH_OP_SINCE:
- return (m->internaldate >= n->num);
- case SEARCH_OP_LARGER:
- return ((int64_t)m->size > n->num);
- case SEARCH_OP_SMALLER:
- return ((int64_t)m->size < n->num);
- case SEARCH_OP_SEQSET:
- return (m->seqno >= n->seq_lo && m->seqno <= n->seq_hi);
- case SEARCH_OP_UIDSET:
- return (m->uid >= n->seq_lo && m->uid <= n->seq_hi);
- case SEARCH_OP_MODSEQ:
- return (m->modseq >= (uint64_t)n->num);
- default:
- return (0);
- }
-}
-
-/*
- * Evaluates nodes[0..nnodes) -- a postfix (reverse Polish) boolean
- * expression compiled by listener.c's parse_search_key()/parse_search_
- * key_list() -- against one message. Bounded by SEARCH_PROGRAM_MAX_NODES
- * (imapd.h): listener.c never emits a program longer than that, and
- * never emits a malformed one (AND/OR/NOT with too few operands already
- * on the stack) -- the bounds checks below are defensive against a
- * build-time struct-layout skew between listener and store, the same
- * posture store_dispatch()'s IMSG_MBOX_SEARCH case already takes on a
- * raw length mismatch, not something a client can trigger through normal
- * protocol use.
- */
-static int
-search_eval(const struct search_node *nodes, uint32_t nnodes,
- const struct search_msg_ctx *m)
-{
- int stack[SEARCH_PROGRAM_MAX_NODES];
- uint32_t sp = 0, i;
-
- for (i = 0; i < nnodes; i++) {
- const struct search_node *n = &nodes[i];
-
- switch (n->op) {
- case SEARCH_OP_AND:
- if (sp < 2)
- return (0);
- sp--;
- stack[sp - 1] = stack[sp - 1] && stack[sp];
- break;
- case SEARCH_OP_OR:
- if (sp < 2)
- return (0);
- sp--;
- stack[sp - 1] = stack[sp - 1] || stack[sp];
- break;
- case SEARCH_OP_NOT:
- if (sp < 1)
- return (0);
- stack[sp - 1] = !stack[sp - 1];
- break;
- default:
- if (sp >= SEARCH_PROGRAM_MAX_NODES)
- return (0);
- stack[sp++] = search_eval_leaf(n, m);
- break;
- }
- }
-
- return (sp == 1 ? stack[0] : 0);
-}
-
-/*
- * IMSG_MBOX_SEARCH handling. Same index-loading/locking shape as handle_
- * mbox_fetch() (LOCK_SH -- SEARCH only reads), but iterates every message
- * in the mailbox (a search key can be anything, so there's no shortcut
- * range the way FETCH's own sequence-set bounds the scan) and, for each,
- * evaluates the compiled postfix program via search_eval(). Matches are
- * streamed as IMSG_MBOX_SEARCH_MATCH in ascending sequence order (same
- * order handle_mbox_fetch() already streams in, for the same "index
- * order == sequence-number order, v1 has no reordering operation"
- * reason), then one terminal IMSG_MBOX_RESULT.
- *
- * SEQSET/UIDSET nodes' "*" is resolved once, up front, against this
- * scan's own idx.nlines (sequence numbers) or highest in-use UID (UID
- * ranges) -- not per message, since neither changes mid-scan (LOCK_SH is
- * held only long enough to read the index into memory, same as FETCH;
- * nothing else in this process mutates it afterward). "Largest UID in
- * use" is taken from the last index line (index_append() always appends
- * in increasing-UID order, and v1 has no operation that reorders or
- * renumbers existing entries), not from idx.uidnext - 1, so this stays
- * correct even if a future pass ever introduces UID gaps.
- *
- * A message that's indexed but missing on disk is skipped, same
- * tolerance handle_mbox_fetch()/handle_mbox_store()/handle_mbox_
- * expunge() already document -- unlike those, SEARCH always needs the
- * on-disk file (locate_message_file()) for both its flag-suffix letters
- * and its size, since almost any real query touches at least one of
- * ANSWERED/DELETED/DRAFT/FLAGGED/SEEN/KEYWORD/LARGER/SMALLER; there's no
- * cheaper partial path worth special-casing here the way FETCH's attrs
- * bitmask lets it skip the stat(2) entirely for a FLAGS-less request.
- */
-static void
-handle_mbox_search(struct imsg_mbox_search *req, struct search_node *nodes,
- uint32_t nnodes, struct imsgev *iev)
-{
- struct mbox_index idx;
- struct imsg_mbox_result result;
- int fd;
- int ok = 1;
- uint32_t sent = 0;
- uint32_t max_uid = 0;
- uint32_t i;
-
- (void)req; /* nnodes (passed separately, already used by the
- * caller to size the node array) is currently its
- * only field -- see imapd.h's imsg_mbox_search
- * comment */
-
- memset(&idx, 0, sizeof(idx));
-
- if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
- log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
- ok = 0;
- goto done;
- }
- if (flock(fd, LOCK_SH) == -1) {
- log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
- close(fd);
- ok = 0;
- goto done;
- }
- if (index_load(fd, &idx) == -1) {
- flock(fd, LOCK_UN);
- close(fd);
- ok = 0;
- goto done;
- }
- flock(fd, LOCK_UN);
- close(fd);
-
- max_uid = index_max_uid(&idx); /* factored out this pass -- UID
- * FETCH/UID STORE/UID EXPUNGE's own
- * "*" resolution now shares this same
- * helper, see its comment */
-
- for (i = 0; i < nnodes; i++) {
- struct search_node *n = &nodes[i];
-
- if (n->op != SEARCH_OP_SEQSET && n->op != SEARCH_OP_UIDSET)
- continue;
-
- if (n->lo_is_star)
- n->seq_lo = (n->op == SEARCH_OP_SEQSET) ?
- (uint32_t)idx.nlines : max_uid;
- if (n->hi_is_star)
- n->seq_hi = (n->op == SEARCH_OP_SEQSET) ?
- (uint32_t)idx.nlines : max_uid;
- }
-
- for (i = 1; i <= (uint32_t)idx.nlines; i++) {
- struct search_msg_ctx m;
- struct index_rec rec;
- const char *letters;
- off_t size = 0;
- char suffix[64];
-
- if (index_parse_line(idx.lines[i - 1], &rec) == -1)
- continue;
-
- memset(&m, 0, sizeof(m));
- m.seqno = i;
- m.uid = rec.uid;
- m.modseq = rec.modseq;
-
- if (locate_message_file(rec.basename, &size, suffix,
- sizeof(suffix)) == -1) {
- log_warnx("session %u: message %s (uid %u) indexed "
- "but missing on disk -- skipped", session_id,
- rec.basename, m.uid);
- continue;
- }
-
- letters = strstr(suffix, "2,");
- letters = (letters != NULL) ? letters + 2 : "";
- m.sysflags = letters_to_sysflags(letters);
- m.keywords = rec.keywords;
- m.internaldate = parse_maildir_timestamp(rec.basename);
- m.size = (uint64_t)size; /* F13 fix: was (uint32_t) */
-
- if (search_eval(nodes, nnodes, &m)) {
- struct imsg_mbox_search_match match;
-
- memset(&match, 0, sizeof(match));
- match.seqno = i;
- match.uid = m.uid;
- match.modseq = m.modseq;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_SEARCH_MATCH,
- 0, 0, -1, &match, sizeof(match)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_SEARCH_MATCH", session_id);
- else
- sent++;
- }
- }
-
-done:
- index_free(&idx);
-
- memset(&result, 0, sizeof(result));
- result.ok = ok;
- result.count = sent;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
- sizeof(result)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * IMSG_MBOX_STORE handling -- see imapd.h's imsg_mbox_store comment for
- * the wire shape and cmd_store_cmd() in listener.c for the RFC 9051
- * SS6.4.6 parsing this responds to. Uses LOCK_EX (like handle_mbox_
- * select(), unlike handle_mbox_fetch()'s LOCK_SH): STORE mutates both the
- * index (keywords) and, for any message whose system flags actually
- * change, the maildir filename itself, so it needs the same mutual-
- * exclusion-across-processes guarantee SELECT's read-modify-write cycle
- * already established, for the same fork-per-session (not fork-per-user)
- * reason.
- *
- * Sends one IMSG_MBOX_FETCH_META per modified message (skipped if
- * req->silent) and exactly one terminal IMSG_MBOX_RESULT, matching
- * handle_mbox_fetch()'s own reply shape -- see imapd.h's comment on
- * why STORE reuses FETCH's reply types rather than inventing new ones.
- *
- * RFC 7162 additions this pass:
- *
- * - req->has_unchangedsince (UNCHANGEDSINCE store-modifier, SS3.1.3): a
- * message whose current mod-sequence exceeds req->unchangedsince fails
- * the conditional test -- the requested flag operation is skipped for
- * it entirely, and its seqno/uid are reported via one IMSG_MBOX_STORE_
- * MODIFIED (listener.c folds these into the tagged response's MODIFIED
- * response code). This overrides req->silent for messages that *pass*
- * the test: SS3.1.3 "An untagged FETCH response MUST be sent, even if
- * the .SILENT suffix is specified, and the response MUST include the
- * MODSEQ message data item" -- see send_fetch below.
- *
- * - One shared mod-sequence bump per STORE command, applied to every
- * message this command actually changes -- not a fresh bump per
- * message. Sourced from RFC 7162 SS3.1.3's own worked examples (9 and
- * 10 especially): a range STORE that changes many messages shows the
- * *identical* MODSEQ value on every changed message's FETCH echo, and
- * SS3.1's guarantee is phrased per *command* ("each STORE command...
- * will get a different mod-sequence value"), not per message. new_
- * modseq below is computed once, up front, and only actually committed
- * to idx.highestmodseq (via the `changed` flag) if at least one message
- * ends up using it -- see also SS3.1.11/SS3.1.12: adding an
- * already-set flag (or removing an already-unset one) SHOULD NOT bump
- * the mod-sequence at all, so a message whose resulting flags/keywords
- * come out identical to what it already had keeps its *old* mod-
- * sequence rather than taking new_modseq.
- *
- * RFC 9051 SS6.4.9 addition (UID command): req->by_uid switches lo/hi (and
- * "*") to UID-space resolution -- see handle_mbox_fetch()'s identical
- * mechanism and comment. meta.uid is already unconditionally populated in
- * the FETCH echo regardless of by_uid; only listener.c's decision to print
- * it changes.
- */
-static void
-handle_mbox_store(struct imsg_mbox_store *req, struct imsgev *iev)
-{
- struct mbox_index idx;
- struct imsg_mbox_result result;
- int fd = -1;
- uint32_t lo, hi, i, sent = 0;
- uint64_t new_modseq;
- int ok = 1, changed = 0, locked = 0;
-
- memset(&idx, 0, sizeof(idx));
-
- if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
- log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
- ok = 0;
- goto done;
- }
- if (flock(fd, LOCK_EX) == -1) {
- log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
- ok = 0;
- goto done;
- }
- locked = 1;
-
- if (index_load(fd, &idx) == -1) {
- ok = 0;
- goto done;
- }
-
- new_modseq = idx.highestmodseq + 1;
-
- /* RFC 9051 SS6.4.9: UID STORE's sequence-set is UID-space -- same
- * lo/hi resolution switch as handle_mbox_fetch(), see that
- * function's comment for the full reasoning. */
- if (req->by_uid) {
- uint32_t max_uid = index_max_uid(&idx);
-
- lo = req->lo_is_star ? max_uid : req->seq_lo;
- hi = req->hi_is_star ? max_uid : req->seq_hi;
- } else {
- lo = req->lo_is_star ? (uint32_t)idx.nlines : req->seq_lo;
- hi = req->hi_is_star ? (uint32_t)idx.nlines : req->seq_hi;
- }
- if (lo < 1)
- lo = 1;
- if (!req->by_uid && hi > (uint32_t)idx.nlines)
- hi = (uint32_t)idx.nlines;
-
- for (i = 1; i <= (uint32_t)idx.nlines; i++) {
- struct imsg_mbox_fetch_meta meta;
- struct index_rec rec;
- const char *lp;
- uint32_t old_sysflags, new_sysflags;
- char suffix[64], newletters[8];
- char newkeywords[MBOX_FLAGS_MAX];
- char newline[STORE_INDEX_LINE_MAX];
- char oldpath[600], newpath[600];
- off_t size;
- uint64_t this_modseq;
- 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() -- see that function's comment. */
- if (req->by_uid) {
- if (rec.uid < lo)
- continue;
- if (rec.uid > hi)
- break;
- } else {
- if (i < lo)
- continue;
- if (i > hi)
- break;
- }
-
- if (req->has_unchangedsince &&
- rec.modseq > req->unchangedsince) {
- struct imsg_mbox_store_modified mod;
-
- memset(&mod, 0, sizeof(mod));
- mod.seqno = i;
- mod.uid = rec.uid;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_STORE_MODIFIED,
- 0, 0, -1, &mod, sizeof(mod)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_STORE_MODIFIED", session_id);
- continue;
- }
-
- if (locate_message_file(rec.basename, &size, suffix,
- sizeof(suffix)) == -1) {
- log_warnx("session %u: message %s (uid %u) indexed "
- "but missing on disk -- skipped", session_id,
- rec.basename, rec.uid);
- continue;
- }
- in_new = (suffix[0] == '\0');
-
- lp = strstr(suffix, "2,");
- old_sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
-
- switch (req->mode) {
- case MBOX_STORE_SET:
- new_sysflags = req->sysflags;
- break;
- case MBOX_STORE_ADD:
- new_sysflags = old_sysflags | req->sysflags;
- break;
- case MBOX_STORE_REMOVE:
- default:
- new_sysflags = old_sysflags & ~req->sysflags;
- break;
- }
- merge_keywords(req->mode, rec.keywords, req->keywords,
- newkeywords, sizeof(newkeywords));
- sysflags_to_letters(new_sysflags, newletters,
- sizeof(newletters));
-
- real_change = (new_sysflags != old_sysflags) ||
- (strcmp(newkeywords, rec.keywords) != 0);
- this_modseq = real_change ? new_modseq : rec.modseq;
-
- /*
- * Once a message is targeted by STORE at all, it moves (if
- * not already there) from new/ to cur/ with an explicit
- * ":2,<letters>" suffix, even if <letters> ends up empty --
- * maildir's new/ specifically means "not yet seen by any
- * client" (Courier maildir(5)), and a STORE is unambiguous
- * evidence the client has now processed the message, the
- * same "no protocol reason left to leave it in new/ once
- * touched" reasoning handle_mbox_select()'s header comment
- * already applies to \Recent. Never moves cur/ -> new/ --
- * no maildir tool does; new/ is one-directional. Skipped
- * entirely if the message is already in cur/ with exactly
- * this letter set, to avoid a no-op rename on every STORE
- * that only touches keywords.
- */
- if (in_new || strcmp(lp != NULL ? lp + 2 : "",
- newletters) != 0) {
- if (snprintf(oldpath, sizeof(oldpath), "%s/%s%s",
- in_new ? "new" : "cur", rec.basename, suffix) >=
- (int)sizeof(oldpath) ||
- snprintf(newpath, sizeof(newpath), "cur/%s:2,%s",
- rec.basename, newletters) >= (int)sizeof(newpath)) {
- log_warnx("session %u: path too long for %s",
- session_id, rec.basename);
- continue;
- }
- if (rename(oldpath, newpath) == -1) {
- log_warn("session %u: rename %s -> %s",
- session_id, oldpath, newpath);
- continue;
- }
- }
-
- len = snprintf(newline, sizeof(newline), "%u:%s:%s:%llu",
- rec.uid, rec.basename, newkeywords,
- (unsigned long long)this_modseq);
- if (len < 0 || (size_t)len >= sizeof(newline)) {
- log_warnx("session %u: new index line too long for "
- "%s", session_id, rec.basename);
- continue;
- }
- free(idx.lines[i - 1]);
- if ((idx.lines[i - 1] = strdup(newline)) == NULL) {
- log_warn("session %u: strdup index line", session_id);
- ok = 0;
- goto done;
- }
- if (real_change)
- changed = 1;
-
- /* SS3.1.3: UNCHANGEDSINCE forces the FETCH echo (with
- * MODSEQ) even under .SILENT, for every message that passed
- * the conditional test -- independent of req->silent. */
- send_fetch = !req->silent || req->has_unchangedsince;
-
- if (send_fetch) {
- char newsuffix[16];
-
- memset(&meta, 0, sizeof(meta));
- meta.seqno = i;
- meta.uid = rec.uid;
- meta.modseq = this_modseq;
- snprintf(newsuffix, sizeof(newsuffix), "2,%s",
- newletters);
- build_flags_string(newsuffix, newkeywords, meta.flags,
- sizeof(meta.flags));
-
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_FETCH_META, 0,
- 0, -1, &meta, sizeof(meta)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_FETCH_META", session_id);
- else
- sent++;
- }
- }
-
- if (changed) {
- idx.highestmodseq = new_modseq;
- if (index_save(&idx) == -1)
- ok = 0;
- }
-
-done:
- if (locked)
- flock(fd, LOCK_UN);
- if (fd != -1)
- close(fd);
-
- memset(&result, 0, sizeof(result));
- result.ok = ok;
- result.count = sent;
- result.highestmodseq = idx.highestmodseq;
- index_free(&idx);
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
- sizeof(result)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * IMSG_MBOX_EXPUNGE handling -- see imapd.h's imsg_mbox_expunge comment
- * for the wire shape (also used, with silent=1, by CLOSE). Uses LOCK_EX,
- * same reason as handle_mbox_store(): this mutates the index structurally
- * (removes lines, not just their content) and unlinks message files.
- *
- * Compacts idx.lines[] in place with a classic remove_if two-pointer scan
- * (in reads every line, out is where the next *kept* line goes) rather
- * than building a second array -- removed lines are simply never copied
- * to out and their storage is free()d immediately; kept lines are moved
- * down by however many removals preceded them. This single pass is also
- * what produces RFC 9051 SS7.5.1's "immediately decremented" sequence
- * numbers for free: at the moment a message at idx.lines[in] is found to
- * be \Deleted, out already equals the count of kept messages before it,
- * which -- because every earlier removal already shifted everything after
- * it down by one -- is exactly that message's current sequence number
- * minus one. See imapd.h's imsg_mbox_expunge comment for the worked
- * check against SS6.4.3's own example (3, 3, 5, 8).
- *
- * A message that can't be classified for some reason (corrupt index
- * line, missing on-disk file, path too long, or a failed unlink(2)) is
- * conservatively kept in the index rather than dropped -- losing track of
- * a message store.c couldn't actually verify as removed would be worse
- * than a missed expunge of it.
- *
- * UIDVALIDITY and UIDNEXT are both left untouched: removing messages must
- * never cause a UID to be reused (RFC 9051 SS2.3.1.1's whole point), and
- * since this function only removes index entries -- never renumbers or
- * reassigns the UIDs of messages that remain -- there's no reason either
- * value would need to change.
- *
- * RFC 7162 additions this pass: exp.uid is now populated too (see imapd.h's
- * imsg_mbox_expunged comment -- needed once QRESYNC is enabled, since
- * listener.c then reports VANISHED, which is UID-based, instead of EXPUNGE).
- * One shared mod-sequence bump for the whole EXPUNGE/UID EXPUNGE/CLOSE
- * command, applied (conceptually -- this implementation doesn't persist a
- * per-expunge-event value at all, see imsg_mbox_select_vanished's SS5.1
- * minimal-state comment) to every message removed by it, same "one bump
- * per command, not per message" reasoning as handle_mbox_store() -- RFC
- * 7162 SS3.2.7's own example shows a single HIGHESTMODSEQ value covering
- * an entire multi-message EXPUNGE. Applies regardless of req->silent
- * (CLOSE): SS3.2.8 requires the mod-sequence bump for CLOSE too, it just
- * additionally forbids CLOSE's tagged OK from *reporting* the new value
- * (a listener.c-side choice, not something this function needs to know
- * about).
- *
- * RFC 9051 SS6.4.9 addition (UID command): req->by_uid, when set, restricts
- * removal to \Deleted messages whose UID also falls in [seq_lo, seq_hi]
- * (resolved once, up front, via index_max_uid() for "*" -- same mechanism
- * FETCH/STORE now share) -- "If a message... has a UID that is not
- * included in the specified sequence set, it is not affected." A message
- * excluded this way is kept in the index exactly like the "not \Deleted at
- * all" case, so it still gets its own turn at a later EXPUNGE/UID EXPUNGE
- * that does include it.
- */
-static void
-handle_mbox_expunge(struct imsg_mbox_expunge *req, struct imsgev *iev)
-{
- struct mbox_index idx;
- struct imsg_mbox_result result;
- int fd = -1;
- size_t in, out;
- uint32_t sent = 0;
- uint32_t uid_lo, uid_hi;
- int ok = 1, changed = 0, locked = 0;
-
- memset(&idx, 0, sizeof(idx));
-
- if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
- log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
- ok = 0;
- goto done;
- }
- if (flock(fd, LOCK_EX) == -1) {
- log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
- ok = 0;
- goto done;
- }
- locked = 1;
-
- if (index_load(fd, &idx) == -1) {
- ok = 0;
- goto done;
- }
-
- /*
- * RFC 9051 SS6.4.9's second UID command form (UID EXPUNGE): resolve
- * the required UID range once, up front, against the mailbox as
- * loaded -- same index_max_uid() "*" resolution as UID FETCH/UID
- * STORE. Meaningless (and unused below) when !req->by_uid, since a
- * plain EXPUNGE takes no arguments at all.
- */
- uid_lo = uid_hi = 0;
- if (req->by_uid) {
- uint32_t max_uid = index_max_uid(&idx);
-
- uid_lo = req->lo_is_star ? max_uid : req->seq_lo;
- uid_hi = req->hi_is_star ? max_uid : req->seq_hi;
- if (uid_lo < 1)
- uid_lo = 1;
- }
-
- out = 0;
- for (in = 0; in < idx.nlines; in++) {
- struct index_rec rec;
- const char *lp;
- uint32_t sysflags;
- char suffix[64], path[600];
- off_t size;
-
- if (index_parse_line(idx.lines[in], &rec) == -1) {
- idx.lines[out++] = idx.lines[in];
- continue;
- }
-
- if (locate_message_file(rec.basename, &size, suffix,
- sizeof(suffix)) == -1) {
- log_warnx("session %u: message %s indexed but missing "
- "on disk -- kept in index, not counted as "
- "expunged", session_id, rec.basename);
- idx.lines[out++] = idx.lines[in];
- continue;
- }
-
- lp = strstr(suffix, "2,");
- sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
- if (!(sysflags & MBOX_FLAG_DELETED)) {
- idx.lines[out++] = idx.lines[in];
- continue;
- }
-
- /*
- * RFC 9051 SS6.4.9: "If a message either does not have the
- * \Deleted flag set or has a UID that is not included in the
- * specified sequence set, it is not affected" -- a \Deleted
- * message outside the UID EXPUNGE range is kept, exactly
- * like the "not \Deleted at all" case just above.
- */
- if (req->by_uid && (rec.uid < uid_lo || rec.uid > uid_hi)) {
- idx.lines[out++] = idx.lines[in];
- continue;
- }
-
- if (snprintf(path, sizeof(path), "%s/%s%s",
- suffix[0] == '\0' ? "new" : "cur", rec.basename, suffix) >=
- (int)sizeof(path)) {
- log_warnx("session %u: path too long for %s -- kept "
- "in index", session_id, rec.basename);
- idx.lines[out++] = idx.lines[in];
- continue;
- }
- if (unlink(path) == -1 && errno != ENOENT) {
- log_warn("session %u: unlink %s", session_id, path);
- idx.lines[out++] = idx.lines[in];
- continue;
- }
-
- if (!req->silent) {
- struct imsg_mbox_expunged exp;
-
- memset(&exp, 0, sizeof(exp));
- exp.seqno = (uint32_t)(out + 1);
- exp.uid = rec.uid;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0,
- -1, &exp, sizeof(exp)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_EXPUNGED", session_id);
- else
- sent++;
- }
-
- free(idx.lines[in]);
- changed = 1;
- }
- idx.nlines = out;
-
- if (changed) {
- idx.highestmodseq++;
- if (index_save(&idx) == -1)
- ok = 0;
- }
-
-done:
- if (locked)
- flock(fd, LOCK_UN);
- if (fd != -1)
- close(fd);
-
- memset(&result, 0, sizeof(result));
- result.ok = ok;
- result.count = sent;
- result.highestmodseq = idx.highestmodseq;
- index_free(&idx);
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
- sizeof(result)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * Ensures tmp/, new/, and cur/ all exist -- APPEND is the first thing in
- * this codebase that ever needs to *deliver* a message itself (see
- * handle_mbox_append()'s header comment): every other IMSG_MBOX_* handler
- * only ever reads directories assumed to already exist, populated by
- * something external (smtpd's native maildir delivery action, per
- * openimap-storage-backend.md). A mailbox that has only ever received
- * mail that way could plausibly have new/ and cur/ but genuinely lack
- * tmp/ (nothing external needs it -- smtpd's own maildir action manages
- * its own tmp/ usage internally and doesn't leave evidence of it here).
- * mkdir(2) with EEXIST tolerated is the standard idempotent "ensure
- * exists" idiom; 0700 matches the store child's own already-narrow
- * privilege scope (privilege-dropped to the session's uid/gid, chroot'd,
- * unveiled to just this maildir subdirectory).
- *
- * Generalized this pass (flat multi-mailbox support) to take a path
- * prefix rather than always assuming cwd is the mailbox root: "" for
- * INBOX (every existing caller, unchanged behavior) or "name/" for a
- * named mailbox, e.g. CREATE or an APPEND targeting a mailbox other than
- * the one currently selected. Every existing call site already passes
- * "" explicitly, so this is a signature change, not a behavior change,
- * for anything that isn't new this pass.
- */
-static int
-ensure_maildir_dirs(const char *prefix)
-{
- static const char *dirs[] = { "tmp", "new", "cur" };
- char path[MBOX_NAME_MAX + 8];
- size_t i;
-
- for (i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) {
- if (snprintf(path, sizeof(path), "%s%s", prefix, dirs[i]) >=
- (int)sizeof(path)) {
- log_warnx("session %u: mailbox path too long",
- session_id);
- return (-1);
- }
- if (mkdir(path, 0700) == -1 && errno != EEXIST) {
- log_warn("session %u: mkdir %s", session_id, path);
- return (-1);
- }
- }
- return (0);
-}
-
-/*
- * IMSG_MBOX_CREATE (RFC 9051 SS6.3.4). See docs/openimap-storage-backend.md
- * item 10 for the full design: CREATE needs no index-initialization code
- * of its own at all -- index_load() already default-initializes a fresh
- * UIDVALIDITY/UIDNEXT when handed an empty index fd, so the first command
- * that actually opens this mailbox (SELECT, APPEND, ...) does that work,
- * exactly as it always has for INBOX. This handler is therefore just:
- * mkdir the mailbox directory itself (EEXIST here means "already exists",
- * SS6.3.4's required refusal, unlike ensure_maildir_dirs()'s idempotent
- * EEXIST-tolerant use elsewhere), then the same tmp/new/cur scaffolding
- * APPEND already needs.
- */
-static void
-handle_mbox_create(struct imsg_mbox_create *req, struct imsgev *iev)
-{
- struct imsg_mbox_result result;
- char prefix[MBOX_NAME_MAX + 1];
- char saved[MBOX_NAME_MAX];
- int switched = 0;
-
- memset(&result, 0, sizeof(result));
-
- if (mailbox_name_is_inbox(req->mailbox) ||
- !mailbox_name_valid(req->mailbox)) {
- log_debug("session %u: CREATE %s: invalid name", session_id,
- req->mailbox);
- result.ok = 0;
- goto send;
- }
-
- /*
- * mkdir(2)/ensure_maildir_dirs() below take req->mailbox as a bare
- * path relative to cwd -- correct only when cwd is this session's
- * maildir root, which is true by default but no longer guaranteed:
- * if this session currently has some *other* named mailbox SELECTed
- * (select_mailbox_dir() having already chdir'd into it), cwd sits
- * one level below root. Temporarily visiting root first -- the same
- * select_mailbox_dir()-based "visit and restore" pattern handle_
- * mbox_status() already established for this identical problem --
- * makes every relative path below correct regardless of what's
- * currently selected. Found by real-hardware testing on premio:
- * CREATE from a session with nothing else selected happened to work
- * (cwd was already root by luck), but a RENAME issued against the
- * mailbox that was itself currently SELECTed failed with a spurious
- * "no such mailbox" -- same root cause; see handle_mbox_rename()'s
- * identical fix, and handle_mbox_delete()'s/handle_mbox_list()'s.
- */
- strlcpy(saved, current_mailbox_dir, sizeof(saved));
- if (select_mailbox_dir("") == -1) {
- log_warnx("session %u: CREATE %s: couldn't reach maildir "
- "root", session_id, req->mailbox);
- result.ok = 0;
- goto send;
- }
- switched = 1;
-
- if (mkdir(req->mailbox, 0700) == -1) {
- if (errno != EEXIST)
- log_warn("session %u: CREATE: mkdir %s", session_id,
- req->mailbox);
- else
- log_debug("session %u: CREATE %s: already exists",
- session_id, req->mailbox);
- result.ok = 0;
- goto send;
- }
-
- if (snprintf(prefix, sizeof(prefix), "%s/", req->mailbox) >=
- (int)sizeof(prefix) || ensure_maildir_dirs(prefix) == -1) {
- /* Best-effort cleanup: a half-initialized mailbox (directory
- * exists, tmp/new/cur don't) would otherwise be stuck in a
- * state CREATE can never retry (mkdir would now see EEXIST)
- * but that can't actually be SELECTed usefully either. */
- rmdir(req->mailbox);
- result.ok = 0;
- goto send;
- }
-
- result.ok = 1;
-
-send:
- if (switched && select_mailbox_dir(saved) == -1)
- log_warnx("session %u: CREATE %s: couldn't restore "
- "previously selected mailbox %s", session_id,
- req->mailbox, saved);
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
- sizeof(result)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * Bounded removal of one flat mailbox's own tmp/new/cur contents plus its
- * index file -- deliberately NOT a general-purpose recursive delete.
- * Refuses (logs and stops, leaving whatever was already removed rather
- * than guessing further) if tmp/, new/, or cur/ contains anything that
- * isn't a regular file: this codebase only ever creates that exact
- * three-subdirectory-of-plain-files shape, so anything else there means
- * something unexpected is going on and blind recursion would be unsafe.
- * Does not remove the mailbox directory itself -- the caller (handle_
- * mbox_delete()) does that last, once this returns success.
- */
-static int
-remove_maildir_subtree(const char *prefix)
-{
- static const char *dirs[] = { "tmp", "new", "cur" };
- /*
- * F14 fix: buffer was MBOX_NAME_MAX + 8, too small to append
- * "imapd.index" (STORE_INDEX_NAME, 11 bytes) to a near-maximal
- * mailbox name. DELETE of such a mailbox failed with "index path
- * too long" *after* already removing tmp/new/cur -- a partial
- * delete that left the mailbox undeletable. + 32 covers the
- * longest suffix this function appends.
- */
- char path[MBOX_NAME_MAX + 32];
- size_t i;
-
- for (i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) {
- DIR *dp;
- struct dirent *de;
- int ok = 1;
-
- if (snprintf(path, sizeof(path), "%s%s", prefix, dirs[i]) >=
- (int)sizeof(path)) {
- log_warnx("session %u: DELETE: path too long",
- session_id);
- return (-1);
- }
- if ((dp = opendir(path)) == NULL) {
- if (errno == ENOENT)
- continue; /* tmp/ in particular may never
- * have been created -- see
- * ensure_maildir_dirs()'s own
- * comment */
- log_warn("session %u: DELETE: opendir %s",
- session_id, path);
- return (-1);
- }
- while ((de = readdir(dp)) != NULL) {
- char entpath[sizeof(path) + 300];
- struct stat st;
-
- if (strcmp(de->d_name, ".") == 0 ||
- strcmp(de->d_name, "..") == 0)
- continue;
- if (snprintf(entpath, sizeof(entpath), "%s/%s", path,
- de->d_name) >= (int)sizeof(entpath)) {
- log_warnx("session %u: DELETE: entry path "
- "too long", session_id);
- ok = 0;
- continue;
- }
- if (lstat(entpath, &st) == -1) {
- log_warn("session %u: DELETE: lstat %s",
- session_id, entpath);
- ok = 0;
- continue;
- }
- if (!S_ISREG(st.st_mode)) {
- log_warnx("session %u: DELETE: refusing -- "
- "%s is not a regular file", session_id,
- entpath);
- ok = 0;
- continue;
- }
- if (unlink(entpath) == -1) {
- log_warn("session %u: DELETE: unlink %s",
- session_id, entpath);
- ok = 0;
- }
- }
- closedir(dp);
- if (!ok)
- return (-1);
- if (rmdir(path) == -1 && errno != ENOENT) {
- log_warn("session %u: DELETE: rmdir %s", session_id,
- path);
- return (-1);
- }
- }
-
- if (snprintf(path, sizeof(path), "%s%s", prefix, STORE_INDEX_NAME) >=
- (int)sizeof(path)) {
- log_warnx("session %u: DELETE: index path too long",
- session_id);
- return (-1);
- }
- if (unlink(path) == -1 && errno != ENOENT) {
- log_warn("session %u: DELETE: unlink %s", session_id, path);
- return (-1);
- }
-
- return (0);
-}
-
-/*
- * IMSG_MBOX_DELETE (RFC 9051 SS6.3.5). v1 is flat (docs/openimap-storage-
- * backend.md item 10), so no mailbox can ever have children -- SS6.3.5's
- * "inferior hierarchical names"/HASCHILDREN carve-out never applies here;
- * deletion is unconditional once the name resolves to a real, non-INBOX
- * mailbox. The UID-preservation requirement ("value of the highest-used
- * unique identifier... MUST be preserved... unless the new incarnation
- * has a different unique identifier validity value") is satisfied for
- * free: removing the directory outright and letting a later CREATE of the
- * same name start from a brand-new time(NULL)-based UIDVALIDITY (see
- * handle_mbox_create()) makes the "different UIDVALIDITY" escape clause
- * trivially true.
- *
- * Known, accepted limitation, same category as handle_mbox_rename()'s own
- * documented one below: no special check exists for "the mailbox being
- * deleted is this session's own currently SELECTed mailbox" (RFC 9051
- * doesn't forbid DELETE of a selected mailbox outright, and real servers
- * differ on how they handle it). If that happens, the send: label's
- * restore-to-saved-selection select_mailbox_dir() call will itself fail
- * (the directory it's trying to chdir back into no longer exists), which
- * is logged but otherwise silently leaves this store child sitting at
- * root while its own current_mailbox_dir bookkeeping and listener.c's
- * s->state/s->selected_mailbox still believe the deleted mailbox is
- * selected -- until that session's next SELECT/EXAMINE/CLOSE/UNSELECT
- * naturally resolves the mismatch. Not solved here; flagged, not silent.
- */
-static void
-handle_mbox_delete(struct imsg_mbox_delete *req, struct imsgev *iev)
-{
- struct imsg_mbox_result result;
- struct stat st;
- char prefix[MBOX_NAME_MAX + 1];
- char saved[MBOX_NAME_MAX];
- int switched = 0;
-
- memset(&result, 0, sizeof(result));
-
- if (mailbox_name_is_inbox(req->mailbox) ||
- !mailbox_name_valid(req->mailbox)) {
- log_debug("session %u: DELETE %s: invalid name", session_id,
- req->mailbox);
- result.ok = 0;
- goto send;
- }
-
- /* Same "req->mailbox is a bare path relative to cwd, which is only
- * guaranteed to be root by default" fix as handle_mbox_create()'s
- * identical comment -- see that function for the real-hardware bug
- * this closes. */
- strlcpy(saved, current_mailbox_dir, sizeof(saved));
- if (select_mailbox_dir("") == -1) {
- log_warnx("session %u: DELETE %s: couldn't reach maildir "
- "root", session_id, req->mailbox);
- result.ok = 0;
- goto send;
- }
- switched = 1;
-
- if (stat(req->mailbox, &st) == -1 || !S_ISDIR(st.st_mode)) {
- log_debug("session %u: DELETE %s: no such mailbox",
- session_id, req->mailbox);
- result.ok = 0;
- goto send;
- }
-
- if (snprintf(prefix, sizeof(prefix), "%s/", req->mailbox) >=
- (int)sizeof(prefix)) {
- result.ok = 0;
- goto send;
- }
-
- if (remove_maildir_subtree(prefix) == -1) {
- result.ok = 0;
- goto send;
- }
- if (rmdir(req->mailbox) == -1 && errno != ENOENT) {
- log_warn("session %u: DELETE: rmdir %s", session_id,
- req->mailbox);
- result.ok = 0;
- goto send;
- }
-
- result.ok = 1;
-
-send:
- if (switched && select_mailbox_dir(saved) == -1)
- log_warnx("session %u: DELETE %s: couldn't restore "
- "previously selected mailbox %s", session_id,
- req->mailbox, saved);
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
- sizeof(result)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * IMSG_MBOX_RENAME (RFC 9051 SS6.3.6). Source and destination are always
- * direct siblings under the same already-unveiled, single-filesystem
- * maildir root (docs/openimap-storage-backend.md item 6's EXDEV note), so
- * this is one rename(2) call after existence checks.
- *
- * Refusing INBOX specifically as a RENAME *source* is a v1 judgment call,
- * not a hard spec requirement -- SS6.3.6 permits renaming INBOX (with
- * special content-move semantics: INBOX ends up empty, its messages move
- * to the new name) but also explicitly sanctions refusal: "some servers
- * disallow renaming INBOX... clients need to be able to handle the
- * failure." INBOX's directory can't simply be renamed away (store.c's
- * unveil(2)/chroot(2) structure assumes it's always the session's own
- * root), and actually moving message content instead is real, separate
- * complexity not taken on here. No special-case is needed to refuse a
- * RENAME *to* "INBOX" -- it always exists, so that's already caught by
- * the ordinary destination-already-exists check below.
- *
- * Known, accepted limitation (not solved here), revised now that this
- * handler visits root rather than trusting cwd (see the real-hardware bug
- * note below): if some *other* session belonging to the same user
- * currently has the mailbox being renamed SELECTed, that other store
- * child's own cwd remains validly referencing the same directory by inode
- * after the rename (POSIX cwd tracking survives a rename of the directory
- * it points into), so its own FETCH/STORE/EXPUNGE keep working -- but its
- * current_mailbox_dir string cache goes stale (still holds the old name)
- * until that session's next SELECT. If instead it's *this* session's own
- * currently-selected mailbox being renamed, the send: label's restore-to-
- * saved-selection call will itself fail the same way handle_mbox_delete()'s
- * identical restore can (the old name no longer resolves), silently
- * leaving this store child's cwd at root while listener.c's own
- * s->state/s->selected_mailbox still believe the old name is selected --
- * until that session's next SELECT/EXAMINE/CLOSE/UNSELECT naturally
- * resolves it. Solving either properly would need the same kind of
- * cross-session coordination the deferred COPY/MOVE-to-other-mailbox work
- * (see the design doc) does, and is out of scope for this pass.
- *
- * Real-hardware bug found testing this on premio: req->oldname/req->newname
- * are bare paths relative to cwd, which stat(2)/rename(2) below assumed was
- * always this session's maildir root -- true by default, but not once a
- * *different* named mailbox is the one currently SELECTed (cwd sits one
- * level below root). Renaming "Drafts" while "Drafts" itself was the
- * currently selected mailbox failed with a spurious "no such mailbox"
- * (looking for maildir-root/Drafts/Drafts, which naturally doesn't exist).
- * Fixed the same way handle_mbox_create()/handle_mbox_delete() are: visit
- * root via select_mailbox_dir("") first, do the real work, restore
- * whatever was selected before on the way out.
- */
-static void
-handle_mbox_rename(struct imsg_mbox_rename *req, struct imsgev *iev)
-{
- struct imsg_mbox_result result;
- struct stat st;
- char saved[MBOX_NAME_MAX];
- int switched = 0;
-
- memset(&result, 0, sizeof(result));
-
- if (mailbox_name_is_inbox(req->oldname) ||
- !mailbox_name_valid(req->oldname) ||
- !mailbox_name_valid(req->newname)) {
- log_debug("session %u: RENAME %s -> %s: invalid name(s)",
- session_id, req->oldname, req->newname);
- result.ok = 0;
- goto send;
- }
-
- strlcpy(saved, current_mailbox_dir, sizeof(saved));
- if (select_mailbox_dir("") == -1) {
- log_warnx("session %u: RENAME %s -> %s: couldn't reach "
- "maildir root", session_id, req->oldname, req->newname);
- result.ok = 0;
- goto send;
- }
- switched = 1;
-
- if (stat(req->oldname, &st) == -1 || !S_ISDIR(st.st_mode)) {
- log_debug("session %u: RENAME %s: no such mailbox",
- session_id, req->oldname);
- result.ok = 0;
- goto send;
- }
-
- /*
- * SS6.3.6: "error to... rename to a mailbox name that already
- * exists." Checked explicitly rather than relying on rename(2)'s
- * own directory-replace-if-empty semantics, which would otherwise
- * let RENAME silently succeed against a stray empty directory that
- * was never really a mailbox at all.
- */
- if (stat(req->newname, &st) == 0 || errno != ENOENT) {
- log_debug("session %u: RENAME %s -> %s: destination exists",
- session_id, req->oldname, req->newname);
- result.ok = 0;
- goto send;
- }
-
- if (rename(req->oldname, req->newname) == -1) {
- log_warn("session %u: RENAME: rename %s -> %s", session_id,
- req->oldname, req->newname);
- result.ok = 0;
- goto send;
- }
-
- result.ok = 1;
-
-send:
- if (switched) {
- const char *restore = saved;
-
- /*
- * If this session's own currently-selected mailbox is the
- * one that just got renamed, follow it to its new name
- * rather than trying (and failing) to restore a name that
- * no longer exists -- keeps this store child's own cwd/
- * current_mailbox_dir internally consistent even though
- * listener.c's separate s->selected_mailbox bookkeeping
- * still needs its own fix for the same case (see cmd_
- * rename()/session_finish_mbox_op() in listener.c).
- */
- if (result.ok && strcmp(saved, req->oldname) == 0)
- restore = req->newname;
- if (select_mailbox_dir(restore) == -1)
- log_warnx("session %u: RENAME %s -> %s: couldn't "
- "restore previously selected mailbox (as %s)",
- session_id, req->oldname, req->newname, restore);
- }
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
- sizeof(result)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * IMSG_MBOX_LIST (RFC 9051 SS6.3.9). Streams one IMSG_MBOX_LIST_ITEM per
- * real, on-disk mailbox subdirectory found at the session's maildir root,
- * then a terminal IMSG_MBOX_RESULT (count = number streamed, ok = 0 only
- * on a real I/O error opening the root itself). INBOX is never included
- * here -- see the IMSG_MBOX_LIST_ITEM enum comment in imapd.h --
- * listener.c already handles it locally and unconditionally. "tmp"/"new"/
- * "cur" (INBOX's own maildir internals, living as siblings of any named
- * mailbox at this same level) and the index file are skipped, along with
- * anything that isn't a directory or fails mailbox_name_valid() (should
- * never happen for anything this codebase itself created, but a stray
- * unexpected entry shouldn't crash or misreport LIST -- it's just
- * silently omitted, the same "ignore what doesn't fit" posture SS6.3.9
- * itself takes for unaccepted patterns).
- *
- * opendir(".") is only correct when cwd is this session's maildir root --
- * true by default, but not once a named mailbox is the one currently
- * SELECTed (cwd sits one level below root). Real-hardware bug found
- * testing this on premio: with "Drafts" selected, LIST enumerated Drafts'
- * own tmp/new/cur instead of the maildir root's mailboxes, silently
- * making every named mailbox but the selected one invisible. Fixed the
- * same visit-root-then-restore way handle_mbox_create()/handle_mbox_
- * delete()/handle_mbox_rename() are.
- */
-static void
-handle_mbox_list(struct imsgev *iev)
-{
- struct imsg_mbox_result result;
- struct imsg_mbox_list_item item;
- DIR *dp;
- struct dirent *de;
- char saved[MBOX_NAME_MAX];
- int switched = 0;
-
- memset(&result, 0, sizeof(result));
-
- strlcpy(saved, current_mailbox_dir, sizeof(saved));
- if (select_mailbox_dir("") == -1) {
- log_warnx("session %u: LIST: couldn't reach maildir root",
- session_id);
- result.ok = 0;
- goto send;
- }
- switched = 1;
-
- if ((dp = opendir(".")) == NULL) {
- log_warn("session %u: LIST: opendir .", session_id);
- result.ok = 0;
- goto send;
- }
-
- while ((de = readdir(dp)) != NULL) {
- struct stat st;
-
- if (strcmp(de->d_name, ".") == 0 ||
- strcmp(de->d_name, "..") == 0)
- continue;
- if (strcmp(de->d_name, "tmp") == 0 ||
- strcmp(de->d_name, "new") == 0 ||
- strcmp(de->d_name, "cur") == 0 ||
- strcmp(de->d_name, STORE_INDEX_NAME) == 0)
- continue;
- if (!mailbox_name_valid(de->d_name))
- continue;
- if (stat(de->d_name, &st) == -1 || !S_ISDIR(st.st_mode))
- continue;
-
- memset(&item, 0, sizeof(item));
- strlcpy(item.mailbox, de->d_name, sizeof(item.mailbox));
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_LIST_ITEM, 0, 0, -1,
- &item, sizeof(item)) == -1) {
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_LIST_ITEM", session_id);
- continue;
- }
- result.count++;
- }
- closedir(dp);
- result.ok = 1;
-
-send:
- if (switched && select_mailbox_dir(saved) == -1)
- log_warnx("session %u: LIST: couldn't restore previously "
- "selected mailbox %s", session_id, saved);
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
- sizeof(result)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * IMSG_MBOX_APPEND handling -- see imapd.h's imsg_mbox_append comment
- * for the wire shape (header struct plus variable-length trailing message
- * bytes in the same imsg). v1 is INBOX-only, checked the same case-
- * insensitive way handle_mbox_select() already does.
- *
- * Delivery follows maildir's own atomic contract (openimap-storage-
- * backend.md: "write to tmp/, rename(2) into new/"): write the full
- * message to a freshly-named tmp/ file, fsync, then rename directly into
- * cur/ with whatever flags the client requested (or none) encoded in the
- * maildir suffix immediately -- not into new/. Unlike externally
- * (MTA-)delivered mail, which genuinely hasn't been seen by any client
- * yet, an APPENDed message is by construction something the appending
- * client already knows about (importing old mail, saving a draft or sent
- * copy, etc.) -- there's no "unseen" period to represent, so there's no
- * reason to place it in new/ the way SELECT's new/ scan handles
- * externally-delivered mail. Not directly sourced -- openimap-storage-
- * backend.md doesn't discuss APPEND at all -- this is this
- * implementation's own inference from what new/ vs. cur/ actually mean,
- * consistent with the "touched by any client action -> cur/, with an
- * explicit (possibly empty) suffix" precedent handle_mbox_store() already
- * established.
- *
- * The basename's uniquer (`<timestamp>.<pid>_<counter>.<hostname>`) is
- * this implementation's own scheme, not a reproduction of Courier's exact
- * algorithm (not directly sourced this session) -- it only has to satisfy
- * maildir's actual requirement (a basename is never reused within this
- * mailbox), which timestamp + pid + a per-process counter already does on
- * its own; the trailing hostname field is included anyway to match
- * Courier's own `<timestamp>.<uniquer>.<hostname>` shape (used there to
- * disambiguate multiple physical delivery hosts sharing one NFS-mounted
- * maildir -- a deployment v1 doesn't support, but there's no reason not
- * to carry the real value once it's available for free). append_
- * hostname() below calls the real gethostname(2) -- confirmed safe under
- * pledge(2) this session by reading the real pledge_sysctl() in
- * openbsd_source/sys/kern/kern_pledge.c directly: the KERN_HOSTNAME case
- * (what gethostname(2) resolves to internally) returns success
- * unconditionally, with no `pledge &` gate on any specific promise the
- * way most of that function's other cases have -- so store's minimal
- * "stdio" pledge already covers it. Not sanitized against unusual
- * characters: ':' is the one character that would actually be dangerous
- * here, since it also introduces the maildir flag-suffix in the final
- * on-disk filename (locate_message_file()'s own basename-prefix-plus-
- * ':' matching). Standard DNS hostnames don't contain ':' -- this is
- * general knowledge, not something re-verified against an RFC this
- * session -- so this is treated as safe in practice rather than
- * defended against explicitly; a hostname from a source that could
- * return one (not gethostname(2) on a normally configured system) would
- * need this revisited.
- *
- * Ordering matters for failure-mode safety: the index is updated (UID
- * assigned, index_save()'d) *before* the tmp/ -> cur/ rename, not after.
- * If the rename then fails, the result is an index entry pointing at a
- * basename that was never actually created in cur/ -- exactly the
- * "message indexed but missing on disk" case handle_mbox_fetch()/
- * handle_mbox_store()/handle_mbox_expunge() already handle gracefully
- * (log, skip). The other ordering (rename first, index second) would
- * instead risk a file sitting in cur/ with no index entry at all if the
- * index step failed afterward -- invisible forever, since (unlike new/)
- * nothing ever scans cur/ for unindexed basenames. Per RFC 9051 SS6.3.12,
- * "the mailbox MUST be restored to its state before the APPEND attempt
- * (other than possibly keeping the changed mailbox's UIDNEXT value)" on
- * failure -- UIDNEXT is explicitly allowed to stay bumped; this
- * implementation additionally, in the specific rename-fails-after-index-
- * save case, leaves behind the phantom index entry rather than reopening
- * the index a second time to roll it back -- a small, deliberately
- * accepted imperfection in an already-rare failure path, not a silent
- * one.
- */
-
-/*
- * Returns this host's name for the maildir basename uniquer above,
- * calling the real gethostname(2) once per store child and caching the
- * result (a session's hostname can't change mid-process, so there's no
- * reason to re-enter the kernel on every APPEND). Falls back to the
- * literal string "imapd" -- the same placeholder this code used
- * unconditionally before gethostname(2)'s pledge(2) coverage was
- * confirmed -- if the call itself ever fails; genuinely unlikely on a
- * normally configured system, but a failure here has never been fatal
- * to the basename's own uniqueness guarantee (which only ever depended
- * on timestamp+pid+counter, see this function's header comment), so
- * there's no reason to treat it as fatal to the whole APPEND either.
- */
-static const char *
-append_hostname(void)
-{
- static char hostbuf[256];
- static int resolved;
-
- if (!resolved) {
- if (gethostname(hostbuf, sizeof(hostbuf)) == -1) {
- log_warn("session %u: gethostname", session_id);
- strlcpy(hostbuf, "imapd", sizeof(hostbuf));
- }
- resolved = 1;
- }
- return (hostbuf);
-}
-
-static void
-handle_mbox_append(struct imsg_mbox_append *req, const char *msgbody,
- size_t msglen, struct imsgev *iev)
-{
- struct mbox_index idx;
- struct imsg_mbox_appended reply;
- int fd = -1, tmpfd = -1, locked = 0;
- char basename[256];
- char tmppath[300], curpath[320];
- char target[MBOX_NAME_MAX];
- char letters[8], line[STORE_INDEX_LINE_MAX];
- int64_t delivery_ts;
- char saved[MBOX_NAME_MAX];
- int switched = 0;
-
- memset(&idx, 0, sizeof(idx));
- memset(&reply, 0, sizeof(reply));
-
- /*
- * RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 addition: APPEND's destination is
- * independent of whatever this session currently has SELECTed (RFC
- * 9051 SS6.3.12: valid in the authenticated state with nothing
- * selected at all), so -- unlike FETCH/STORE/EXPUNGE, which stay
- * cwd-relative and unchanged because they never need to go anywhere
- * else -- this resolves and *moves to* its own target directory via
- * select_mailbox_dir(), then uses bare, cwd-relative paths for
- * everything from here down, exactly the way handle_mbox_status()
- * and (for its destination side) handle_mbox_copy()/handle_mbox_
- * move()'s commit_copy_messages() already do.
- *
- * This function used to do it differently -- stay at the maildir
- * root and build every path with an explicit "name/" prefix string,
- * the same shape CREATE/DELETE/RENAME/LIST use (see handle_mbox_
- * create()'s comment for that pattern's own real-hardware bug and
- * fix). That approach worked for every path this function *directly*
- * builds (the tmp/ write, the cur/ rename, and the initial indexed
- * open via a prefixed idxpath) -- but it silently broke on the final
- * index_save() call, which is a shared helper that has no prefix
- * parameter at all and unconditionally operates on bare "imapd.
- * index"/"imapd.index.tmp" relative to cwd. Since this function
- * never actually left the maildir root, every APPEND to a *named*
- * mailbox correctly read that mailbox's own real index (via the
- * prefixed open+fd) but then wrote the updated result to the
- * session's INBOX index instead -- overwriting whatever was really
- * there, once per APPEND, with a fresh one-line index for whichever
- * message happened to be appended last. The actual message file
- * still landed in the right place (cur/ under the correct mailbox,
- * via the prefixed tmp/cur paths, which don't go through index_
- * save()) -- only the index bookkeeping went to the wrong file, so
- * this was a real hazard to a mailbox's *index* integrity, not to
- * message data itself.
- *
- * Found on premio testing real Apple Mail draft autosaves against a
- * named Drafts mailbox: four rapid APPENDs each correctly wrote
- * their message into Drafts/cur/, but Drafts/openimap.index stayed
- * at UIDNEXT 1 (never advanced), while the session's real INBOX
- * index -- untouched by anything else in the same window -- ended
- * up replaced by a single line pointing at the fourth APPEND's own
- * basename. INBOX's original message files were never touched
- * (still present in its own cur/), so nothing was lost, but INBOX
- * became briefly unreadable through IMAP until the index was
- * repaired. Switching this function to actually chdir into the
- * target mailbox before doing any of this work -- rather than
- * merely building path strings that describe where it is -- closes
- * the whole class: index_save() (and everything else below) is now
- * cwd-relative and correct by the same construction FETCH/STORE/
- * EXPUNGE/STATUS already rely on, with nothing left that needs its
- * own prefix parameter to get right.
- */
- strlcpy(saved, current_mailbox_dir, sizeof(saved));
-
- if (mailbox_name_is_inbox(req->mailbox)) {
- target[0] = '\0';
- } else if (mailbox_name_valid(req->mailbox)) {
- strlcpy(target, req->mailbox, sizeof(target));
- } else {
- log_debug("session %u: APPEND %s: invalid mailbox name",
- session_id, req->mailbox);
- reply.no_such_mailbox = 1;
- goto done_reply;
- }
-
- if (select_mailbox_dir(target) == -1) {
- log_debug("session %u: APPEND %s: no such mailbox",
- session_id, req->mailbox);
- reply.no_such_mailbox = 1;
- goto done_reply;
- }
- switched = 1;
-
- if (ensure_maildir_dirs("") == -1)
- goto done_reply;
-
- delivery_ts = req->has_date ? req->date : (int64_t)time(NULL);
-
- if (snprintf(basename, sizeof(basename), "%lld.%d_%u.%s",
- (long long)delivery_ts, (int)getpid(), append_counter++,
- append_hostname()) >= (int)sizeof(basename)) {
- log_warnx("session %u: generated basename too long",
- session_id);
- goto done_reply;
- }
- if (snprintf(tmppath, sizeof(tmppath), "tmp/%s", basename) >=
- (int)sizeof(tmppath)) {
- log_warnx("session %u: tmp path too long", session_id);
- goto done_reply;
- }
-
- if ((tmpfd = open(tmppath, O_WRONLY | O_CREAT | O_EXCL, 0600)) == -1) {
- log_warn("session %u: open %s", session_id, tmppath);
- goto done_reply;
- }
- {
- size_t written = 0;
-
- while (written < msglen) {
- ssize_t n;
-
- n = write(tmpfd, msgbody + written, msglen - written);
- if (n == -1) {
- if (errno == EINTR)
- continue;
- log_warn("session %u: write %s", session_id,
- tmppath);
- close(tmpfd);
- tmpfd = -1;
- unlink(tmppath);
- goto done_reply;
- }
- written += (size_t)n;
- }
- }
- if (fsync(tmpfd) == -1)
- log_warn("session %u: fsync %s (continuing)", session_id,
- tmppath);
- close(tmpfd);
- tmpfd = -1;
-
- if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
- log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
- goto done_unlink;
- }
- if (flock(fd, LOCK_EX) == -1) {
- log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
- goto done_unlink;
- }
- locked = 1;
- if (index_load(fd, &idx) == -1)
- goto done_unlink;
-
- reply.uid = idx.uidnext;
- if (index_append(&idx, reply.uid, basename) == -1)
- goto done_unlink;
- idx.uidnext++;
-
- if (req->keywords[0] != '\0') {
- int n;
-
- /*
- * index_append() just assigned this message idx.highestmodseq
- * (its own bump, above) and wrote an empty-keywords line --
- * this rewrite has to carry that same value forward, not drop
- * it, or an APPEND with an initial flag-list would silently
- * lose its per-message mod-sequence (RFC 7162 field added this
- * pass; see index_append()'s comment).
- */
- n = snprintf(line, sizeof(line), "%u:%s:%s:%llu", reply.uid,
- basename, req->keywords,
- (unsigned long long)idx.highestmodseq);
- if (n < 0 || (size_t)n >= sizeof(line)) {
- log_warnx("session %u: index line too long for %s",
- session_id, basename);
- goto done_unlink;
- }
- free(idx.lines[idx.nlines - 1]);
- if ((idx.lines[idx.nlines - 1] = strdup(line)) == NULL) {
- log_warn("session %u: strdup index line", session_id);
- goto done_unlink;
- }
- }
-
- if (index_save(&idx) == -1)
- goto done_unlink;
-
- reply.uidvalidity = idx.uidvalidity;
- reply.exists = (uint32_t)idx.nlines;
-
- flock(fd, LOCK_UN);
- close(fd);
- fd = -1;
- locked = 0;
- index_free(&idx);
-
- /* Index committed -- now the rename; see this function's header
- * comment for why this order (index-then-rename, not rename-then-
- * index) is the safer one to fail partway through. */
- sysflags_to_letters(req->sysflags, letters, sizeof(letters));
- if (snprintf(curpath, sizeof(curpath), "cur/%s:2,%s", basename,
- letters) >= (int)sizeof(curpath)) {
- log_warnx("session %u: cur path too long for %s", session_id,
- basename);
- unlink(tmppath);
- goto done_reply;
- }
- if (rename(tmppath, curpath) == -1) {
- log_warn("session %u: rename %s -> %s", session_id, tmppath,
- curpath);
- unlink(tmppath);
- goto done_reply;
- }
-
- reply.ok = 1;
- goto done_reply;
-
-done_unlink:
- unlink(tmppath);
- if (locked)
- flock(fd, LOCK_UN);
- if (fd != -1)
- close(fd);
- index_free(&idx);
-
-done_reply:
- if (switched && select_mailbox_dir(saved) == -1)
- log_warnx("session %u: APPEND %s: couldn't restore "
- "previously selected mailbox %s", session_id,
- req->mailbox, saved);
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_APPENDED, 0, 0, -1, &reply,
- sizeof(reply)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_APPENDED",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * One message staged by stage_copy_messages() for handle_mbox_copy()/
- * handle_mbox_move() -- read fully into memory from the source mailbox,
- * not yet written anywhere. File-scope (not local to either function)
- * since move_cross_mailbox() below needs the same shape too.
- */
-
-/*
- * F6 fix: bound how much message data COPY/MOVE holds in memory at once.
- * stage_copy_messages() reads every message in the range fully into RAM
- * before commit (required for RFC 9051 6.4.7 all-or-nothing COPY), so an
- * unbounded range of large, externally-delivered messages could exhaust
- * the store child's address space. Exceeding either limit fails the whole
- * COPY/MOVE cleanly (no partial copy). Tunable for larger deployments.
- */
-#define COPY_STAGE_MSG_MAX ((uint64_t)64 * 1024 * 1024) /* per message */
-#define COPY_STAGE_TOTAL_MAX ((uint64_t)512 * 1024 * 1024) /* per operation */
-
-struct copy_staged {
- uint32_t src_uid;
- uint32_t dest_uid;
- char basename[256];
- char keywords[512];
- uint32_t sysflags;
- void *body;
- size_t bodylen;
-};
-
-/*
- * Resolves a client-supplied mailbox name to the bare "target" string
- * select_mailbox_dir() expects: "" for INBOX, or the name itself once
- * mailbox_name_valid() has passed syntax. Returns 0 and fills *target on
- * success, -1 (target left untouched) if the name is syntactically
- * invalid -- this only checks syntax, not existence; select_mailbox_dir()'s
- * own stat(2) call is still what determines whether the mailbox genuinely
- * exists on disk. Added this pass (RFC 9051 SS6.4.7/SS6.4.8 cross-mailbox
- * COPY/MOVE, docs/openimap-storage-backend.md item 10's own follow-up) so
- * handle_mbox_copy()/handle_mbox_move() can resolve a destination name the
- * identical way handle_mbox_select()/handle_mbox_create() already resolve
- * theirs, rather than re-deriving the same mailbox_name_is_inbox()/
- * mailbox_name_valid()/else three-way split a third and fourth time.
- */
-static int
-resolve_mailbox_target(const char *name, char *target, size_t targetlen)
-{
- if (mailbox_name_is_inbox(name)) {
- target[0] = '\0';
- return (0);
- }
- if (mailbox_name_valid(name)) {
- if (strlcpy(target, name, targetlen) >= targetlen)
- return (-1);
- return (0);
- }
- return (-1);
-}
-
-/*
- * Pass 1 of COPY/MOVE: reads every message in the requested sequence/UID
- * range (by_uid-or-sequence interpreted the same way handle_mbox_search()/
- * handle_mbox_fetch() already do) from whichever mailbox is currently
- * cwd-resident into memory, read-only -- no disk writes, no index
- * mutation. Shared by handle_mbox_copy() and move_cross_mailbox() (a
- * cross-mailbox MOVE is, per RFC 9051 SS6.4.8's own "COPY... followed by
- * removal" equivalence, structurally a COPY that additionally removes the
- * originals afterward -- see move_cross_mailbox()'s own header comment),
- * and by both of COPY's same-mailbox/cross-mailbox code paths, since
- * staging never depends on where the *destination* is, only on where the
- * source already is (cwd, by the time this is called).
- *
- * Returns 1 and fills *staged_out and *nstaged_out on success (caller frees
- * each entry's .body plus the array itself), 0 if any message failed to
- * stage -- per COPY's own all-or-nothing requirement (SS6.4.7 -- "server
- * implementations MUST restore the destination mailbox to its state
- * before the COPY attempt... i.e., partial copy MUST NOT be done"),
- * nothing has been written anywhere yet either way.
- *
- * New basenames reuse parse_maildir_timestamp() against the *source*
- * basename (not the current time) so the copy's own INTERNALDATE --
- * derived from that same leading field, see that function's comment --
- * matches the original's, per SS6.4.7: "The flags and internal date of
- * the message(s) SHOULD be preserved in the copy." append_counter/
- * append_hostname() (file-scope, see append_counter's own comment) mint
- * the rest of the basename exactly like APPEND's own basenames.
- */
-static int
-stage_copy_messages(struct mbox_index *idx, struct imsg_mbox_copy *req,
- struct copy_staged **staged_out, size_t *nstaged_out)
-{
- struct copy_staged *staged = NULL;
- size_t nstaged = 0, stagedcap = 0, i;
- uint64_t staged_total = 0; /* F6: bytes staged so far */
- uint32_t lo, hi;
-
- lo = hi = 0;
- if (req->by_uid) {
- uint32_t max_uid = index_max_uid(idx);
-
- lo = req->lo_is_star ? max_uid : req->seq_lo;
- hi = req->hi_is_star ? max_uid : req->seq_hi;
- if (lo < 1)
- lo = 1;
- } else {
- lo = req->seq_lo;
- hi = req->hi_is_star ? (uint32_t)idx->nlines : req->seq_hi;
- }
-
- for (i = 0; i < idx->nlines; i++) {
- struct index_rec rec;
- const char *lp;
- char suffix[64];
- off_t size;
- char path[600];
- int srcfd;
- struct copy_staged cs;
-
- if (index_parse_line(idx->lines[i], &rec) == -1)
- continue;
-
- if (req->by_uid) {
- if (rec.uid < lo)
- continue;
- if (rec.uid > hi)
- break;
- } else {
- if (i + 1 < lo)
- continue;
- if (i + 1 > hi)
- break;
- }
-
- if (locate_message_file(rec.basename, &size, suffix,
- sizeof(suffix)) == -1) {
- log_warnx("session %u: COPY: message %s indexed but "
- "missing on disk -- failing whole COPY (partial "
- "copy not permitted, RFC 9051 SS6.4.7)",
- session_id, rec.basename);
- goto fail;
- }
-
- /* F6 fix: refuse before allocating if this message, or the
- * running total, would exceed the staging limits. */
- if ((uint64_t)size > COPY_STAGE_MSG_MAX) {
- log_warnx("session %u: COPY: message %s is %lld bytes, "
- "over the per-message staging limit -- failing COPY",
- session_id, rec.basename, (long long)size);
- goto fail;
- }
- if ((uint64_t)size > COPY_STAGE_TOTAL_MAX - staged_total) {
- log_warnx("session %u: COPY: staged data would exceed the "
- "total staging limit -- failing COPY", session_id);
- goto fail;
- }
- staged_total += (uint64_t)size;
-
- memset(&cs, 0, sizeof(cs));
- cs.src_uid = rec.uid;
- strlcpy(cs.keywords, rec.keywords, sizeof(cs.keywords));
- lp = strstr(suffix, "2,");
- cs.sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
-
- if (snprintf(cs.basename, sizeof(cs.basename),
- "%lld.%d_%u.%s",
- (long long)parse_maildir_timestamp(rec.basename),
- (int)getpid(), append_counter++, append_hostname()) >=
- (int)sizeof(cs.basename)) {
- log_warnx("session %u: COPY: generated basename too "
- "long", session_id);
- goto fail;
- }
-
- if (snprintf(path, sizeof(path), "%s/%s%s",
- suffix[0] == '\0' ? "new" : "cur", rec.basename, suffix)
- >= (int)sizeof(path)) {
- log_warnx("session %u: COPY: source path too long "
- "for %s", session_id, rec.basename);
- goto fail;
- }
- if ((srcfd = open(path, O_RDONLY)) == -1) {
- log_warn("session %u: COPY: open %s", session_id,
- path);
- goto fail;
- }
- cs.bodylen = (size_t)size;
- if (cs.bodylen > 0 && (cs.body = malloc(cs.bodylen)) == NULL) {
- log_warn("session %u: COPY: malloc %zu bytes",
- session_id, cs.bodylen);
- close(srcfd);
- goto fail;
- }
- {
- size_t rd = 0;
-
- while (rd < cs.bodylen) {
- ssize_t n = read(srcfd,
- (char *)cs.body + rd, cs.bodylen - rd);
- if (n == -1) {
- if (errno == EINTR)
- continue;
- log_warn("session %u: COPY: read %s",
- session_id, path);
- close(srcfd);
- free(cs.body);
- goto fail;
- }
- if (n == 0)
- break; /* short file -- copy what's
- * actually there rather than
- * fail */
- rd += (size_t)n;
- }
- cs.bodylen = rd;
- }
- close(srcfd);
-
- if (nstaged == stagedcap) {
- size_t newcap = (stagedcap == 0) ? 8 :
- stagedcap * 2;
- struct copy_staged *newstaged = reallocarray(staged,
- newcap, sizeof(*staged));
-
- if (newstaged == NULL) {
- log_warn("session %u: COPY: reallocarray "
- "staged", session_id);
- free(cs.body);
- goto fail;
- }
- staged = newstaged;
- stagedcap = newcap;
- }
- staged[nstaged++] = cs;
- }
-
- *staged_out = staged;
- *nstaged_out = nstaged;
- return (1);
-
-fail:
- for (i = 0; i < nstaged; i++)
- free(staged[i].body);
- free(staged);
- *staged_out = NULL;
- *nstaged_out = 0;
- return (0);
-}
-
-/*
- * Pass 2+3 of COPY/MOVE: commits every staged message (see stage_copy_
- * messages()) to whichever mailbox is currently cwd-resident -- tmp/
- * write + rename into cur/ per message, then one index_append() per
- * message into destidx and a single index_save() once every corresponding
- * cur/ file is already durably in place, mirroring APPEND's own rename-
- * then-index ordering. Streams one IMSG_MBOX_COPY_MAPPING per committed
- * message as it's indexed, ready for session_finish_copy_or_move() in
- * listener.c to compact into COPYUID's two UID sets.
- *
- * Caller must already have select_mailbox_dir()'d to the destination and
- * ensure_maildir_dirs("")'d it. Returns 1 if every message committed, 0 on
- * the first failure -- a mid-pass-2 failure (much rarer than a pass-1 read
- * failure, since the source files are already known to exist) leaves
- * destidx's UIDNEXT completely untouched, better than RFC 9051 SS6.4.7's
- * own minimum bar, which explicitly allows UIDNEXT to have moved -- at the
- * cost of a small, deliberately accepted imperfection: any cur/ files
- * already renamed earlier in pass 2 before the failure are left behind
- * rather than transactionally rolled back, the same category of rare-
- * failure-path tradeoff APPEND's own header comment already flags for its
- * own rename-ordering choice.
- */
-static int
-commit_copy_messages(struct mbox_index *destidx, struct copy_staged *staged,
- size_t nstaged, struct imsgev *iev)
-{
- size_t i;
-
- for (i = 0; i < nstaged; i++) {
- char tmppath[300], curpath[320];
- char letters[8];
- int tmpfd;
-
- if (snprintf(tmppath, sizeof(tmppath), "tmp/%s",
- staged[i].basename) >= (int)sizeof(tmppath)) {
- log_warnx("session %u: COPY: tmp path too long",
- session_id);
- return (0);
- }
- if ((tmpfd = open(tmppath, O_WRONLY | O_CREAT | O_EXCL,
- 0600)) == -1) {
- log_warn("session %u: COPY: open %s", session_id,
- tmppath);
- return (0);
- }
- {
- size_t written = 0;
-
- while (written < staged[i].bodylen) {
- ssize_t n = write(tmpfd,
- (char *)staged[i].body + written,
- staged[i].bodylen - written);
- if (n == -1) {
- if (errno == EINTR)
- continue;
- log_warn("session %u: COPY: write %s",
- session_id, tmppath);
- close(tmpfd);
- unlink(tmppath);
- return (0);
- }
- written += (size_t)n;
- }
- }
- if (fsync(tmpfd) == -1)
- log_warn("session %u: COPY: fsync %s (continuing)",
- session_id, tmppath);
- close(tmpfd);
-
- sysflags_to_letters(staged[i].sysflags, letters,
- sizeof(letters));
- if (snprintf(curpath, sizeof(curpath), "cur/%s:2,%s",
- staged[i].basename, letters) >= (int)sizeof(curpath)) {
- log_warnx("session %u: COPY: cur path too long",
- session_id);
- unlink(tmppath);
- return (0);
- }
- if (rename(tmppath, curpath) == -1) {
- log_warn("session %u: COPY: rename %s -> %s",
- session_id, tmppath, curpath);
- unlink(tmppath);
- return (0);
- }
- }
-
- for (i = 0; i < nstaged; i++) {
- uint32_t dest_uid = destidx->uidnext;
-
- if (index_append(destidx, dest_uid, staged[i].basename) ==
- -1)
- return (0);
- destidx->uidnext++;
- staged[i].dest_uid = dest_uid;
-
- if (staged[i].keywords[0] != '\0') {
- char line[STORE_INDEX_LINE_MAX];
- int n;
-
- n = snprintf(line, sizeof(line), "%u:%s:%s:%llu",
- dest_uid, staged[i].basename, staged[i].keywords,
- (unsigned long long)destidx->highestmodseq);
- if (n < 0 || (size_t)n >= sizeof(line)) {
- log_warnx("session %u: COPY: index line too "
- "long", session_id);
- return (0);
- }
- free(destidx->lines[destidx->nlines - 1]);
- if ((destidx->lines[destidx->nlines - 1] =
- strdup(line)) == NULL) {
- log_warn("session %u: COPY: strdup index "
- "line", session_id);
- return (0);
- }
- }
- }
-
- if (index_save(destidx) == -1)
- return (0);
-
- for (i = 0; i < nstaged; i++) {
- struct imsg_mbox_copy_mapping mapping;
-
- memset(&mapping, 0, sizeof(mapping));
- mapping.src_uid = staged[i].src_uid;
- mapping.dest_uid = staged[i].dest_uid;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_COPY_MAPPING, 0, 0, -1,
- &mapping, sizeof(mapping)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_COPY_MAPPING", session_id);
- }
-
- return (1);
-}
-
-/*
- * RFC 9051 SS6.4.7 COPY (and, via handle_mbox_move() just below, half of
- * SS6.4.8 MOVE too). Destination resolution (RFC 9051 SS6.3.4-SS6.3.6 flat
- * multi-mailbox support, docs/openimap-storage-backend.md item 10's own
- * follow-up): req->destname names any real, existing mailbox, not just
- * the one already SELECTed the way the original v1 code required. Two
- * structurally different paths follow, chosen once near the top:
- *
- * - destname resolves to the mailbox already SELECTed: a single index,
- * opened and flock(2)'d exactly once, cwd never moves -- the original
- * v1 code, unchanged in spirit. This isn't just simpler than the
- * cross-mailbox path below -- opening and flock(2)ing the *same* path
- * a second time from this same process would self-deadlock (BSD
- * flock(2) locks belong to the open file description, not the
- * process, so a second independent open()+flock(LOCK_EX) here would
- * block forever on the lock this same process already holds on the
- * first).
- *
- * - destname resolves to a genuinely different mailbox: both mailboxes'
- * index files are opened and flock(2)'d for the duration. Two
- * sessions issuing opposite-direction copies between the same two
- * mailboxes (session A: source X, dest Y; session B: source Y, dest
- * X) must not each acquire one mailbox's lock and then block waiting
- * for the other -- classic AB-BA cross-session deadlock. Locking in a
- * fixed order derived only from the two mailbox names themselves,
- * identical for both sessions regardless of which one is "source" and
- * which is "destination" for *that* session, closes it: strcmp()
- * between the two target strings (INBOX's "" sorts first against any
- * named mailbox for free, no special-casing needed) picks the same
- * first-to-lock mailbox both sessions above contend for, so they
- * queue on it in the same order every time rather than in mirror-
- * image order.
- *
- * See stage_copy_messages()/commit_copy_messages() above for the actual
- * staging/commit work and their own all-or-nothing guarantees, shared
- * verbatim between both paths.
- */
-static void
-handle_mbox_copy(struct imsg_mbox_copy *req, struct imsgev *iev)
-{
- struct mbox_index idx_a, idx_b;
- struct mbox_index *srcidx = NULL, *destidx = NULL;
- struct imsg_mbox_result result;
- struct copy_staged *staged = NULL;
- size_t nstaged = 0, i;
- int fd_a = -1, fd_a_locked = 0;
- int fd_b = -1, fd_b_locked = 0;
- int ok = 1;
- char saved[MBOX_NAME_MAX];
- char desttarget[MBOX_NAME_MAX];
- int cross_mailbox;
-
- memset(&idx_a, 0, sizeof(idx_a));
- memset(&idx_b, 0, sizeof(idx_b));
- memset(&result, 0, sizeof(result));
-
- strlcpy(saved, current_mailbox_dir, sizeof(saved));
-
- if (resolve_mailbox_target(req->destname, desttarget,
- sizeof(desttarget)) == -1) {
- log_debug("session %u: COPY %s: invalid destination mailbox "
- "name", session_id, req->destname);
- result.no_such_mailbox = 1;
- ok = 0;
- goto done;
- }
- cross_mailbox = (strcmp(saved, desttarget) != 0);
-
- if (!cross_mailbox) {
- if ((fd_a = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
- -1) {
- log_warn("session %u: open %s", session_id,
- STORE_INDEX_NAME);
- ok = 0;
- goto done;
- }
- if (flock(fd_a, LOCK_EX) == -1) {
- log_warn("session %u: flock %s", session_id,
- STORE_INDEX_NAME);
- ok = 0;
- goto done;
- }
- fd_a_locked = 1;
- if (index_load(fd_a, &idx_a) == -1) {
- ok = 0;
- goto done;
- }
- srcidx = &idx_a;
- destidx = &idx_a;
- } else {
- char first[MBOX_NAME_MAX], second[MBOX_NAME_MAX];
- int first_is_dest;
-
- if (strcmp(saved, desttarget) <= 0) {
- strlcpy(first, saved, sizeof(first));
- strlcpy(second, desttarget, sizeof(second));
- first_is_dest = 0;
- } else {
- strlcpy(first, desttarget, sizeof(first));
- strlcpy(second, saved, sizeof(second));
- first_is_dest = 1;
- }
-
- if (select_mailbox_dir(first) == -1) {
- if (first_is_dest)
- result.no_such_mailbox = 1;
- else
- log_warnx("session %u: COPY: couldn't reach "
- "%s", session_id, first);
- ok = 0;
- goto done;
- }
- if ((fd_a = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
- -1) {
- log_warn("session %u: open %s", session_id,
- STORE_INDEX_NAME);
- ok = 0;
- goto restore_saved;
- }
- if (flock(fd_a, LOCK_EX) == -1) {
- log_warn("session %u: flock %s", session_id,
- STORE_INDEX_NAME);
- ok = 0;
- goto restore_saved;
- }
- fd_a_locked = 1;
- if (index_load(fd_a, &idx_a) == -1) {
- ok = 0;
- goto restore_saved;
- }
-
- if (select_mailbox_dir(second) == -1) {
- if (!first_is_dest)
- result.no_such_mailbox = 1;
- else
- log_warnx("session %u: COPY: couldn't reach "
- "%s", session_id, second);
- ok = 0;
- goto restore_saved;
- }
- if ((fd_b = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
- -1) {
- log_warn("session %u: open %s", session_id,
- STORE_INDEX_NAME);
- ok = 0;
- goto restore_saved;
- }
- if (flock(fd_b, LOCK_EX) == -1) {
- log_warn("session %u: flock %s", session_id,
- STORE_INDEX_NAME);
- ok = 0;
- goto restore_saved;
- }
- fd_b_locked = 1;
- if (index_load(fd_b, &idx_b) == -1) {
- ok = 0;
- goto restore_saved;
- }
-
- srcidx = first_is_dest ? &idx_b : &idx_a;
- destidx = first_is_dest ? &idx_a : &idx_b;
-
- /* Back to source -- staging below reads relative to cwd. */
- if (select_mailbox_dir(saved) == -1) {
- log_warnx("session %u: COPY: couldn't return to %s "
- "to stage messages", session_id, saved);
- ok = 0;
- goto restore_saved;
- }
- }
-
- if (!stage_copy_messages(srcidx, req, &staged, &nstaged)) {
- ok = 0;
- goto restore_saved;
- }
-
- if (nstaged > 0) {
- if (cross_mailbox && select_mailbox_dir(desttarget) == -1) {
- log_warnx("session %u: COPY: couldn't reach "
- "destination %s", session_id, desttarget);
- ok = 0;
- goto restore_saved;
- }
- if (ensure_maildir_dirs("") == -1) {
- ok = 0;
- goto restore_saved;
- }
- if (!commit_copy_messages(destidx, staged, nstaged, iev))
- ok = 0;
- }
-
- if (ok) {
- result.count = (uint32_t)nstaged;
- result.uidvalidity = destidx->uidvalidity;
- result.highestmodseq = destidx->highestmodseq;
- }
-
-restore_saved:
- if (cross_mailbox && select_mailbox_dir(saved) == -1)
- log_warnx("session %u: COPY: couldn't restore previously "
- "selected mailbox %s", session_id, saved);
-
-done:
- for (i = 0; i < nstaged; i++)
- free(staged[i].body);
- free(staged);
-
- if (fd_a_locked)
- flock(fd_a, LOCK_UN);
- if (fd_a != -1)
- close(fd_a);
- if (fd_b_locked)
- flock(fd_b, LOCK_UN);
- if (fd_b != -1)
- close(fd_b);
-
- result.ok = ok;
- index_free(&idx_a);
- index_free(&idx_b);
-
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
- sizeof(result)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
- session_id);
- imsgev_add(iev);
-}
-
-/*
- * MOVE when the destination is the mailbox already SELECTed -- the
- * original v1 algorithm, unchanged: moving a message to the mailbox it's
- * already in needs no file I/O at all, the same physical cur/ file just
- * gets reindexed under a fresh UID. This is "MOVE = COPY + STORE
- * +FLAGS.SILENT \Deleted + UID EXPUNGE" (SS6.4.8's own equivalence)
- * collapsed to its net effect once source and destination coincide: the
- * old index line disappears, a new one (same basename, new UID) appears
- * at the tail, and nothing on disk changes at all. See handle_mbox_
- * move()'s own header comment for why this stays a distinct, cheaper path
- * from move_cross_mailbox() below rather than being folded into the
- * general "copy then remove" shape a genuinely different destination
- * requires.
- *
- * Unlike COPY, MOVE is explicitly allowed to fail partway through its set
- * ("Regardless of whether the command is successful in moving the entire
- * set, each individual message MUST be either moved or unaffected" --
- * SS6.4.8), so this doesn't need COPY's stage-then-commit split -- given
- * the zero-copy design, the only failure modes left are index_append()'s
- * own malloc/line-length checks and the final index_save(), both of which
- * fail before any disk state changes, so each message really is
- * atomically either moved or unaffected in this implementation.
- *
- * Two passes over idx, the first shaped exactly like handle_mbox_
- * expunge()'s own compaction algorithm: pass 1 walks and compacts,
- * removing each matched message's old line and recording (old UID, old
- * seqno, basename, keywords) for pass 2, which re-appends each recorded
- * message at the tail with a fresh UID via index_append() (same
- * per-message HIGHESTMODSEQ bump as an APPEND or COPY arrival -- see that
- * function's own RFC 7162 SS3.1 citation: "the server generates a new
- * modification sequence... when a message is appended... via APPEND,
- * COPY to the mailbox, or using an external mechanism"; a MOVE's
- * destination-side effect is exactly this same kind of arrival).
- * old_seqno is computed the identical way handle_mbox_expunge() already
- * does ("immediately decremented" as earlier matches are removed), not
- * recomputed afterward.
- *
- * Per SS6.4.8, "servers are also REQUIRED to send the COPYUID response
- * code in an untagged OK before sending EXPUNGE" -- this function sends
- * every IMSG_MBOX_COPY_MAPPING first (naturally, since pass 2 runs before
- * the final notification loop below), then every IMSG_MBOX_EXPUNGED,
- * matching send order to the required response order; listener.c
- * additionally buffers and re-flushes in this same fixed order regardless
- * (see imapd.h's imsg_mbox_copy_mapping comment), so this function's
- * own ordering isn't the only thing enforcing it, just a natural match.
- *
- * Real bug caught testing MOVE live on premio, before flat multi-mailbox
- * support existed: "MOVE 2 INBOX" against a 3-message mailbox moved
- * messages 2 *and* 3, not just 2. Root cause was using "out" (this loop's
- * compaction *write* index, which stalls -- doesn't increment -- every
- * time a message matches and gets removed) instead of "in" (the original
- * read position) to test sequence-range membership. "in" is the correct
- * original 1-based sequence number for the line currently being examined;
- * old_seqno just below is a *different*, intentionally correct use of
- * "out + 1" -- it reports each EXPUNGE's sequence number relative to the
- * mailbox state after earlier removals in the same command, which is
- * exactly what the "immediately decremented" value is for.
- */
-static int
-move_same_mailbox(struct imsg_mbox_copy *req, struct mbox_index *idx,
- uint32_t *nmoved_out, struct imsgev *iev)
-{
- struct moved {
- uint32_t old_uid;
- uint32_t old_seqno;
- uint32_t dest_uid;
- char basename[512];
- char keywords[512];
- };
-
- struct moved *moved = NULL;
- size_t nmoved = 0, movedcap = 0, in, out, i;
- uint32_t lo, hi;
- int ok = 1;
-
- *nmoved_out = 0;
-
- lo = hi = 0;
- if (req->by_uid) {
- uint32_t max_uid = index_max_uid(idx);
-
- lo = req->lo_is_star ? max_uid : req->seq_lo;
- hi = req->hi_is_star ? max_uid : req->seq_hi;
- if (lo < 1)
- lo = 1;
- } else {
- lo = req->seq_lo;
- hi = req->hi_is_star ? (uint32_t)idx->nlines : req->seq_hi;
- }
-
- out = 0;
- for (in = 0; in < idx->nlines; in++) {
- struct index_rec rec;
- int matched;
-
- if (index_parse_line(idx->lines[in], &rec) == -1) {
- idx->lines[out++] = idx->lines[in];
- continue;
- }
-
- if (req->by_uid)
- matched = (rec.uid >= lo && rec.uid <= hi);
- else
- matched = ((uint32_t)(in + 1) >= lo &&
- (uint32_t)(in + 1) <= hi);
-
- if (!matched) {
- idx->lines[out++] = idx->lines[in];
- continue;
- }
-
- if (nmoved == movedcap) {
- size_t newcap = (movedcap == 0) ? 8 :
- movedcap * 2;
- struct moved *newmoved = reallocarray(moved, newcap,
- sizeof(*moved));
-
- if (newmoved == NULL) {
- log_warn("session %u: MOVE: reallocarray "
- "moved", session_id);
- free(idx->lines[in]);
- ok = 0;
- goto done;
- }
- moved = newmoved;
- movedcap = newcap;
- }
- moved[nmoved].old_uid = rec.uid;
- moved[nmoved].old_seqno = (uint32_t)(out + 1);
- strlcpy(moved[nmoved].basename, rec.basename,
- sizeof(moved[nmoved].basename));
- strlcpy(moved[nmoved].keywords, rec.keywords,
- sizeof(moved[nmoved].keywords));
- nmoved++;
-
- free(idx->lines[in]);
- }
- idx->nlines = out;
-
- for (i = 0; i < nmoved; i++) {
- uint32_t dest_uid = idx->uidnext;
-
- if (index_append(idx, dest_uid, moved[i].basename) == -1) {
- ok = 0;
- goto done;
- }
- idx->uidnext++;
- moved[i].dest_uid = dest_uid;
-
- if (moved[i].keywords[0] != '\0') {
- char line[STORE_INDEX_LINE_MAX];
- int n;
-
- n = snprintf(line, sizeof(line), "%u:%s:%s:%llu",
- dest_uid, moved[i].basename, moved[i].keywords,
- (unsigned long long)idx->highestmodseq);
- if (n < 0 || (size_t)n >= sizeof(line)) {
- log_warnx("session %u: MOVE: index line too "
- "long", session_id);
- ok = 0;
- goto done;
- }
- free(idx->lines[idx->nlines - 1]);
- if ((idx->lines[idx->nlines - 1] = strdup(line)) ==
- NULL) {
- log_warn("session %u: MOVE: strdup index "
- "line", session_id);
- ok = 0;
- goto done;
- }
- }
- }
-
- if (index_save(idx) == -1) {
- ok = 0;
- goto done;
- }
-
- /* COPYUID mapping data first... */
- for (i = 0; i < nmoved; i++) {
- struct imsg_mbox_copy_mapping mapping;
-
- memset(&mapping, 0, sizeof(mapping));
- mapping.src_uid = moved[i].old_uid;
- mapping.dest_uid = moved[i].dest_uid;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_COPY_MAPPING, 0, 0, -1,
- &mapping, sizeof(mapping)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_COPY_MAPPING", session_id);
- }
- /* ...then EXPUNGE notices for the old UIDs/seqnos, per SS6.4.8's
- * required ordering. */
- for (i = 0; i < nmoved; i++) {
- struct imsg_mbox_expunged exp;
-
- memset(&exp, 0, sizeof(exp));
- exp.seqno = moved[i].old_seqno;
- exp.uid = moved[i].old_uid;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0, -1,
- &exp, sizeof(exp)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_EXPUNGED",
- session_id);
- }
-
-done:
- *nmoved_out = (uint32_t)nmoved;
- free(moved);
- return (ok);
-}
-
-/*
- * MOVE when the destination genuinely differs from the mailbox already
- * SELECTed. Per RFC 9051 SS6.4.8's own definition ("MOVE... has the same
- * properties as COPY, followed by removal of the copied messages"), this
- * is exactly that: stage_copy_messages()/commit_copy_messages() (shared
- * with handle_mbox_copy() above) do the "COPY" half -- reusing all of
- * COPY's own all-or-nothing staging/commit guarantees for messages that
- * cross-mailbox MOVE now needs too, since (unlike move_same_mailbox()'s
- * zero-copy design above) real file I/O against two different
- * directories is unavoidable here -- and this function's own second half
- * then removes each successfully-committed message from the source:
- * unlink(2) the original cur/ or new/ file and compact it out of srcidx.
- *
- * RFC 9051 SS6.4.8 only requires "each individual message MUST be either
- * moved or unaffected", which permits a partial MOVE across a large set;
- * this implementation is deliberately stricter, matching COPY's own
- * all-or-nothing choice (stage_copy_messages()/commit_copy_messages()'s
- * own comments already flag that as a safe superset of the RFC's minimum
- * bar) -- simpler to reason about, and the risk of a large cross-mailbox
- * MOVE landing half-done is the same category of surprise a client would
- * get from a half-done COPY, which SS6.4.7 already forbids outright.
- *
- * One accepted asymmetry remains, and is called out explicitly rather
- * than silently: once commit_copy_messages() has durably rename(2)d every
- * copy into the destination's cur/ and index_save()'d there, this
- * function still has to unlink(2) each source file and index_save() the
- * source's own compaction separately -- a real (if narrow) window where a
- * crash could leave a message duplicated in both mailboxes rather than
- * moved. Preferred over the alternative of removing from source *before*
- * the destination commit is confirmed, which could instead lose the
- * message outright on the same kind of crash -- duplication is the
- * recoverable failure mode, data loss is not.
- *
- * Messages are matched for removal by the UID stage_copy_messages()
- * already recorded (staged[i].src_uid), not by re-deriving sequence-range
- * membership a second time -- sidesteps entirely the by-sequence
- * "in"-vs-"out" bug class move_same_mailbox()'s own header comment
- * documents, since there's no ambiguity left to get wrong once the exact
- * UID set is already known.
- */
-static int
-move_cross_mailbox(struct imsg_mbox_copy *req, struct mbox_index *srcidx,
- struct mbox_index *destidx, const char *desttarget, const char *saved,
- uint32_t *nmoved_out, struct imsgev *iev)
-{
- struct copy_staged *staged = NULL;
- size_t nstaged = 0, i;
- int ok = 1, any_removed = 0;
-
- *nmoved_out = 0;
-
- if (!stage_copy_messages(srcidx, req, &staged, &nstaged))
- return (0);
-
- if (nstaged == 0)
- return (1);
-
- if (select_mailbox_dir(desttarget) == -1) {
- log_warnx("session %u: MOVE: couldn't reach destination %s",
- session_id, desttarget);
- ok = 0;
- goto cleanup;
- }
- if (ensure_maildir_dirs("") == -1) {
- ok = 0;
- goto cleanup;
- }
- if (!commit_copy_messages(destidx, staged, nstaged, iev)) {
- ok = 0;
- goto cleanup;
- }
-
- /*
- * Destination commit is durable -- now remove each original from
- * the source. Back to source's own directory first (cwd is
- * currently sitting at the destination, from the commit above).
- */
- if (select_mailbox_dir(saved) == -1) {
- log_warnx("session %u: MOVE: committed to destination but "
- "couldn't return to source %s to remove the originals "
- "-- message(s) now duplicated in both mailboxes rather "
- "than moved", session_id, saved);
- ok = 0;
- goto cleanup;
- }
-
- for (i = 0; i < nstaged; i++) {
- struct index_rec rec;
- size_t j, out;
- uint32_t old_seqno = 0;
- int found = 0;
- off_t size;
- char suffix[64], path[600];
-
- /*
- * Re-locate under its *original* basename (staged[i]'s own
- * .basename is the freshly minted destination name) via a
- * fresh scan of srcidx -- deliberately not a single
- * combined compaction pass across every staged[] entry at
- * once, so each removal's old_seqno reflects the mailbox
- * state after every earlier removal in this same command,
- * matching move_same_mailbox()'s "immediately decremented"
- * semantics exactly rather than approximating it.
- */
- for (j = 0, out = 0; j < srcidx->nlines; j++) {
- if (!found && index_parse_line(srcidx->lines[j],
- &rec) == 0 && rec.uid == staged[i].src_uid) {
- old_seqno = (uint32_t)(out + 1);
- found = 1;
- free(srcidx->lines[j]);
- continue;
- }
- srcidx->lines[out++] = srcidx->lines[j];
- }
- srcidx->nlines = out;
-
- if (!found) {
- /*
- * Already gone from the source index -- another of
- * this same user's sessions raced an EXPUNGE/STORE/
- * MOVE of the same message between staging and
- * here. The copy at the destination is still valid
- * and durable; nothing left to remove.
- */
- continue;
- }
- any_removed = 1;
-
- if (locate_message_file(rec.basename, &size, suffix,
- sizeof(suffix)) == 0) {
- if (snprintf(path, sizeof(path), "%s/%s%s",
- suffix[0] == '\0' ? "new" : "cur", rec.basename,
- suffix) < (int)sizeof(path))
- unlink(path);
- }
-
- {
- struct imsg_mbox_expunged exp;
-
- memset(&exp, 0, sizeof(exp));
- exp.seqno = old_seqno;
- exp.uid = staged[i].src_uid;
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0,
- -1, &exp, sizeof(exp)) == -1)
- log_warn("session %u: imsg_compose "
- "IMSG_MBOX_EXPUNGED", session_id);
- }
- }
-
- /* RFC 7162 SS3.1: same single per-operation HIGHESTMODSEQ bump
- * handle_mbox_expunge() already uses for its own compaction. */
- if (any_removed)
- srcidx->highestmodseq++;
-
- if (index_save(srcidx) == -1) {
- log_warnx("session %u: MOVE: destination commit succeeded "
- "but saving the source's compacted index failed -- "
- "message(s) may remain duplicated", session_id);
- ok = 0;
- goto cleanup;
- }
-
- *nmoved_out = (uint32_t)nstaged;
-
-cleanup:
- for (i = 0; i < nstaged; i++)
- free(staged[i].body);
- free(staged);
- return (ok);
-}
-
-/*
- * RFC 9051 SS6.4.8 MOVE, dispatcher half: resolves the destination and
- * acquires whichever index file(s) are needed exactly the way handle_
- * mbox_copy() does (see that function's header comment for the same-
- * mailbox-vs-cross-mailbox split and the lock-ordering reasoning -- not
- * re-explained here), then hands off to move_same_mailbox() or move_
- * cross_mailbox() above for the actual work.
- */
-static void
-handle_mbox_move(struct imsg_mbox_copy *req, struct imsgev *iev)
-{
- struct mbox_index idx_a, idx_b;
- struct mbox_index *srcidx = NULL, *destidx = NULL;
- struct imsg_mbox_result result;
- uint32_t nmoved = 0;
- int fd_a = -1, fd_a_locked = 0;
- int fd_b = -1, fd_b_locked = 0;
- int ok = 1;
- char saved[MBOX_NAME_MAX];
- char desttarget[MBOX_NAME_MAX];
- int cross_mailbox;
-
- memset(&idx_a, 0, sizeof(idx_a));
- memset(&idx_b, 0, sizeof(idx_b));
- memset(&result, 0, sizeof(result));
-
- strlcpy(saved, current_mailbox_dir, sizeof(saved));
-
- if (resolve_mailbox_target(req->destname, desttarget,
- sizeof(desttarget)) == -1) {
- log_debug("session %u: MOVE %s: invalid destination mailbox "
- "name", session_id, req->destname);
- result.no_such_mailbox = 1;
- ok = 0;
- goto done;
- }
- cross_mailbox = (strcmp(saved, desttarget) != 0);
-
- if (!cross_mailbox) {
- if ((fd_a = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
- -1) {
- log_warn("session %u: open %s", session_id,
- STORE_INDEX_NAME);
- ok = 0;
- goto done;
- }
- if (flock(fd_a, LOCK_EX) == -1) {
- log_warn("session %u: flock %s", session_id,
- STORE_INDEX_NAME);
- ok = 0;
- goto done;
- }
- fd_a_locked = 1;
- if (index_load(fd_a, &idx_a) == -1) {
- ok = 0;
- goto done;
- }
- srcidx = &idx_a;
- destidx = &idx_a;
- } else {
- char first[MBOX_NAME_MAX], second[MBOX_NAME_MAX];
- int first_is_dest;
-
- if (strcmp(saved, desttarget) <= 0) {
- strlcpy(first, saved, sizeof(first));
- strlcpy(second, desttarget, sizeof(second));
- first_is_dest = 0;
- } else {
- strlcpy(first, desttarget, sizeof(first));
- strlcpy(second, saved, sizeof(second));
- first_is_dest = 1;
- }
-
- if (select_mailbox_dir(first) == -1) {
- if (first_is_dest)
- result.no_such_mailbox = 1;
- else
- log_warnx("session %u: MOVE: couldn't reach "
- "%s", session_id, first);
- ok = 0;
- goto done;
- }
- if ((fd_a = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
- -1) {
- log_warn("session %u: open %s", session_id,
- STORE_INDEX_NAME);
- ok = 0;
- goto restore_saved;
- }
- if (flock(fd_a, LOCK_EX) == -1) {
- log_warn("session %u: flock %s", session_id,
- STORE_INDEX_NAME);
- ok = 0;
- goto restore_saved;
- }
- fd_a_locked = 1;
- if (index_load(fd_a, &idx_a) == -1) {
- ok = 0;
- goto restore_saved;
- }
-
- if (select_mailbox_dir(second) == -1) {
- if (!first_is_dest)
- result.no_such_mailbox = 1;
- else
- log_warnx("session %u: MOVE: couldn't reach "
- "%s", session_id, second);
- ok = 0;
- goto restore_saved;
- }
- if ((fd_b = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
- -1) {
- log_warn("session %u: open %s", session_id,
- STORE_INDEX_NAME);
- ok = 0;
- goto restore_saved;
- }
- if (flock(fd_b, LOCK_EX) == -1) {
- log_warn("session %u: flock %s", session_id,
- STORE_INDEX_NAME);
- ok = 0;
- goto restore_saved;
- }
- fd_b_locked = 1;
- if (index_load(fd_b, &idx_b) == -1) {
- ok = 0;
- goto restore_saved;
- }
-
- srcidx = first_is_dest ? &idx_b : &idx_a;
- destidx = first_is_dest ? &idx_a : &idx_b;
-
- if (select_mailbox_dir(saved) == -1) {
- log_warnx("session %u: MOVE: couldn't return to %s",
- session_id, saved);
- ok = 0;
- goto restore_saved;
- }
- }
-
- if (!cross_mailbox) {
- if (!move_same_mailbox(req, srcidx, &nmoved, iev))
- ok = 0;
- } else {
- if (!move_cross_mailbox(req, srcidx, destidx, desttarget,
- saved, &nmoved, iev))
- ok = 0;
- }
-
- if (ok) {
- result.count = nmoved;
- result.uidvalidity = destidx->uidvalidity;
- result.highestmodseq = destidx->highestmodseq;
- }
-
-restore_saved:
- if (cross_mailbox && select_mailbox_dir(saved) == -1)
- log_warnx("session %u: MOVE: couldn't restore previously "
- "selected mailbox %s", session_id, saved);
-
-done:
- if (fd_a_locked)
- flock(fd_a, LOCK_UN);
- if (fd_a != -1)
- close(fd_a);
- if (fd_b_locked)
- flock(fd_b, LOCK_UN);
- if (fd_b != -1)
- close(fd_b);
-
- result.ok = ok;
- index_free(&idx_a);
- index_free(&idx_b);
-
- if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
- sizeof(result)) == -1)
- log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
- session_id);
- imsgev_add(iev);
-}
-
-static void
+ if (strlcpy(current_mailbox_dir, target, sizeof(current_mailbox_dir))
+ >= sizeof(current_mailbox_dir)) {
+ /* can't happen (callers bound target under MBOX_NAME_MAX); mirror the restore-cwd failure path anyway */
+ log_warnx("session %u: select_mailbox_dir: target truncated "
+ "-- can't happen (callers bound target under "
+ "MBOX_NAME_MAX, same size as current_mailbox_dir)",
+ session_id);
+ if (chdir("..") == -1)
+ log_warn("session %u: chdir .. (leaving %s after "
+ "truncated select)", session_id, target);
+ else if (current_mailbox_dir[0] != '\0' &&
+ chdir(current_mailbox_dir) == -1)
+ log_warn("session %u: chdir %s (restoring after "
+ "truncated select)", session_id,
+ current_mailbox_dir);
+ return (-1);
+ }
+ return (0);
+}
+
+
+void
store_dispatch(int fd, short event, void *arg)
{
struct imsgev *iev = arg;
struct imsg imsg;
ssize_t n;
- /*
- * EV_WRITE: same real bug as listener.c's listener_dispatch_auth()/
- * auth.c's auth_dispatch() -- see those header comments for the
- * full citation against imsg_init(3). Every IMSG_MBOX_*_RESULT this
- * process sends back is queued via imsg_compose() and needs an
- * actual imsgbuf_write() once the fd is writable.
- */
+ /* every IMSG_MBOX_*_RESULT queued via imsg_compose() needs an actual imsgbuf_write() once writable */
if (event & EV_WRITE) {
if (imsgbuf_write(&iev->ibuf) == -1)
fatal("imsgbuf_write");
if ((n = imsgbuf_read(&iev->ibuf)) == -1)
fatal("imsgbuf_read");
if (n == 0) {
- /* listener's end closed -- treat like
- * IMSG_STORE_SHUTDOWN: nothing left to serve. */
+ /* listener's end closed -- treat like IMSG_STORE_SHUTDOWN: nothing left to serve */
store_shutdown();
/* NOTREACHED */
}
log_warnx("bad IMSG_MBOX_SELECT");
break;
}
- /* handle_mbox_select() composes its own replies
- * (IMSG_MBOX_SELECT_VANISHED / IMSG_MBOX_FETCH_META x N
- * for a QRESYNC resync, then one IMSG_MBOX_SELECTED)
- * and calls imsgev_add(iev) itself -- same streaming
- * pattern as handle_mbox_fetch()/handle_mbox_store()/
- * handle_mbox_expunge()/handle_mbox_search() below. */
+ /* composes its own reply stream and calls imsgev_add(iev), like every handle_mbox_*() below */
handle_mbox_select(&req, iev);
break;
}
log_warnx("bad IMSG_MBOX_FETCH");
break;
}
- /* handle_mbox_fetch() composes its own replies
- * (IMSG_MBOX_FETCH_META x N, then one
- * IMSG_MBOX_RESULT) and calls imsgev_add(iev) itself
- * once at the end -- unlike IMSG_MBOX_SELECT above,
- * which sends exactly one reply and re-arms right
- * after composing it, a variable-length stream of
- * replies is cleaner re-armed once, after the whole
- * batch is queued, than after each individual
- * imsg_compose(). */
handle_mbox_fetch(&req, iev);
break;
}
log_warnx("bad IMSG_MBOX_STORE");
break;
}
- /* Same reply-composition shape as IMSG_MBOX_FETCH
- * above -- handle_mbox_store() composes its own
- * IMSG_MBOX_FETCH_META x N / IMSG_MBOX_RESULT stream
- * and calls imsgev_add(iev) itself once at the end. */
handle_mbox_store(&req, iev);
break;
}
log_warnx("bad IMSG_MBOX_EXPUNGE");
break;
}
- /* Same reply-composition shape as IMSG_MBOX_FETCH/
- * IMSG_MBOX_STORE above -- handle_mbox_expunge()
- * composes its own IMSG_MBOX_EXPUNGED x N /
- * IMSG_MBOX_RESULT stream and calls imsgev_add(iev)
- * itself once at the end. Also the request CLOSE
- * (listener.c's cmd_close()) sends, with silent=1. */
+ /* also what CLOSE (listener.c's cmd_close()) sends, with silent=1 */
handle_mbox_expunge(&req, iev);
break;
}
case IMSG_MBOX_IDLE_REFRESH:
- /* No request payload -- see imapd.h's imsg_mbox_
- * idle_uid/imsg_mbox_idle_refreshed comment. handle_
- * mbox_idle_refresh() composes its own IMSG_MBOX_
- * IDLE_UID x N / IMSG_MBOX_IDLE_REFRESHED stream and
- * calls imsgev_add(iev) itself, same shape as every
- * other streaming handler above. */
+ /* no request payload -- see imapd.h's imsg_mbox_idle_uid comment */
handle_mbox_idle_refresh(iev);
break;
case IMSG_MBOX_APPEND: {
size_t bodylen;
char *body = NULL;
- /*
- * imsg_get_data() (used by every other case here)
- * requires an *exact* length match and can't be used
- * for a header-plus-variable-body imsg -- imsg_get_
- * buf() (sequential, no length check) plus imsg_get_
- * len() (bytes *remaining*, since it's ibuf_size() =
- * wpos - rpos) is the verified-against-real-imsg-
- * buffer.c pattern; see imapd.h's imsg_mbox_append
- * comment for the full citation.
- *
- * Any failure here (bad header, a msglen mismatch, a
- * malloc failure) is treated the same as every other
- * "bad IMSG_MBOX_*" case in this switch: logged and
- * dropped, no reply sent. That's a pre-existing
- * pattern, not a new gap -- every other case's
- * imsg_get_data() failure path does the same, on the
- * premise that a malformed imsg between listener and
- * store (as opposed to a client protocol error, which
- * never reaches here) means a build-time struct-
- * layout skew between the two binaries, not something
- * a client action can trigger.
- */
+ /* header-plus-variable-body: imsg_get_buf()+imsg_get_len(), see imapd.h's imsg_mbox_append */
if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
log_warnx("bad IMSG_MBOX_APPEND (header)");
break;
size_t bodylen;
struct search_node *nodes = NULL;
- /* Same header-plus-variable-body shape and the same
- * imsg_get_buf()/imsg_get_len() technique as
- * IMSG_MBOX_APPEND just above -- see imapd.h's
- * imsg_mbox_search comment. Trailing data here is an
- * array of struct search_node, not raw message bytes,
- * but the wire mechanics are identical. */
+ /* same header-plus-variable-body shape as APPEND, trailing struct search_node[] not raw bytes */
if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
log_warnx("bad IMSG_MBOX_SEARCH (header)");
break;
break;
}
case IMSG_MBOX_EXAMINE:
- /*
- * Permanently unreachable: EXAMINE reuses IMSG_MBOX_
- * SELECT wholesale (struct imsg_mbox_select's own
- * "readonly" field distinguishes them) -- see
- * select_or_examine() in listener.c. This enum value
- * predates that design decision and was never
- * removed.
- */
+ /* unreachable: EXAMINE reuses IMSG_MBOX_SELECT's "readonly" field, see listener.c */
log_debug("session %u: unimplemented mbox op %d",
session_id, imsg_get_type(&imsg));
break;
}
imsg_free(&imsg);
}
- /*
- * Real bug caught on first real-hardware run, same shape and same
- * fix as auth.c's auth_dispatch() (see its header comment for the
- * full citation against imsg_init(3)): every case above delegates
- * its own re-arm to whichever handle_mbox_*() function it calls
- * (or, for a couple of cases, none at all yet -- see the TODO
- * cases above), on the assumption this function always gets called
- * with something new to process. That assumption breaks once
- * EV_WRITE is actually handled (just above): a pure EV_WRITE
- * firing with nothing new to read runs no case at all, so nothing
- * re-arms this channel -- and since imsgev_init() registers plain
- * EV_READ, not EV_PERSIST, that silently drops store's only
- * connection to listener for good. Unconditional call here closes
- * that gap regardless of which path (or no path) was taken above.
- */
+ /* unconditional re-arm: imsgev_init() is EV_READ not EV_PERSIST, so a pure EV_WRITE call would drop the channel */
imsgev_add(iev);
(void)fd;
}
-/*
- * Per the design doc: IMSG_STORE_SHUTDOWN arrives directly from listener
- * over the peer channel, no round-trip through parent -- "parent isn't on
- * this path once wiring completes." Flush-then-exit; there's currently
- * nothing to flush (no open index/message fds are held across dispatch
- * calls in this skeleton), but the function exists as the one place
- * that'll need to change once there is.
- */
-static __dead void
+/* IMSG_STORE_SHUTDOWN arrives directly from listener, no round-trip through parent */
+__dead void
store_shutdown(void)
{
log_debug("session %u: store shutting down", session_id);
blob - /dev/null
blob + 057cc76fd1e66b7c12523384292371c4cdd5bba9 (mode 644)
--- /dev/null
+++ src/mailbox_cmd.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.
+ */
+
+/* mailbox_cmd.c -- SELECT/EXAMINE/CREATE/DELETE/RENAME/SUBSCRIBE/LIST/NAMESPACE/STATUS handlers. */
+
+#include <sys/types.h>
+#include <sys/queue.h>
+#include <sys/socket.h>
+
+#include <netinet/in.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <event.h>
+#include <imsg.h>
+#include <resolv.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <tls.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "listener.h"
+
+int
+parse_qresync_group(char *inner, struct imsg_mbox_select *req,
+ const char **errmsg)
+{
+ char *p;
+ const char *tok;
+ uint32_t uidvalidity;
+ uint64_t modseq;
+ char *ep;
+
+ p = inner;
+ while (*p == ' ')
+ p++;
+ tok = p;
+ while (*p != '\0' && *p != ' ')
+ p++;
+ if (*p != '\0') {
+ *p = '\0';
+ p++;
+ }
+ if (parse_nz_number(tok, &uidvalidity) == -1) {
+ *errmsg = "invalid QRESYNC uidvalidity";
+ return (-1);
+ }
+
+ while (*p == ' ')
+ p++;
+ tok = p;
+ while (*p != '\0' && *p != ' ')
+ p++;
+ if (*p != '\0') {
+ *p = '\0';
+ p++;
+ }
+ if (*tok == '\0') {
+ *errmsg = "QRESYNC requires uidvalidity and mod-sequence";
+ return (-1);
+ }
+ errno = 0;
+ modseq = strtoull(tok, &ep, 10);
+ if (*ep != '\0' || errno != 0) {
+ *errmsg = "invalid QRESYNC mod-sequence";
+ return (-1);
+ }
+
+ req->qresync_uidvalidity = uidvalidity;
+ req->qresync_modseq = modseq;
+ req->qresync_has_uids = 0;
+
+ while (*p == ' ')
+ p++;
+ if (*p == '\0')
+ return (0);
+
+ if (*p == '(') {
+ *errmsg = "QRESYNC seq-match-data requires known-uids first";
+ return (-1);
+ }
+
+ tok = p;
+ while (*p != '\0' && *p != ' ')
+ p++;
+ if (*p != '\0') {
+ *p = '\0';
+ p++;
+ }
+ if (strchr(tok, ',') != NULL) {
+ *errmsg = "comma-separated known-uids not supported in v1";
+ return (-1);
+ }
+ {
+ uint32_t lo, hi;
+ int lo_star, hi_star;
+
+ if (parse_seq_range(tok, &lo, &hi, &lo_star, &hi_star) == -1 ||
+ lo_star || hi_star) {
+ *errmsg = "invalid known-uids -- '*' is not allowed "
+ "here (RFC 7162 SS3.2.5.1)";
+ return (-1);
+ }
+ req->qresync_has_uids = 1;
+ req->qresync_uid_lo = lo;
+ req->qresync_uid_hi = hi;
+ }
+
+ while (*p == ' ')
+ p++;
+ if (*p == '\0')
+ return (0);
+
+ if (*p != '(') {
+ *errmsg = "expected seq-match-data";
+ return (-1);
+ }
+ {
+ char *end = p;
+ int depth = 0;
+
+ for (;;) {
+ if (*end == '(')
+ depth++;
+ else if (*end == ')') {
+ depth--;
+ if (depth == 0)
+ break;
+ } else if (*end == '\0') {
+ *errmsg = "unterminated seq-match-data";
+ return (-1);
+ }
+ end++;
+ }
+ p = end + 1;
+ }
+
+ while (*p == ' ')
+ p++;
+ if (*p != '\0') {
+ *errmsg = "unexpected data after QRESYNC arguments";
+ return (-1);
+ }
+
+ return (0);
+}
+
+/* SELECT/EXAMINE select-params (RFC 4466 + RFC 7162 SS3.1.8/SS3.2.5 CONDSTORE/QRESYNC); p modified in place */
+int
+parse_select_params(char *p, struct imsg_mbox_select *req,
+ const struct session *s, int *want_condstore, const char **errmsg)
+{
+ *want_condstore = 0;
+
+ while (*p != '\0') {
+ while (*p == ' ')
+ p++;
+ if (*p == '\0')
+ break;
+
+ if (strncasecmp(p, "CONDSTORE", 9) == 0 &&
+ (p[9] == '\0' || p[9] == ' ')) {
+ *want_condstore = 1;
+ p += 9;
+ continue;
+ }
+
+ if (strncasecmp(p, "QRESYNC", 7) == 0 &&
+ (p[7] == '\0' || p[7] == ' ')) {
+ char *q = p + 7;
+ char *end;
+ int depth;
+
+ while (*q == ' ')
+ q++;
+ if (*q != '(') {
+ *errmsg = "QRESYNC requires a parenthesized "
+ "argument list";
+ return (-1);
+ }
+ if (!s->qresync_enabled) {
+ *errmsg = "QRESYNC select-param requires "
+ "ENABLE QRESYNC first (RFC 7162 SS3.2.5)";
+ return (-1);
+ }
+
+ depth = 0;
+ end = q;
+ for (;;) {
+ if (*end == '(')
+ depth++;
+ else if (*end == ')') {
+ depth--;
+ if (depth == 0)
+ break;
+ } else if (*end == '\0') {
+ *errmsg = "unterminated QRESYNC "
+ "argument list";
+ return (-1);
+ }
+ end++;
+ }
+ *end = '\0';
+
+ if (parse_qresync_group(q + 1, req, errmsg) == -1)
+ return (-1);
+
+ req->qresync = 1;
+ *want_condstore = 1; /* SS3.2.3: QRESYNC implies CONDSTORE */
+ p = end + 1;
+ continue;
+ }
+
+ *errmsg = "unrecognized SELECT parameter";
+ return (-1);
+ }
+
+ return (0);
+}
+
+/* RFC 9051 SS6.3.2/SS6.3.3; shared by cmd_select()/cmd_examine(), only quoted-string mailbox form handled */
+static int
+select_or_examine(struct session *s, const char *tag, char *args, int readonly)
+{
+ struct imsg_mbox_select req;
+ char *params, *p;
+ size_t len;
+ int want_condstore = 0;
+ const char *cmdname = readonly ? "EXAMINE" : "SELECT";
+
+ if (args == NULL) {
+ char text[40];
+
+ snprintf(text, sizeof(text), "%s requires a mailbox name",
+ cmdname);
+ session_reply(s, tag, "BAD", text);
+ return (1);
+ }
+
+ p = args;
+ if (*p == '"') {
+ p++;
+ while (*p != '\0' && *p != '"')
+ p++;
+ if (*p == '"')
+ p++;
+ } else {
+ while (*p != '\0' && *p != ' ')
+ p++;
+ }
+ if (*p == ' ') {
+ *p = '\0';
+ p++;
+ while (*p == ' ')
+ p++;
+ params = (*p != '\0') ? p : NULL;
+ } else if (*p == '\0') {
+ params = NULL;
+ } else {
+ char text[40];
+
+ snprintf(text, sizeof(text), "malformed %s arguments", cmdname);
+ session_reply(s, tag, "BAD", text);
+ return (1);
+ }
+
+ len = strlen(args);
+ if (len >= 2 && args[0] == '"' && args[len - 1] == '"') {
+ args[len - 1] = '\0';
+ args++;
+ len -= 2;
+ }
+ if (len == 0) {
+ session_reply(s, tag, "BAD", "empty mailbox name");
+ return (1);
+ }
+ if (len >= sizeof(req.mailbox)) {
+ session_reply(s, tag, "BAD", "mailbox name too long");
+ return (1);
+ }
+
+ if (s->store_iev == NULL) {
+ /* should already be wired here -- ST_AUTH requires SESSION_AUTHENTICATED/SELECTED */
+ log_warnx("session %u: %s with no store channel wired",
+ s->id, cmdname);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ memset(&req, 0, sizeof(req));
+ if (strlcpy(req.mailbox, args, sizeof(req.mailbox)) >=
+ sizeof(req.mailbox)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ req.readonly = readonly;
+
+ if (params != NULL) {
+ size_t plen = strlen(params);
+ const char *errmsg = NULL;
+
+ if (plen < 2 || params[0] != '(' || params[plen - 1] != ')') {
+ session_reply(s, tag, "BAD",
+ "malformed select-param list");
+ return (1);
+ }
+ params[plen - 1] = '\0';
+
+ if (parse_select_params(params + 1, &req, s, &want_condstore,
+ &errmsg) == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+ }
+
+ /* RFC 7162 SS3.1.8/SS3.2.3: not via session_condstore_enable(), SELECT's own reply has HIGHESTMODSEQ */
+ if (want_condstore)
+ s->condstore_enabled = 1;
+
+ /* RFC 9051 SS6.3.2: SELECT auto-deselects any current mailbox with untagged OK [CLOSED] */
+ if (s->state == SESSION_SELECTED)
+ session_untagged(s, "OK [CLOSED] Previous mailbox is now closed");
+
+ if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag) ||
+ strlcpy(s->selected_mailbox, args, sizeof(s->selected_mailbox)) >=
+ sizeof(s->selected_mailbox)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->state = SESSION_SELECTING;
+ s->mbox_readonly = readonly;
+
+ if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_SELECT, 0, 0, -1,
+ &req, sizeof(req)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_SELECT", s->id);
+ imsgev_add(s->store_iev);
+
+ return (1);
+}
+
+
+int
+cmd_select(struct session *s, const char *tag, char *args)
+{
+ return select_or_examine(s, tag, args, 0);
+}
+
+
+int
+cmd_examine(struct session *s, const char *tag, char *args)
+{
+ 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 */
+int
+listener_mailbox_name_valid(const char *name)
+{
+ size_t i, len;
+
+ len = strlen(name);
+ if (len == 0 || len >= MBOX_NAME_MAX)
+ 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);
+
+ return (1);
+}
+
+
+int
+listener_mailbox_name_is_inbox(const char *name)
+{
+ return (strcasecmp(name, "INBOX") == 0);
+}
+
+/* RFC 9051 SS6.3.4 CREATE; "already exists" is store.c's call (mkdir(2) EEXIST, handle_mbox_create()) */
+int
+cmd_create(struct session *s, const char *tag, char *args)
+{
+ struct imsg_mbox_create req;
+ char mailbox[MBOX_NAME_MAX];
+ char *p;
+ const char *errmsg = NULL;
+
+ if (args == NULL) {
+ session_reply(s, tag, "BAD", "CREATE requires a mailbox name");
+ return (1);
+ }
+
+ p = args;
+ if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+
+ if (listener_mailbox_name_is_inbox(mailbox)) {
+ session_reply(s, tag, "NO", "[CANNOT] cannot create INBOX");
+ return (1);
+ }
+ if (!listener_mailbox_name_valid(mailbox)) {
+ session_reply(s, tag, "BAD", "invalid mailbox name");
+ return (1);
+ }
+
+ if (s->store_iev == NULL) {
+ log_warnx("session %u: CREATE with no store channel wired",
+ s->id);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ memset(&req, 0, sizeof(req));
+ if (strlcpy(req.mailbox, mailbox, sizeof(req.mailbox)) >=
+ sizeof(req.mailbox) ||
+ strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->mbox_op_prev_state = s->state;
+ s->state = SESSION_CREATING;
+
+ 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);
+ imsgev_add(s->store_iev);
+
+ return (1);
+}
+
+/* RFC 9051 SS6.3.5 DELETE; same split as CREATE, existence is store.c's call (handle_mbox_delete()) */
+int
+cmd_delete(struct session *s, const char *tag, char *args)
+{
+ struct imsg_mbox_delete req;
+ char mailbox[MBOX_NAME_MAX];
+ char *p;
+ const char *errmsg = NULL;
+
+ if (args == NULL) {
+ session_reply(s, tag, "BAD", "DELETE requires a mailbox name");
+ return (1);
+ }
+
+ p = args;
+ if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+
+ if (listener_mailbox_name_is_inbox(mailbox)) {
+ session_reply(s, tag, "NO", "[CANNOT] cannot delete INBOX");
+ return (1);
+ }
+ if (!listener_mailbox_name_valid(mailbox)) {
+ /* RFC 5530 NONEXISTENT: an invalid name can never have existed */
+ session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
+ return (1);
+ }
+
+ if (s->store_iev == NULL) {
+ log_warnx("session %u: DELETE with no store channel wired",
+ s->id);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ memset(&req, 0, sizeof(req));
+ if (strlcpy(req.mailbox, mailbox, sizeof(req.mailbox)) >=
+ sizeof(req.mailbox) ||
+ strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->mbox_op_prev_state = s->state;
+ s->state = SESSION_DELETING;
+
+ 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);
+ imsgev_add(s->store_iev);
+
+ return (1);
+}
+
+/* RFC 9051 SS6.3.6 RENAME; renaming *from* INBOX refused client-side, per the RFC's sanctioned carve-out */
+int
+cmd_rename(struct session *s, const char *tag, char *args)
+{
+ struct imsg_mbox_rename req;
+ char oldname[MBOX_NAME_MAX];
+ char newname[MBOX_NAME_MAX];
+ char *p;
+ const char *errmsg = NULL;
+
+ if (args == NULL) {
+ session_reply(s, tag, "BAD",
+ "RENAME requires two mailbox names");
+ return (1);
+ }
+
+ p = args;
+ if (parse_list_token(&p, oldname, sizeof(oldname), &errmsg) == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+ if (parse_list_token(&p, newname, sizeof(newname), &errmsg) == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+
+ if (listener_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_mailbox_name_valid(oldname)) {
+ session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
+ return (1);
+ }
+ if (listener_mailbox_name_is_inbox(newname) || !listener_mailbox_name_valid(newname)) {
+ session_reply(s, tag, "BAD", "invalid mailbox name");
+ return (1);
+ }
+
+ if (s->store_iev == NULL) {
+ log_warnx("session %u: RENAME with no store channel wired",
+ s->id);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ memset(&req, 0, sizeof(req));
+ if (strlcpy(req.oldname, oldname, sizeof(req.oldname)) >=
+ sizeof(req.oldname) ||
+ strlcpy(req.newname, newname, sizeof(req.newname)) >=
+ sizeof(req.newname) ||
+ strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag) ||
+ strlcpy(s->rename_oldname, oldname, sizeof(s->rename_oldname)) >=
+ sizeof(s->rename_oldname) ||
+ strlcpy(s->rename_newname, newname, sizeof(s->rename_newname)) >=
+ sizeof(s->rename_newname)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->mbox_op_prev_state = s->state;
+ s->state = SESSION_RENAMING;
+
+ 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);
+ imsgev_add(s->store_iev);
+
+ return (1);
+}
+
+
+int
+cmd_subscribe(struct session *s, const char *tag, char *args)
+{
+ (void)args;
+ return stub_not_implemented(s, tag, "SUBSCRIBE");
+}
+
+
+int
+cmd_unsubscribe(struct session *s, const char *tag, char *args)
+{
+ (void)args;
+ return stub_not_implemented(s, tag, "UNSUBSCRIBE");
+}
+
+/* RFC 9051 SS6.3.9 wildcards, collapsed to "zero or more of anything" -- v1's flat namespace has no delimiter */
+int
+list_pattern_match(const char *pat, const char *name, int ci)
+{
+ const char *p = pat;
+ const char *s = name;
+ const char *star_p = NULL; /* pat position just past the most recently seen wildcard run */
+ const char *star_s = NULL; /* name position that wildcard has absorbed through so far */
+
+ /* iterative two-pointer glob(3)-style match, O(n*m); avoids the exponential blowup of naive backtracking */
+ while (*s != '\0') {
+ if (*p == '*' || *p == '%') {
+ while (*p == '*' || *p == '%')
+ p++;
+ star_p = p;
+ star_s = s;
+ } else if (*p != '\0' && (ci ?
+ toupper((unsigned char)*p) == toupper((unsigned char)*s) :
+ *p == *s)) {
+ p++;
+ s++;
+ } else if (star_p != NULL) {
+ star_s++;
+ s = star_s;
+ p = star_p;
+ } else {
+ return (0);
+ }
+ }
+
+ while (*p == '*' || *p == '%')
+ p++;
+ return (*p == '\0');
+}
+
+/* pulls one list-mailbox-shaped token (RFC 9051 SS9) off *pp; "" is a legal zero-length token for LIST */
+int
+parse_list_token(char **pp, char *out, size_t outsize, const char **errmsg)
+{
+ char *p = *pp;
+
+ while (*p == ' ')
+ p++;
+
+ if (*p == '\0') {
+ *errmsg = "LIST requires two arguments";
+ return (-1);
+ }
+
+ if (*p == '"') {
+ const char *start = p + 1;
+ char *end = strchr(start, '"');
+ size_t len;
+
+ if (end == NULL) {
+ *errmsg = "unterminated quoted string";
+ return (-1);
+ }
+ len = (size_t)(end - start);
+ if (len >= outsize) {
+ *errmsg = "argument too long";
+ return (-1);
+ }
+ memcpy(out, start, len);
+ out[len] = '\0';
+ p = end + 1;
+ } else {
+ const char *start = p;
+ size_t len;
+
+ while (*p != '\0' && *p != ' ')
+ p++;
+ len = (size_t)(p - start);
+ if (len >= outsize) {
+ *errmsg = "argument too long";
+ return (-1);
+ }
+ memcpy(out, start, len);
+ out[len] = '\0';
+ }
+
+ *pp = p;
+ return (0);
+}
+
+/* RFC 9051 SS6.3.9 basic syntax only, no list-select/return-opts; LSUB shares this, is_lsub just varies output */
+static int
+list_dispatch(struct session *s, const char *tag, char *args, int is_lsub)
+{
+ char reference[MBOX_NAME_MAX];
+ char pattern[MBOX_NAME_MAX];
+ char canon[2 * MBOX_NAME_MAX];
+ char *p;
+ const char *errmsg;
+ const char *cmdname = is_lsub ? "LSUB" : "LIST";
+ const char *kw = is_lsub ? "LSUB" : "LIST";
+ char text[64];
+
+ if (args == NULL) {
+ snprintf(text, sizeof(text), "%s requires two arguments",
+ cmdname);
+ session_reply(s, tag, "BAD", text);
+ return (1);
+ }
+
+ p = args;
+ while (*p == ' ')
+ p++;
+
+ if (*p == '(') {
+ /* SS6.3.9 condition 1: first word after the command starts with "(" -- list-select-opts */
+ snprintf(text, sizeof(text),
+ "extended %s selection options not supported", cmdname);
+ session_reply(s, tag, "NO", text);
+ return (1);
+ }
+
+ if (parse_list_token(&p, reference, sizeof(reference), &errmsg) ==
+ -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+
+ while (*p == ' ')
+ p++;
+
+ if (*p == '(') {
+ /* SS6.3.9 condition 2: second word starts with "(" -- parenthesized `patterns` form */
+ snprintf(text, sizeof(text),
+ "extended %s mailbox-pattern lists not supported",
+ cmdname);
+ session_reply(s, tag, "NO", text);
+ return (1);
+ }
+
+ if (parse_list_token(&p, pattern, sizeof(pattern), &errmsg) == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+
+ while (*p == ' ')
+ p++;
+ if (*p != '\0') {
+ /* SS6.3.9 condition 3: more than 2 parameters -- trailing list-return-opts */
+ snprintf(text, sizeof(text),
+ "extended %s return options not supported", cmdname);
+ session_reply(s, tag, "NO", text);
+ return (1);
+ }
+
+ /* RFC 9051 SS6.3.9: empty pattern requests the delimiter/root; v1 has no hierarchy, always empty root */
+ if (pattern[0] == '\0') {
+ snprintf(text, sizeof(text), "%s (\\Noselect) \"/\" \"\"", kw);
+ session_untagged(s, text);
+ snprintf(text, sizeof(text), "%s completed", cmdname);
+ session_reply(s, tag, "OK", text);
+ return (1);
+ }
+
+ /* canonical LIST pattern: reference concatenated with the mailbox pattern (RFC 9051 SS6.3.9) */
+ {
+ size_t n;
+
+ n = strlcpy(canon, reference, sizeof(canon));
+ if (n < sizeof(canon))
+ n = strlcat(canon, pattern, sizeof(canon));
+ if (n >= sizeof(canon)) {
+ session_reply(s, tag, "BAD",
+ "combined reference and pattern too long");
+ return (1);
+ }
+ }
+
+ /* 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 */
+ snprintf(text, sizeof(text), "%s () \"/\" INBOX", kw);
+ session_untagged(s, text);
+ }
+
+ if (s->store_iev == NULL) {
+ log_warnx("session %u: %s with no store channel wired",
+ s->id, cmdname);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ /* real mailbox names live on disk; IMSG_MBOX_LIST has no payload, s->list_pattern is tested per streamed name */
+ if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag) ||
+ strlcpy(s->list_pattern, canon, sizeof(s->list_pattern)) >=
+ sizeof(s->list_pattern)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->list_is_lsub = is_lsub;
+ s->mbox_op_prev_state = s->state;
+ s->state = SESSION_LISTING;
+
+ 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);
+ imsgev_add(s->store_iev);
+
+ return (1);
+}
+
+
+int
+cmd_list(struct session *s, const char *tag, char *args)
+{
+ return list_dispatch(s, tag, args, 0);
+}
+
+
+int
+cmd_lsub(struct session *s, const char *tag, char *args)
+{
+ return list_dispatch(s, tag, args, 1);
+}
+
+/* RFC 9051 SS6.3.10 NAMESPACE, answered locally: single Personal Namespace, no prefix, "/" delimiter */
+int
+cmd_namespace(struct session *s, const char *tag, char *args)
+{
+ (void)args;
+ session_untagged(s, "NAMESPACE ((\"\" \"/\")) NIL NIL");
+ session_reply(s, tag, "OK", "NAMESPACE command completed");
+ return (1);
+}
+
+/* RFC 9051 SS6.3.11 STATUS; doesn't change the selected mailbox, SESSION_STATUSING is a transient async wait */
+int
+cmd_status(struct session *s, const char *tag, char *args)
+{
+ struct imsg_mbox_status req;
+ char mailbox[MBOX_NAME_MAX];
+ char *p, *atts, *tok, *save;
+ char attbuf[256];
+ const char *errmsg = NULL;
+ uint32_t attrs = 0;
+ size_t plen;
+
+ if (args == NULL) {
+ session_reply(s, tag, "BAD",
+ "STATUS requires a mailbox name and status-att list");
+ return (1);
+ }
+
+ p = args;
+ if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+
+ while (*p == ' ')
+ p++;
+ atts = p;
+ plen = strlen(atts);
+ if (plen < 2 || atts[0] != '(' || atts[plen - 1] != ')') {
+ session_reply(s, tag, "BAD",
+ "STATUS requires a parenthesized status-att list");
+ return (1);
+ }
+ atts[plen - 1] = '\0';
+ atts++;
+
+ if (strlcpy(attbuf, atts, sizeof(attbuf)) >= sizeof(attbuf)) {
+ session_reply(s, tag, "BAD", "status-att list too long");
+ return (1);
+ }
+ for (tok = strtok_r(attbuf, " ", &save); tok != NULL;
+ tok = strtok_r(NULL, " ", &save)) {
+ if (strcasecmp(tok, "MESSAGES") == 0)
+ attrs |= STATUS_ATT_MESSAGES;
+ else if (strcasecmp(tok, "UIDNEXT") == 0)
+ attrs |= STATUS_ATT_UIDNEXT;
+ else if (strcasecmp(tok, "UIDVALIDITY") == 0)
+ attrs |= STATUS_ATT_UIDVALIDITY;
+ else if (strcasecmp(tok, "UNSEEN") == 0)
+ attrs |= STATUS_ATT_UNSEEN;
+ else if (strcasecmp(tok, "DELETED") == 0)
+ attrs |= STATUS_ATT_DELETED;
+ else if (strcasecmp(tok, "SIZE") == 0)
+ attrs |= STATUS_ATT_SIZE;
+ 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. */
+ attrs |= STATUS_ATT_RECENT;
+ else {
+ session_reply(s, tag, "BAD", "unknown status-att");
+ return (1);
+ }
+ }
+
+ /* RFC 9051 SS9: request-side status-att-list requires at least one status-att, unlike the response side */
+ if (attrs == 0) {
+ session_reply(s, tag, "BAD",
+ "STATUS requires at least one status-att");
+ return (1);
+ }
+
+ if (!listener_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);
+ }
+
+ if (s->store_iev == NULL) {
+ log_warnx("session %u: STATUS with no store channel wired",
+ s->id);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ /* RFC 7162 SS3.1: STATUS (HIGHESTMODSEQ) is CONDSTORE-enabling; called before s->state is overwritten */
+ if (attrs & STATUS_ATT_HIGHESTMODSEQ)
+ session_condstore_enable(s);
+
+ memset(&req, 0, sizeof(req));
+ if (strlcpy(req.mailbox, mailbox, sizeof(req.mailbox)) >=
+ sizeof(req.mailbox) ||
+ strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag) ||
+ strlcpy(s->status_mailbox, mailbox, sizeof(s->status_mailbox)) >=
+ sizeof(s->status_mailbox)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ req.attrs = attrs;
+ s->status_attrs = attrs;
+ s->status_prev_state = s->state;
+ s->state = SESSION_STATUSING;
+
+ 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);
+ imsgev_add(s->store_iev);
+
+ return (1);
+}
+
+/* v1's whole-message-in-one-imsg design caps literal size to fit MAX_IMSGSIZE (16384); also read-side cap */
blob - /dev/null
blob + a84ea914205d6836057a543fd1d357b47cb41d6f (mode 644)
--- /dev/null
+++ src/mbox_copy.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.
+ */
+
+/*
+ * mbox_copy.c -- COPY and MOVE request handling, both
+ * same-mailbox and cross-mailbox.
+ */
+
+#include <sys/types.h>
+#include <sys/file.h>
+#include <sys/stat.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <event.h>
+#include <fcntl.h>
+#include <imsg.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "store_internal.h"
+
+static int
+resolve_mailbox_target(const char *name, char *target, size_t targetlen)
+{
+ if (mailbox_name_is_inbox(name)) {
+ target[0] = '\0';
+ return (0);
+ }
+ if (mailbox_name_valid(name)) {
+ if (strlcpy(target, name, targetlen) >= targetlen)
+ return (-1);
+ return (0);
+ }
+ return (-1);
+}
+
+/* Pass 1 of COPY/MOVE: stages messages read-only; 0 return means partial failure, nothing written yet (RFC 9051 SS6.4.7). */
+
+static int
+stage_copy_messages(struct mbox_index *idx, struct imsg_mbox_copy *req,
+ struct copy_staged **staged_out, size_t *nstaged_out)
+{
+ struct copy_staged *staged = NULL;
+ size_t nstaged = 0, stagedcap = 0, i;
+ uint64_t staged_total = 0; /* bytes staged so far */
+ uint32_t lo, hi;
+
+ lo = hi = 0;
+ if (req->by_uid) {
+ uint32_t max_uid = index_max_uid(idx);
+
+ lo = req->lo_is_star ? max_uid : req->seq_lo;
+ hi = req->hi_is_star ? max_uid : req->seq_hi;
+ if (lo < 1)
+ lo = 1;
+ } else {
+ lo = req->seq_lo;
+ hi = req->hi_is_star ? (uint32_t)idx->nlines : req->seq_hi;
+ }
+
+ for (i = 0; i < idx->nlines; i++) {
+ struct index_rec rec;
+ const char *lp;
+ char suffix[64];
+ off_t size;
+ char path[600];
+ int srcfd;
+ struct copy_staged cs;
+
+ if (index_parse_line(idx->lines[i], &rec) == -1)
+ continue;
+
+ if (req->by_uid) {
+ if (rec.uid < lo)
+ continue;
+ if (rec.uid > hi)
+ break;
+ } else {
+ if (i + 1 < lo)
+ continue;
+ if (i + 1 > hi)
+ break;
+ }
+
+ if (locate_message_file(rec.basename, &size, suffix,
+ sizeof(suffix)) == -1) {
+ log_warnx("session %u: COPY: message %s indexed but "
+ "missing on disk -- failing whole COPY (partial "
+ "copy not permitted, RFC 9051 SS6.4.7)",
+ session_id, rec.basename);
+ goto fail;
+ }
+
+ /* refuse before allocating if this message or the running total exceeds the staging limits */
+ if ((uint64_t)size > COPY_STAGE_MSG_MAX) {
+ log_warnx("session %u: COPY: message %s is %lld bytes, "
+ "over the per-message staging limit -- failing COPY",
+ session_id, rec.basename, (long long)size);
+ goto fail;
+ }
+ if ((uint64_t)size > COPY_STAGE_TOTAL_MAX - staged_total) {
+ log_warnx("session %u: COPY: staged data would exceed the "
+ "total staging limit -- failing COPY", session_id);
+ goto fail;
+ }
+ staged_total += (uint64_t)size;
+
+ memset(&cs, 0, sizeof(cs));
+ cs.src_uid = rec.uid;
+ if (strlcpy(cs.keywords, rec.keywords, sizeof(cs.keywords)) >=
+ sizeof(cs.keywords)) {
+ log_warnx("session %u: COPY: keywords too long for "
+ "%s -- failing COPY", session_id, rec.basename);
+ goto fail;
+ }
+ lp = strstr(suffix, "2,");
+ cs.sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
+
+ if (snprintf(cs.basename, sizeof(cs.basename),
+ "%lld.%d_%u.%s",
+ (long long)parse_maildir_timestamp(rec.basename),
+ (int)getpid(), append_counter++, append_hostname()) >=
+ (int)sizeof(cs.basename)) {
+ log_warnx("session %u: COPY: generated basename too "
+ "long", session_id);
+ goto fail;
+ }
+
+ if (snprintf(path, sizeof(path), "%s/%s%s",
+ suffix[0] == '\0' ? "new" : "cur", rec.basename, suffix)
+ >= (int)sizeof(path)) {
+ log_warnx("session %u: COPY: source path too long "
+ "for %s", session_id, rec.basename);
+ goto fail;
+ }
+ if ((srcfd = open(path, O_RDONLY)) == -1) {
+ log_warn("session %u: COPY: open %s", session_id,
+ path);
+ goto fail;
+ }
+ cs.bodylen = (size_t)size;
+ if (cs.bodylen > 0 && (cs.body = malloc(cs.bodylen)) == NULL) {
+ log_warn("session %u: COPY: malloc %zu bytes",
+ session_id, cs.bodylen);
+ close(srcfd);
+ goto fail;
+ }
+ {
+ size_t rd = 0;
+
+ while (rd < cs.bodylen) {
+ ssize_t n = read(srcfd,
+ (char *)cs.body + rd, cs.bodylen - rd);
+ if (n == -1) {
+ if (errno == EINTR)
+ continue;
+ log_warn("session %u: COPY: read %s",
+ session_id, path);
+ close(srcfd);
+ free(cs.body);
+ goto fail;
+ }
+ if (n == 0)
+ break; /* short file; copy what we got */
+ rd += (size_t)n;
+ }
+ cs.bodylen = rd;
+ }
+ close(srcfd);
+
+ if (nstaged == stagedcap) {
+ size_t newcap = (stagedcap == 0) ? 8 :
+ stagedcap * 2;
+ struct copy_staged *newstaged = reallocarray(staged,
+ newcap, sizeof(*staged));
+
+ if (newstaged == NULL) {
+ log_warn("session %u: COPY: reallocarray "
+ "staged", session_id);
+ free(cs.body);
+ goto fail;
+ }
+ staged = newstaged;
+ stagedcap = newcap;
+ }
+ staged[nstaged++] = cs;
+ }
+
+ *staged_out = staged;
+ *nstaged_out = nstaged;
+ return (1);
+
+fail:
+ for (i = 0; i < nstaged; i++)
+ free(staged[i].body);
+ free(staged);
+ *staged_out = NULL;
+ *nstaged_out = 0;
+ return (0);
+}
+
+/* Pass 2+3: commits staged messages via tmp/ write + rename into cur/, then index_append(); no rollback of already-renamed files on failure. */
+
+static int
+commit_copy_messages(struct mbox_index *destidx, struct copy_staged *staged,
+ size_t nstaged, struct imsgev *iev)
+{
+ size_t i;
+
+ for (i = 0; i < nstaged; i++) {
+ char tmppath[300], curpath[320];
+ char letters[8];
+ int tmpfd;
+
+ if (snprintf(tmppath, sizeof(tmppath), "tmp/%s",
+ staged[i].basename) >= (int)sizeof(tmppath)) {
+ log_warnx("session %u: COPY: tmp path too long",
+ session_id);
+ return (0);
+ }
+ if ((tmpfd = open(tmppath, O_WRONLY | O_CREAT | O_EXCL,
+ 0600)) == -1) {
+ log_warn("session %u: COPY: open %s", session_id,
+ tmppath);
+ return (0);
+ }
+ {
+ size_t written = 0;
+
+ while (written < staged[i].bodylen) {
+ ssize_t n = write(tmpfd,
+ (char *)staged[i].body + written,
+ staged[i].bodylen - written);
+ if (n == -1) {
+ if (errno == EINTR)
+ continue;
+ log_warn("session %u: COPY: write %s",
+ session_id, tmppath);
+ close(tmpfd);
+ unlink(tmppath);
+ return (0);
+ }
+ written += (size_t)n;
+ }
+ }
+ if (fsync(tmpfd) == -1)
+ log_warn("session %u: COPY: fsync %s (continuing)",
+ session_id, tmppath);
+ close(tmpfd);
+
+ sysflags_to_letters(staged[i].sysflags, letters,
+ sizeof(letters));
+ if (snprintf(curpath, sizeof(curpath), "cur/%s:2,%s",
+ staged[i].basename, letters) >= (int)sizeof(curpath)) {
+ log_warnx("session %u: COPY: cur path too long",
+ session_id);
+ unlink(tmppath);
+ return (0);
+ }
+ if (rename(tmppath, curpath) == -1) {
+ log_warn("session %u: COPY: rename %s -> %s",
+ session_id, tmppath, curpath);
+ unlink(tmppath);
+ return (0);
+ }
+ }
+
+ for (i = 0; i < nstaged; i++) {
+ uint32_t dest_uid = destidx->uidnext;
+
+ if (index_append(destidx, dest_uid, staged[i].basename) ==
+ -1)
+ return (0);
+ destidx->uidnext++;
+ staged[i].dest_uid = dest_uid;
+
+ if (staged[i].keywords[0] != '\0') {
+ char line[STORE_INDEX_LINE_MAX];
+ int n;
+
+ n = snprintf(line, sizeof(line), "%u:%s:%s:%llu",
+ dest_uid, staged[i].basename, staged[i].keywords,
+ (unsigned long long)destidx->highestmodseq);
+ if (n < 0 || (size_t)n >= sizeof(line)) {
+ log_warnx("session %u: COPY: index line too "
+ "long", session_id);
+ return (0);
+ }
+ free(destidx->lines[destidx->nlines - 1]);
+ if ((destidx->lines[destidx->nlines - 1] =
+ strdup(line)) == NULL) {
+ log_warn("session %u: COPY: strdup index "
+ "line", session_id);
+ return (0);
+ }
+ }
+ }
+
+ if (index_save(destidx) == -1)
+ return (0);
+
+ for (i = 0; i < nstaged; i++) {
+ struct imsg_mbox_copy_mapping mapping;
+
+ memset(&mapping, 0, sizeof(mapping));
+ mapping.src_uid = staged[i].src_uid;
+ mapping.dest_uid = staged[i].dest_uid;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_COPY_MAPPING, 0, 0, -1,
+ &mapping, sizeof(mapping)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_COPY_MAPPING", session_id);
+ }
+
+ return (1);
+}
+
+/* RFC 9051 SS6.4.7 COPY; if dest != SELECTed mailbox, both indexes are locked in strcmp() name order to avoid AB-BA deadlock. */
+
+void
+handle_mbox_copy(struct imsg_mbox_copy *req, struct imsgev *iev)
+{
+ struct mbox_index idx_a, idx_b;
+ struct mbox_index *srcidx = NULL, *destidx = NULL;
+ struct imsg_mbox_result result;
+ struct copy_staged *staged = NULL;
+ size_t nstaged = 0, i;
+ int fd_a = -1, fd_a_locked = 0;
+ int fd_b = -1, fd_b_locked = 0;
+ int ok = 1;
+ char saved[MBOX_NAME_MAX];
+ char desttarget[MBOX_NAME_MAX];
+ int cross_mailbox;
+
+ memset(&idx_a, 0, sizeof(idx_a));
+ memset(&idx_b, 0, sizeof(idx_b));
+ memset(&result, 0, sizeof(result));
+
+ if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
+ sizeof(saved)) {
+ log_warnx("session %u: COPY: current_mailbox_dir truncated -- "
+ "can't happen (same-size buffers)", session_id);
+ ok = 0;
+ goto done;
+ }
+
+ if (resolve_mailbox_target(req->destname, desttarget,
+ sizeof(desttarget)) == -1) {
+ log_debug("session %u: COPY %s: invalid destination mailbox "
+ "name", session_id, req->destname);
+ result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+ ok = 0;
+ goto done;
+ }
+ cross_mailbox = (strcmp(saved, desttarget) != 0);
+
+ if (!cross_mailbox) {
+ if ((fd_a = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
+ -1) {
+ log_warn("session %u: open %s", session_id,
+ STORE_INDEX_NAME);
+ ok = 0;
+ goto done;
+ }
+ if (flock(fd_a, LOCK_EX) == -1) {
+ log_warn("session %u: flock %s", session_id,
+ STORE_INDEX_NAME);
+ ok = 0;
+ goto done;
+ }
+ fd_a_locked = 1;
+ if (index_load(fd_a, &idx_a) == -1) {
+ ok = 0;
+ goto done;
+ }
+ srcidx = &idx_a;
+ destidx = &idx_a;
+ } else {
+ char first[MBOX_NAME_MAX], second[MBOX_NAME_MAX];
+ int first_is_dest;
+
+ if (strcmp(saved, desttarget) <= 0) {
+ if (strlcpy(first, saved, sizeof(first)) >=
+ sizeof(first) ||
+ strlcpy(second, desttarget, sizeof(second)) >=
+ sizeof(second)) {
+ log_warnx("session %u: COPY: mailbox name "
+ "truncated -- can't happen (same-size "
+ "buffers)", session_id);
+ ok = 0;
+ goto done;
+ }
+ first_is_dest = 0;
+ } else {
+ if (strlcpy(first, desttarget, sizeof(first)) >=
+ sizeof(first) ||
+ strlcpy(second, saved, sizeof(second)) >=
+ sizeof(second)) {
+ log_warnx("session %u: COPY: mailbox name "
+ "truncated -- can't happen (same-size "
+ "buffers)", session_id);
+ ok = 0;
+ goto done;
+ }
+ first_is_dest = 1;
+ }
+
+ if (select_mailbox_dir(first) == -1) {
+ if (first_is_dest)
+ result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+ else
+ log_warnx("session %u: COPY: couldn't reach "
+ "%s", session_id, first);
+ ok = 0;
+ goto done;
+ }
+ if ((fd_a = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
+ -1) {
+ log_warn("session %u: open %s", session_id,
+ STORE_INDEX_NAME);
+ ok = 0;
+ goto restore_saved;
+ }
+ if (flock(fd_a, LOCK_EX) == -1) {
+ log_warn("session %u: flock %s", session_id,
+ STORE_INDEX_NAME);
+ ok = 0;
+ goto restore_saved;
+ }
+ fd_a_locked = 1;
+ if (index_load(fd_a, &idx_a) == -1) {
+ ok = 0;
+ goto restore_saved;
+ }
+
+ if (select_mailbox_dir(second) == -1) {
+ if (!first_is_dest)
+ result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+ else
+ log_warnx("session %u: COPY: couldn't reach "
+ "%s", session_id, second);
+ ok = 0;
+ goto restore_saved;
+ }
+ if ((fd_b = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
+ -1) {
+ log_warn("session %u: open %s", session_id,
+ STORE_INDEX_NAME);
+ ok = 0;
+ goto restore_saved;
+ }
+ if (flock(fd_b, LOCK_EX) == -1) {
+ log_warn("session %u: flock %s", session_id,
+ STORE_INDEX_NAME);
+ ok = 0;
+ goto restore_saved;
+ }
+ fd_b_locked = 1;
+ if (index_load(fd_b, &idx_b) == -1) {
+ ok = 0;
+ goto restore_saved;
+ }
+
+ srcidx = first_is_dest ? &idx_b : &idx_a;
+ destidx = first_is_dest ? &idx_a : &idx_b;
+
+ /* Back to source -- staging below reads relative to cwd. */
+ if (select_mailbox_dir(saved) == -1) {
+ log_warnx("session %u: COPY: couldn't return to %s "
+ "to stage messages", session_id, saved);
+ ok = 0;
+ goto restore_saved;
+ }
+ }
+
+ if (!stage_copy_messages(srcidx, req, &staged, &nstaged)) {
+ ok = 0;
+ goto restore_saved;
+ }
+
+ if (nstaged > 0) {
+ if (cross_mailbox && select_mailbox_dir(desttarget) == -1) {
+ log_warnx("session %u: COPY: couldn't reach "
+ "destination %s", session_id, desttarget);
+ ok = 0;
+ goto restore_saved;
+ }
+ if (ensure_maildir_dirs("") == -1) {
+ ok = 0;
+ goto restore_saved;
+ }
+ if (!commit_copy_messages(destidx, staged, nstaged, iev))
+ ok = 0;
+ }
+
+ if (ok) {
+ result.count = (uint32_t)nstaged;
+ result.uidvalidity = destidx->uidvalidity;
+ result.highestmodseq = destidx->highestmodseq;
+ }
+
+restore_saved:
+ if (cross_mailbox && select_mailbox_dir(saved) == -1)
+ log_warnx("session %u: COPY: couldn't restore previously "
+ "selected mailbox %s", session_id, saved);
+
+done:
+ for (i = 0; i < nstaged; i++)
+ free(staged[i].body);
+ free(staged);
+
+ if (fd_a_locked)
+ flock(fd_a, LOCK_UN);
+ if (fd_a != -1)
+ close(fd_a);
+ if (fd_b_locked)
+ flock(fd_b, LOCK_UN);
+ if (fd_b != -1)
+ close(fd_b);
+
+ if (result.error != MBOX_OP_ERR_NO_SUCH_MAILBOX)
+ result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
+ index_free(&idx_a);
+ index_free(&idx_b);
+
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+ sizeof(result)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+ session_id);
+ imsgev_add(iev);
+}
+
+/* MOVE within same mailbox: range membership below must test "in" (read pos), not "out" (compaction write index) -- "out" under-consumed the range. */
+
+static int
+move_same_mailbox(struct imsg_mbox_copy *req, struct mbox_index *idx,
+ uint32_t *nmoved_out, struct imsgev *iev)
+{
+ struct moved {
+ uint32_t old_uid;
+ uint32_t old_seqno;
+ uint32_t dest_uid;
+ char basename[512];
+ char keywords[512];
+ };
+
+ struct moved *moved = NULL;
+ size_t nmoved = 0, movedcap = 0, in, out, i;
+ uint32_t lo, hi;
+ int ok = 1;
+
+ *nmoved_out = 0;
+
+ lo = hi = 0;
+ if (req->by_uid) {
+ uint32_t max_uid = index_max_uid(idx);
+
+ lo = req->lo_is_star ? max_uid : req->seq_lo;
+ hi = req->hi_is_star ? max_uid : req->seq_hi;
+ if (lo < 1)
+ lo = 1;
+ } else {
+ lo = req->seq_lo;
+ hi = req->hi_is_star ? (uint32_t)idx->nlines : req->seq_hi;
+ }
+
+ out = 0;
+ for (in = 0; in < idx->nlines; in++) {
+ struct index_rec rec;
+ int matched;
+
+ if (index_parse_line(idx->lines[in], &rec) == -1) {
+ idx->lines[out++] = idx->lines[in];
+ continue;
+ }
+
+ if (req->by_uid)
+ matched = (rec.uid >= lo && rec.uid <= hi);
+ else
+ matched = ((uint32_t)(in + 1) >= lo &&
+ (uint32_t)(in + 1) <= hi);
+
+ if (!matched) {
+ idx->lines[out++] = idx->lines[in];
+ continue;
+ }
+
+ if (nmoved == movedcap) {
+ size_t newcap = (movedcap == 0) ? 8 :
+ movedcap * 2;
+ struct moved *newmoved = reallocarray(moved, newcap,
+ sizeof(*moved));
+
+ if (newmoved == NULL) {
+ log_warn("session %u: MOVE: reallocarray "
+ "moved", session_id);
+ free(idx->lines[in]);
+ ok = 0;
+ goto done;
+ }
+ moved = newmoved;
+ movedcap = newcap;
+ }
+ moved[nmoved].old_uid = rec.uid;
+ moved[nmoved].old_seqno = (uint32_t)(out + 1);
+ if (strlcpy(moved[nmoved].basename, rec.basename,
+ sizeof(moved[nmoved].basename)) >=
+ sizeof(moved[nmoved].basename) ||
+ strlcpy(moved[nmoved].keywords, rec.keywords,
+ sizeof(moved[nmoved].keywords)) >=
+ sizeof(moved[nmoved].keywords)) {
+ log_warnx("session %u: MOVE: basename/keywords too "
+ "long for %s -- failing MOVE", session_id,
+ rec.basename);
+ free(idx->lines[in]);
+ ok = 0;
+ goto done;
+ }
+ nmoved++;
+
+ free(idx->lines[in]);
+ }
+ idx->nlines = out;
+
+ for (i = 0; i < nmoved; i++) {
+ uint32_t dest_uid = idx->uidnext;
+
+ if (index_append(idx, dest_uid, moved[i].basename) == -1) {
+ ok = 0;
+ goto done;
+ }
+ idx->uidnext++;
+ moved[i].dest_uid = dest_uid;
+
+ if (moved[i].keywords[0] != '\0') {
+ char line[STORE_INDEX_LINE_MAX];
+ int n;
+
+ n = snprintf(line, sizeof(line), "%u:%s:%s:%llu",
+ dest_uid, moved[i].basename, moved[i].keywords,
+ (unsigned long long)idx->highestmodseq);
+ if (n < 0 || (size_t)n >= sizeof(line)) {
+ log_warnx("session %u: MOVE: index line too "
+ "long", session_id);
+ ok = 0;
+ goto done;
+ }
+ free(idx->lines[idx->nlines - 1]);
+ if ((idx->lines[idx->nlines - 1] = strdup(line)) ==
+ NULL) {
+ log_warn("session %u: MOVE: strdup index "
+ "line", session_id);
+ ok = 0;
+ goto done;
+ }
+ }
+ }
+
+ if (index_save(idx) == -1) {
+ ok = 0;
+ goto done;
+ }
+
+ /* COPYUID mapping data first... */
+ for (i = 0; i < nmoved; i++) {
+ struct imsg_mbox_copy_mapping mapping;
+
+ memset(&mapping, 0, sizeof(mapping));
+ mapping.src_uid = moved[i].old_uid;
+ mapping.dest_uid = moved[i].dest_uid;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_COPY_MAPPING, 0, 0, -1,
+ &mapping, sizeof(mapping)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_COPY_MAPPING", session_id);
+ }
+ /* ...then EXPUNGE notices for the old UIDs/seqnos, per SS6.4.8's required ordering. */
+ for (i = 0; i < nmoved; i++) {
+ struct imsg_mbox_expunged exp;
+
+ memset(&exp, 0, sizeof(exp));
+ exp.seqno = moved[i].old_seqno;
+ exp.uid = moved[i].old_uid;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0, -1,
+ &exp, sizeof(exp)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_EXPUNGED",
+ session_id);
+ }
+
+done:
+ *nmoved_out = (uint32_t)nmoved;
+ free(moved);
+ return (ok);
+}
+
+/* Cross-mailbox MOVE (SS6.4.8): dest commit completes before source removal, so a crash can duplicate a message but never lose one. */
+
+static int
+move_cross_mailbox(struct imsg_mbox_copy *req, struct mbox_index *srcidx,
+ struct mbox_index *destidx, const char *desttarget, const char *saved,
+ uint32_t *nmoved_out, struct imsgev *iev)
+{
+ struct copy_staged *staged = NULL;
+ size_t nstaged = 0, i;
+ int ok = 1, any_removed = 0;
+
+ *nmoved_out = 0;
+
+ if (!stage_copy_messages(srcidx, req, &staged, &nstaged))
+ return (0);
+
+ if (nstaged == 0)
+ return (1);
+
+ if (select_mailbox_dir(desttarget) == -1) {
+ log_warnx("session %u: MOVE: couldn't reach destination %s",
+ session_id, desttarget);
+ ok = 0;
+ goto cleanup;
+ }
+ if (ensure_maildir_dirs("") == -1) {
+ ok = 0;
+ goto cleanup;
+ }
+ if (!commit_copy_messages(destidx, staged, nstaged, iev)) {
+ ok = 0;
+ goto cleanup;
+ }
+
+ /* destination commit is durable -- now remove originals from source; cwd is at destination, return to saved dir first */
+ if (select_mailbox_dir(saved) == -1) {
+ log_warnx("session %u: MOVE: committed to destination but "
+ "couldn't return to source %s to remove the originals "
+ "-- message(s) now duplicated in both mailboxes rather "
+ "than moved", session_id, saved);
+ ok = 0;
+ goto cleanup;
+ }
+
+ for (i = 0; i < nstaged; i++) {
+ struct index_rec rec;
+ size_t j, out;
+ uint32_t old_seqno = 0;
+ int found = 0;
+ off_t size;
+ char suffix[64], path[600];
+
+ /* fresh scan per message so old_seqno reflects state after each earlier removal */
+ for (j = 0, out = 0; j < srcidx->nlines; j++) {
+ if (!found && index_parse_line(srcidx->lines[j],
+ &rec) == 0 && rec.uid == staged[i].src_uid) {
+ old_seqno = (uint32_t)(out + 1);
+ found = 1;
+ free(srcidx->lines[j]);
+ continue;
+ }
+ srcidx->lines[out++] = srcidx->lines[j];
+ }
+ srcidx->nlines = out;
+
+ if (!found) {
+ /* raced away by another session; dest copy stands */
+ continue;
+ }
+ any_removed = 1;
+
+ if (locate_message_file(rec.basename, &size, suffix,
+ sizeof(suffix)) == 0) {
+ if (snprintf(path, sizeof(path), "%s/%s%s",
+ suffix[0] == '\0' ? "new" : "cur", rec.basename,
+ suffix) < (int)sizeof(path))
+ unlink(path);
+ }
+
+ {
+ struct imsg_mbox_expunged exp;
+
+ memset(&exp, 0, sizeof(exp));
+ exp.seqno = old_seqno;
+ exp.uid = staged[i].src_uid;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0,
+ -1, &exp, sizeof(exp)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_EXPUNGED", session_id);
+ }
+ }
+
+ /* RFC 7162 SS3.1: single per-operation HIGHESTMODSEQ bump, same as handle_mbox_expunge() */
+ if (any_removed)
+ srcidx->highestmodseq++;
+
+ if (index_save(srcidx) == -1) {
+ log_warnx("session %u: MOVE: destination commit succeeded "
+ "but saving the source's compacted index failed -- "
+ "message(s) may remain duplicated", session_id);
+ ok = 0;
+ goto cleanup;
+ }
+
+ *nmoved_out = (uint32_t)nstaged;
+
+cleanup:
+ for (i = 0; i < nstaged; i++)
+ free(staged[i].body);
+ free(staged);
+ return (ok);
+}
+
+/* RFC 9051 SS6.4.8 MOVE dispatcher: locks index file(s) like handle_mbox_copy(), then hands off to the same/cross-mailbox helpers above. */
+
+void
+handle_mbox_move(struct imsg_mbox_copy *req, struct imsgev *iev)
+{
+ struct mbox_index idx_a, idx_b;
+ struct mbox_index *srcidx = NULL, *destidx = NULL;
+ struct imsg_mbox_result result;
+ uint32_t nmoved = 0;
+ int fd_a = -1, fd_a_locked = 0;
+ int fd_b = -1, fd_b_locked = 0;
+ int ok = 1;
+ char saved[MBOX_NAME_MAX];
+ char desttarget[MBOX_NAME_MAX];
+ int cross_mailbox;
+
+ memset(&idx_a, 0, sizeof(idx_a));
+ memset(&idx_b, 0, sizeof(idx_b));
+ memset(&result, 0, sizeof(result));
+
+ if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
+ sizeof(saved)) {
+ log_warnx("session %u: MOVE: current_mailbox_dir truncated -- "
+ "can't happen (same-size buffers)", session_id);
+ ok = 0;
+ goto done;
+ }
+
+ if (resolve_mailbox_target(req->destname, desttarget,
+ sizeof(desttarget)) == -1) {
+ log_debug("session %u: MOVE %s: invalid destination mailbox "
+ "name", session_id, req->destname);
+ result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+ ok = 0;
+ goto done;
+ }
+ cross_mailbox = (strcmp(saved, desttarget) != 0);
+
+ if (!cross_mailbox) {
+ if ((fd_a = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
+ -1) {
+ log_warn("session %u: open %s", session_id,
+ STORE_INDEX_NAME);
+ ok = 0;
+ goto done;
+ }
+ if (flock(fd_a, LOCK_EX) == -1) {
+ log_warn("session %u: flock %s", session_id,
+ STORE_INDEX_NAME);
+ ok = 0;
+ goto done;
+ }
+ fd_a_locked = 1;
+ if (index_load(fd_a, &idx_a) == -1) {
+ ok = 0;
+ goto done;
+ }
+ srcidx = &idx_a;
+ destidx = &idx_a;
+ } else {
+ char first[MBOX_NAME_MAX], second[MBOX_NAME_MAX];
+ int first_is_dest;
+
+ if (strcmp(saved, desttarget) <= 0) {
+ if (strlcpy(first, saved, sizeof(first)) >=
+ sizeof(first) ||
+ strlcpy(second, desttarget, sizeof(second)) >=
+ sizeof(second)) {
+ log_warnx("session %u: MOVE: mailbox name "
+ "truncated -- can't happen (same-size "
+ "buffers)", session_id);
+ ok = 0;
+ goto done;
+ }
+ first_is_dest = 0;
+ } else {
+ if (strlcpy(first, desttarget, sizeof(first)) >=
+ sizeof(first) ||
+ strlcpy(second, saved, sizeof(second)) >=
+ sizeof(second)) {
+ log_warnx("session %u: MOVE: mailbox name "
+ "truncated -- can't happen (same-size "
+ "buffers)", session_id);
+ ok = 0;
+ goto done;
+ }
+ first_is_dest = 1;
+ }
+
+ if (select_mailbox_dir(first) == -1) {
+ if (first_is_dest)
+ result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+ else
+ log_warnx("session %u: MOVE: couldn't reach "
+ "%s", session_id, first);
+ ok = 0;
+ goto done;
+ }
+ if ((fd_a = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
+ -1) {
+ log_warn("session %u: open %s", session_id,
+ STORE_INDEX_NAME);
+ ok = 0;
+ goto restore_saved;
+ }
+ if (flock(fd_a, LOCK_EX) == -1) {
+ log_warn("session %u: flock %s", session_id,
+ STORE_INDEX_NAME);
+ ok = 0;
+ goto restore_saved;
+ }
+ fd_a_locked = 1;
+ if (index_load(fd_a, &idx_a) == -1) {
+ ok = 0;
+ goto restore_saved;
+ }
+
+ if (select_mailbox_dir(second) == -1) {
+ if (!first_is_dest)
+ result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+ else
+ log_warnx("session %u: MOVE: couldn't reach "
+ "%s", session_id, second);
+ ok = 0;
+ goto restore_saved;
+ }
+ if ((fd_b = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
+ -1) {
+ log_warn("session %u: open %s", session_id,
+ STORE_INDEX_NAME);
+ ok = 0;
+ goto restore_saved;
+ }
+ if (flock(fd_b, LOCK_EX) == -1) {
+ log_warn("session %u: flock %s", session_id,
+ STORE_INDEX_NAME);
+ ok = 0;
+ goto restore_saved;
+ }
+ fd_b_locked = 1;
+ if (index_load(fd_b, &idx_b) == -1) {
+ ok = 0;
+ goto restore_saved;
+ }
+
+ srcidx = first_is_dest ? &idx_b : &idx_a;
+ destidx = first_is_dest ? &idx_a : &idx_b;
+
+ if (select_mailbox_dir(saved) == -1) {
+ log_warnx("session %u: MOVE: couldn't return to %s",
+ session_id, saved);
+ ok = 0;
+ goto restore_saved;
+ }
+ }
+
+ if (!cross_mailbox) {
+ if (!move_same_mailbox(req, srcidx, &nmoved, iev))
+ ok = 0;
+ } else {
+ if (!move_cross_mailbox(req, srcidx, destidx, desttarget,
+ saved, &nmoved, iev))
+ ok = 0;
+ }
+
+ if (ok) {
+ result.count = nmoved;
+ result.uidvalidity = destidx->uidvalidity;
+ result.highestmodseq = destidx->highestmodseq;
+ }
+
+restore_saved:
+ if (cross_mailbox && select_mailbox_dir(saved) == -1)
+ log_warnx("session %u: MOVE: couldn't restore previously "
+ "selected mailbox %s", session_id, saved);
+
+done:
+ if (fd_a_locked)
+ flock(fd_a, LOCK_UN);
+ if (fd_a != -1)
+ close(fd_a);
+ if (fd_b_locked)
+ flock(fd_b, LOCK_UN);
+ if (fd_b != -1)
+ close(fd_b);
+
+ if (result.error != MBOX_OP_ERR_NO_SUCH_MAILBOX)
+ result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
+ index_free(&idx_a);
+ index_free(&idx_b);
+
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+ sizeof(result)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+ session_id);
+ imsgev_add(iev);
+}
+
blob - /dev/null
blob + 335ff535eb3abdf54b70601be59012780c46af82 (mode 644)
--- /dev/null
+++ src/mbox_fetch.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.
+ */
+
+/*
+ * mbox_fetch.c -- FETCH and STATUS request handling.
+ */
+
+#include <sys/types.h>
+#include <sys/file.h>
+#include <sys/stat.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <event.h>
+#include <fcntl.h>
+#include <imsg.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "store_internal.h"
+
+void
+handle_mbox_fetch(struct imsg_mbox_fetch *req, struct imsgev *iev)
+{
+ struct mbox_index idx;
+ struct imsg_mbox_result result;
+ int fd;
+ uint32_t lo, hi, i, sent = 0;
+ int ok = 1;
+
+ memset(&idx, 0, sizeof(idx));
+
+ if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+ log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+ ok = 0;
+ goto done;
+ }
+ if (flock(fd, LOCK_SH) == -1) {
+ log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+ close(fd);
+ ok = 0;
+ goto done;
+ }
+ if (index_load(fd, &idx) == -1) {
+ flock(fd, LOCK_UN);
+ close(fd);
+ ok = 0;
+ goto done;
+ }
+ flock(fd, LOCK_UN);
+ close(fd);
+
+ /* RFC 9051 SS6.4.9: UID FETCH's sequence-set is UIDs; lo/hi resolve against index_max_uid(), not idx.nlines */
+ if (req->by_uid) {
+ uint32_t max_uid = index_max_uid(&idx);
+
+ lo = req->lo_is_star ? max_uid : req->seq_lo;
+ hi = req->hi_is_star ? max_uid : req->seq_hi;
+ } else {
+ lo = req->lo_is_star ? (uint32_t)idx.nlines : req->seq_lo;
+ hi = req->hi_is_star ? (uint32_t)idx.nlines : req->seq_hi;
+ }
+ if (lo < 1)
+ lo = 1;
+ if (!req->by_uid && hi > (uint32_t)idx.nlines)
+ hi = (uint32_t)idx.nlines;
+
+ /* RFC 7162 SS3.2.6: VANISHED (EARLIER) MUST precede FETCH responses -- guaranteed by send order here */
+ if (req->by_uid && req->want_vanished)
+ send_vanished_range(&idx, lo, hi, iev);
+
+ for (i = 1; i <= (uint32_t)idx.nlines; i++) {
+ struct imsg_mbox_fetch_meta meta;
+ struct index_rec rec;
+ 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 < lo)
+ continue;
+ if (rec.uid > hi)
+ break;
+ } else {
+ if (i < lo)
+ continue;
+ if (i > hi)
+ break;
+ }
+
+ if (req->has_changedsince && rec.modseq <= req->changedsince)
+ continue;
+
+ memset(&meta, 0, sizeof(meta));
+ meta.seqno = i;
+ meta.uid = rec.uid;
+ meta.modseq = rec.modseq;
+
+ if (req->attrs & (MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_FLAGS)) {
+ if (locate_message_file(rec.basename, &size, suffix,
+ sizeof(suffix)) == -1) {
+ log_warnx("session %u: message %s (uid %u) "
+ "indexed but missing on disk -- skipped",
+ session_id, rec.basename, meta.uid);
+ continue;
+ }
+ have_file = 1;
+ }
+
+ if (req->attrs & MBOX_FETCH_RFC822_SIZE)
+ meta.size = (uint64_t)size;
+ if (req->attrs & MBOX_FETCH_FLAGS)
+ build_flags_string(have_file ? suffix : "",
+ rec.keywords, meta.flags, sizeof(meta.flags));
+ if (req->attrs & MBOX_FETCH_INTERNALDATE)
+ meta.internaldate = parse_maildir_timestamp(
+ rec.basename);
+
+ /* IMSG_MBOX_FETCH_HEADER precedes this message's own FETCH_META (listener.c depends on the order); always sent, found=0 on failure */
+ if (req->attrs & (MBOX_FETCH_BODY_HEADER |
+ MBOX_FETCH_HEADER_FIELDS)) {
+ struct imsg_mbox_fetch_header hdrmeta;
+ char *hdrbuf = NULL;
+ uint32_t hdrlen = 0;
+ char *combined;
+ size_t combined_len;
+ int rc;
+
+ memset(&hdrmeta, 0, sizeof(hdrmeta));
+ hdrmeta.seqno = i;
+ hdrmeta.uid = rec.uid;
+ if (req->attrs & MBOX_FETCH_BODY_HEADER)
+ rc = read_message_header(rec.basename,
+ &hdrbuf, &hdrlen);
+ else
+ rc = read_message_header_fields(rec.basename,
+ req->header_fields,
+ req->header_fields_not, &hdrbuf, &hdrlen);
+ if (rc == 0) {
+ hdrmeta.found = 1;
+ hdrmeta.hdrlen = hdrlen;
+ }
+
+ combined_len = sizeof(hdrmeta) +
+ (hdrmeta.found ? hdrlen : 0);
+ if ((combined = malloc(combined_len)) == NULL) {
+ log_warn("session %u: malloc "
+ "IMSG_MBOX_FETCH_HEADER buffer",
+ session_id);
+ } else {
+ memcpy(combined, &hdrmeta, sizeof(hdrmeta));
+ if (hdrmeta.found)
+ memcpy(combined + sizeof(hdrmeta),
+ hdrbuf, hdrlen);
+ if (imsg_compose(&iev->ibuf,
+ IMSG_MBOX_FETCH_HEADER, 0, 0, -1, combined,
+ combined_len) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_FETCH_HEADER",
+ session_id);
+ free(combined);
+ }
+ free(hdrbuf);
+ }
+
+ /* IMSG_MBOX_FETCH_BODY, same always-sent/found=0/ordered-before-META contract as HEADER above; WHOLE wins over TEXT/PART */
+ if (req->attrs & (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT |
+ MBOX_FETCH_BODY_PART)) {
+ struct imsg_mbox_fetch_body bodymeta;
+ char *bodybuf = NULL;
+ uint32_t bodylen = 0;
+ char *combined;
+ size_t combined_len;
+ int want_whole =
+ (req->attrs & MBOX_FETCH_BODY_WHOLE) != 0;
+ int want_part = !want_whole &&
+ (req->attrs & MBOX_FETCH_BODY_PART) != 0 &&
+ !(req->attrs & MBOX_FETCH_BODY_TEXT);
+ int want_text = !want_whole &&
+ !want_part;
+
+ memset(&bodymeta, 0, sizeof(bodymeta));
+ bodymeta.seqno = i;
+ bodymeta.uid = rec.uid;
+ bodymeta.is_text = want_text;
+
+ if (want_part) {
+ int path[MIME_MAX_DEPTH];
+ int pathlen;
+
+ pathlen = parse_section_part(req->section_part,
+ path, MIME_MAX_DEPTH);
+ if (pathlen != -1 &&
+ extract_mime_part(rec.basename, path,
+ pathlen, req->has_partial,
+ req->partial_start, req->partial_count,
+ &bodybuf, &bodylen) == 0)
+ bodymeta.found = 1;
+ } else {
+ size_t read_cap = req->has_partial ?
+ bodystructure_read_max : APPEND_LITERAL_MAX;
+ char *wholebuf = NULL;
+ uint32_t wholelen = 0;
+
+ if (read_message_body(rec.basename, want_text,
+ read_cap, want_text ? "BODY[TEXT]" :
+ "BODY[]", &wholebuf, &wholelen) == 0) {
+ const char *out;
+ size_t outlen;
+
+ apply_partial_range(wholebuf, wholelen,
+ req->has_partial,
+ req->partial_start,
+ req->partial_count, &out, &outlen);
+ if (outlen == 0) {
+ bodymeta.found = 1;
+ } else if ((bodybuf = malloc(outlen)) ==
+ NULL) {
+ log_warn("session %u: malloc "
+ "IMSG_MBOX_FETCH_BODY "
+ "slice (%s)", session_id,
+ rec.basename);
+ } else {
+ memcpy(bodybuf, out, outlen);
+ bodylen = (uint32_t)outlen;
+ bodymeta.found = 1;
+ }
+ }
+ free(wholebuf);
+ }
+ bodymeta.bodylen = bodylen;
+
+ combined_len = sizeof(bodymeta) +
+ (bodymeta.found ? bodylen : 0);
+ if ((combined = malloc(combined_len)) == NULL) {
+ log_warn("session %u: malloc "
+ "IMSG_MBOX_FETCH_BODY buffer", session_id);
+ } else {
+ memcpy(combined, &bodymeta, sizeof(bodymeta));
+ if (bodymeta.found && bodylen > 0)
+ memcpy(combined + sizeof(bodymeta),
+ bodybuf, bodylen);
+ if (imsg_compose(&iev->ibuf,
+ IMSG_MBOX_FETCH_BODY, 0, 0, -1, combined,
+ combined_len) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_FETCH_BODY", session_id);
+ free(combined);
+ }
+ free(bodybuf);
+ }
+
+ /* IMSG_MBOX_FETCH_ENVELOPE, same contract as HEADER/BODY; trailing bytes are build_envelope()'s formatted text */
+ if (req->attrs & MBOX_FETCH_ENVELOPE) {
+ struct imsg_mbox_fetch_envelope envmeta;
+ char *envbuf = NULL;
+ uint32_t envlen = 0;
+ char *combined;
+ size_t combined_len;
+
+ memset(&envmeta, 0, sizeof(envmeta));
+ envmeta.seqno = i;
+ envmeta.uid = rec.uid;
+ if (build_envelope(rec.basename, &envbuf, &envlen) == 0) {
+ envmeta.found = 1;
+ envmeta.envlen = envlen;
+ }
+
+ combined_len = sizeof(envmeta) +
+ (envmeta.found ? envlen : 0);
+ if ((combined = malloc(combined_len)) == NULL) {
+ log_warn("session %u: malloc "
+ "IMSG_MBOX_FETCH_ENVELOPE buffer", session_id);
+ } else {
+ memcpy(combined, &envmeta, sizeof(envmeta));
+ if (envmeta.found && envlen > 0)
+ memcpy(combined + sizeof(envmeta),
+ envbuf, envlen);
+ if (imsg_compose(&iev->ibuf,
+ IMSG_MBOX_FETCH_ENVELOPE, 0, 0, -1, combined,
+ combined_len) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_FETCH_ENVELOPE", session_id);
+ free(combined);
+ }
+ free(envbuf);
+ }
+
+ /* IMSG_MBOX_FETCH_BODYSTRUCTURE, same contract and shape as IMSG_MBOX_FETCH_ENVELOPE above */
+ if (req->attrs & MBOX_FETCH_BODYSTRUCTURE) {
+ struct imsg_mbox_fetch_bodystructure bsmeta;
+ char *bsbuf = NULL;
+ uint32_t bslen = 0;
+ char *combined;
+ size_t combined_len;
+
+ memset(&bsmeta, 0, sizeof(bsmeta));
+ bsmeta.seqno = i;
+ bsmeta.uid = rec.uid;
+ if (build_bodystructure(rec.basename, &bsbuf, &bslen) == 0) {
+ bsmeta.found = 1;
+ bsmeta.bslen = bslen;
+ }
+
+ combined_len = sizeof(bsmeta) +
+ (bsmeta.found ? bslen : 0);
+ if ((combined = malloc(combined_len)) == NULL) {
+ log_warn("session %u: malloc "
+ "IMSG_MBOX_FETCH_BODYSTRUCTURE buffer",
+ session_id);
+ } else {
+ memcpy(combined, &bsmeta, sizeof(bsmeta));
+ if (bsmeta.found && bslen > 0)
+ memcpy(combined + sizeof(bsmeta),
+ bsbuf, bslen);
+ if (imsg_compose(&iev->ibuf,
+ IMSG_MBOX_FETCH_BODYSTRUCTURE, 0, 0, -1,
+ combined, combined_len) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_FETCH_BODYSTRUCTURE",
+ session_id);
+ free(combined);
+ }
+ free(bsbuf);
+ }
+
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_FETCH_META, 0, 0, -1,
+ &meta, sizeof(meta)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_FETCH_META", session_id);
+ else
+ sent++;
+ }
+
+done:
+ index_free(&idx);
+
+ memset(&result, 0, sizeof(result));
+ result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
+ result.count = sent;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+ sizeof(result)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+ session_id);
+ imsgev_add(iev);
+}
+
+/* Inverse of build_flags_string()'s letter table: maps flag-suffix letters (e.g. "FS") back to the MBOX_FLAG_* bitmask. */
+uint32_t
+letters_to_sysflags(const char *letters)
+{
+ uint32_t f = 0;
+
+ if (strchr(letters, 'D') != NULL)
+ f |= MBOX_FLAG_DRAFT;
+ if (strchr(letters, 'F') != NULL)
+ f |= MBOX_FLAG_FLAGGED;
+ if (strchr(letters, 'R') != NULL)
+ f |= MBOX_FLAG_ANSWERED;
+ if (strchr(letters, 'S') != NULL)
+ f |= MBOX_FLAG_SEEN;
+ if (strchr(letters, 'T') != NULL)
+ f |= MBOX_FLAG_DELETED;
+ return (f);
+}
+
+/* Renders sysflags into maildir(5)'s required ASCII letter order: D, F, R, S, T. */
+void
+sysflags_to_letters(uint32_t sysflags, char *out, size_t outsize)
+{
+ size_t i = 0;
+
+ if ((sysflags & MBOX_FLAG_DRAFT) && i + 1 < outsize)
+ out[i++] = 'D';
+ if ((sysflags & MBOX_FLAG_FLAGGED) && i + 1 < outsize)
+ out[i++] = 'F';
+ if ((sysflags & MBOX_FLAG_ANSWERED) && i + 1 < outsize)
+ out[i++] = 'R';
+ if ((sysflags & MBOX_FLAG_SEEN) && i + 1 < outsize)
+ out[i++] = 'S';
+ if ((sysflags & MBOX_FLAG_DELETED) && i + 1 < outsize)
+ out[i++] = 'T';
+ out[i] = '\0';
+}
+
+/* RFC 9051 SS6.3.11 STATUS; reuses SELECT's new/ scan so counts can't under-report. UNSEEN/DELETED/SIZE scan only if requested (SS6.3.11: expensive). */
+void
+handle_mbox_status(struct imsg_mbox_status *req, struct imsgev *iev)
+{
+ struct mbox_index idx;
+ struct imsg_mbox_status_result reply;
+ int fd;
+ DIR *dp;
+ struct dirent *de;
+ char saved[MBOX_NAME_MAX];
+ const char *target;
+ int switched = 0;
+
+ memset(&reply, 0, sizeof(reply));
+
+ /* RFC 9051 SS6.3.11: STATUS targets a mailbox independent of the session's selection; visit-and-restore via select_mailbox_dir() */
+ if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
+ sizeof(saved)) {
+ log_warnx("session %u: STATUS: current_mailbox_dir truncated "
+ "-- can't happen (same-size buffers)", session_id);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+ if (mailbox_name_is_inbox(req->mailbox))
+ target = "";
+ else if (mailbox_name_valid(req->mailbox))
+ target = req->mailbox;
+ else {
+ log_debug("session %u: STATUS %s: invalid mailbox name",
+ session_id, req->mailbox);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+ if (select_mailbox_dir(target) == -1) {
+ log_debug("session %u: STATUS %s: no such mailbox",
+ session_id, req->mailbox);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+ switched = 1;
+
+ if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+ log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+ if (flock(fd, LOCK_EX) == -1) {
+ log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+ close(fd);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ if (index_load(fd, &idx) == -1) {
+ flock(fd, LOCK_UN);
+ close(fd);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ dp = opendir("new");
+ if (dp == NULL) {
+ if (errno != ENOENT)
+ log_warn("session %u: opendir new", session_id);
+ } else {
+ while ((de = readdir(dp)) != NULL) {
+ if (de->d_name[0] == '.')
+ continue;
+ if (index_has_basename(&idx, de->d_name))
+ continue;
+ if (index_append(&idx, idx.uidnext, de->d_name)
+ == -1) {
+ closedir(dp);
+ index_free(&idx);
+ flock(fd, LOCK_UN);
+ close(fd);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+ idx.uidnext++;
+ }
+ closedir(dp);
+ }
+
+ if (index_save(&idx) == -1) {
+ index_free(&idx);
+ flock(fd, LOCK_UN);
+ close(fd);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ reply.error = MBOX_OP_OK;
+ reply.messages = (uint32_t)idx.nlines;
+ reply.uidnext = idx.uidnext;
+ reply.uidvalidity = idx.uidvalidity;
+ reply.highestmodseq = idx.highestmodseq;
+
+ if (req->attrs &
+ (STATUS_ATT_UNSEEN | STATUS_ATT_DELETED | STATUS_ATT_SIZE)) {
+ size_t i;
+
+ for (i = 0; i < idx.nlines; i++) {
+ struct index_rec rec;
+ const char *lp;
+ uint32_t sysflags;
+ char suffix[64];
+ off_t size;
+
+ if (index_parse_line(idx.lines[i], &rec) == -1)
+ continue;
+ if (locate_message_file(rec.basename, &size, suffix,
+ sizeof(suffix)) == -1) {
+ log_warnx("session %u: message %s indexed but "
+ "missing on disk -- skipped for STATUS "
+ "UNSEEN/DELETED/SIZE", session_id,
+ rec.basename);
+ continue;
+ }
+
+ lp = strstr(suffix, "2,");
+ sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
+ if (!(sysflags & MBOX_FLAG_SEEN))
+ reply.unseen++;
+ if (sysflags & MBOX_FLAG_DELETED)
+ reply.deleted++;
+ reply.size += (uint64_t)size;
+ }
+ }
+
+ index_free(&idx);
+ flock(fd, LOCK_UN);
+ close(fd);
+
+send:
+ /* restore whatever this session had selected before STATUS (no-op if already there) */
+ if (switched && select_mailbox_dir(saved) == -1)
+ log_warnx("session %u: STATUS: failed to restore selection "
+ "to %s -- session may be left in an inconsistent state",
+ session_id, saved[0] != '\0' ? saved : "INBOX");
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_STATUS_RESULT, 0, 0, -1, &reply,
+ sizeof(reply)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_STATUS_RESULT",
+ session_id);
+ imsgev_add(iev);
+}
blob - /dev/null
blob + 3509890981573d844a3f5185b7e774a973ac2410 (mode 644)
--- /dev/null
+++ src/mbox_manage.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.
+ */
+
+/*
+ * mbox_manage.c -- CREATE/DELETE/RENAME/LIST/APPEND/SELECT
+ * request handling.
+ */
+
+#include <sys/types.h>
+#include <sys/file.h>
+#include <sys/stat.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <event.h>
+#include <fcntl.h>
+#include <imsg.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "store_internal.h"
+
+void
+handle_mbox_select(struct imsg_mbox_select *req, struct imsgev *iev)
+{
+ struct mbox_index idx;
+ struct imsg_mbox_selected reply;
+ int fd;
+ const char *target;
+
+ memset(&reply, 0, sizeof(reply));
+
+ if (mailbox_name_is_inbox(req->mailbox))
+ target = "";
+ else if (mailbox_name_valid(req->mailbox))
+ target = req->mailbox;
+ else {
+ log_debug("session %u: SELECT %s: invalid mailbox name",
+ session_id, req->mailbox);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ if (select_mailbox_dir(target) == -1) {
+ log_debug("session %u: SELECT %s: no such mailbox",
+ session_id, req->mailbox);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+ log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+ /* multiple store children per user, so this read-modify-write cycle needs cross-process mutual exclusion */
+ if (flock(fd, LOCK_EX) == -1) {
+ log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+ close(fd);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ if (refresh_index(&idx, fd) == -1) {
+ flock(fd, LOCK_UN);
+ close(fd);
+ reply.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ reply.error = MBOX_OP_OK;
+ reply.exists = (uint32_t)idx.nlines;
+ reply.uidvalidity = idx.uidvalidity;
+ reply.uidnext = idx.uidnext;
+ reply.highestmodseq = idx.highestmodseq;
+
+ if (req->qresync && req->qresync_uidvalidity == idx.uidvalidity)
+ qresync_send_resync(req, &idx, iev);
+
+ index_free(&idx);
+ flock(fd, LOCK_UN);
+ close(fd);
+
+send:
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECTED, 0, 0, -1, &reply,
+ sizeof(reply)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_SELECTED",
+ session_id);
+ imsgev_add(iev);
+}
+
+int
+ensure_maildir_dirs(const char *prefix)
+{
+ static const char *dirs[] = { "tmp", "new", "cur" };
+ char path[MBOX_NAME_MAX + 8];
+ size_t i;
+
+ for (i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) {
+ if (snprintf(path, sizeof(path), "%s%s", prefix, dirs[i]) >=
+ (int)sizeof(path)) {
+ log_warnx("session %u: mailbox path too long",
+ session_id);
+ return (-1);
+ }
+ if (mkdir(path, 0700) == -1 && errno != EEXIST) {
+ log_warn("session %u: mkdir %s", session_id, path);
+ 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
+handle_mbox_create(struct imsg_mbox_create *req, struct imsgev *iev)
+{
+ struct imsg_mbox_result result;
+ char prefix[MBOX_NAME_MAX + 1];
+ char saved[MBOX_NAME_MAX];
+ int switched = 0;
+
+ memset(&result, 0, sizeof(result));
+
+ if (mailbox_name_is_inbox(req->mailbox) ||
+ !mailbox_name_valid(req->mailbox)) {
+ log_debug("session %u: CREATE %s: invalid name", session_id,
+ req->mailbox);
+ result.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ /* mkdir/ensure_maildir_dirs() below need cwd at maildir root, not wherever a SELECTed mailbox left it -- visit root first */
+ if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
+ sizeof(saved)) {
+ log_warnx("session %u: CREATE %s: current_mailbox_dir "
+ "truncated -- can't happen (same-size buffers)",
+ session_id, req->mailbox);
+ 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) {
+ if (errno != EEXIST) {
+ log_warn("session %u: CREATE: mkdir %s", session_id,
+ req->mailbox);
+ result.error = MBOX_OP_ERR_GENERIC;
+ } else {
+ log_debug("session %u: CREATE %s: already exists",
+ session_id, req->mailbox);
+ /* RFC 5530 SS3 ALREADYEXISTS: this is its own worked example */
+ result.error = MBOX_OP_ERR_ALREADY_EXISTS;
+ }
+ goto send;
+ }
+
+ if (snprintf(prefix, sizeof(prefix), "%s/", req->mailbox) >=
+ (int)sizeof(prefix) || ensure_maildir_dirs(prefix) == -1) {
+ /* best-effort cleanup so a half-initialized mailbox doesn't get stuck (mkdir would now see EEXIST) */
+ rmdir(req->mailbox);
+ result.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ result.error = MBOX_OP_OK;
+
+send:
+ if (switched && select_mailbox_dir(saved) == -1)
+ log_warnx("session %u: CREATE %s: couldn't restore "
+ "previously selected mailbox %s", session_id,
+ req->mailbox, saved);
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+ sizeof(result)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+ session_id);
+ imsgev_add(iev);
+}
+
+/* bounded removal of one flat mailbox's tmp/new/cur contents plus its index; refuses if any entry isn't a regular file; caller rmdir()s the mailbox itself */
+static int
+remove_maildir_subtree(const char *prefix)
+{
+ static const char *dirs[] = { "tmp", "new", "cur" };
+ char path[MBOX_NAME_MAX + 32]; /* +32 must cover STORE_INDEX_NAME on a near-maximal mailbox name */
+ size_t i;
+
+ for (i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) {
+ DIR *dp;
+ const struct dirent *de;
+ int ok = 1;
+
+ if (snprintf(path, sizeof(path), "%s%s", prefix, dirs[i]) >=
+ (int)sizeof(path)) {
+ log_warnx("session %u: DELETE: path too long",
+ session_id);
+ return (-1);
+ }
+ if ((dp = opendir(path)) == NULL) {
+ if (errno == ENOENT)
+ continue; /* tmp/ may never have been created */
+ log_warn("session %u: DELETE: opendir %s",
+ session_id, path);
+ return (-1);
+ }
+ while ((de = readdir(dp)) != NULL) {
+ char entpath[sizeof(path) + 300];
+ struct stat st;
+
+ if (strcmp(de->d_name, ".") == 0 ||
+ strcmp(de->d_name, "..") == 0)
+ continue;
+ if (snprintf(entpath, sizeof(entpath), "%s/%s", path,
+ de->d_name) >= (int)sizeof(entpath)) {
+ log_warnx("session %u: DELETE: entry path "
+ "too long", session_id);
+ ok = 0;
+ continue;
+ }
+ if (lstat(entpath, &st) == -1) {
+ log_warn("session %u: DELETE: lstat %s",
+ session_id, entpath);
+ ok = 0;
+ continue;
+ }
+ if (!S_ISREG(st.st_mode)) {
+ log_warnx("session %u: DELETE: refusing -- "
+ "%s is not a regular file", session_id,
+ entpath);
+ ok = 0;
+ continue;
+ }
+ if (unlink(entpath) == -1) {
+ log_warn("session %u: DELETE: unlink %s",
+ session_id, entpath);
+ ok = 0;
+ }
+ }
+ closedir(dp);
+ if (!ok)
+ return (-1);
+ if (rmdir(path) == -1 && errno != ENOENT) {
+ log_warn("session %u: DELETE: rmdir %s", session_id,
+ path);
+ return (-1);
+ }
+ }
+
+ if (snprintf(path, sizeof(path), "%s%s", prefix, STORE_INDEX_NAME) >=
+ (int)sizeof(path)) {
+ log_warnx("session %u: DELETE: index path too long",
+ session_id);
+ return (-1);
+ }
+ if (unlink(path) == -1 && errno != ENOENT) {
+ log_warn("session %u: DELETE: unlink %s", session_id, path);
+ return (-1);
+ }
+
+ return (0);
+}
+
+/* IMSG_MBOX_DELETE (RFC 9051 SS6.3.5); no check for "is this session's own SELECTed mailbox" -- leaves store child at root until next SELECT resolves it */
+
+void
+handle_mbox_delete(struct imsg_mbox_delete *req, struct imsgev *iev)
+{
+ struct imsg_mbox_result result;
+ struct stat st;
+ char prefix[MBOX_NAME_MAX + 1];
+ char saved[MBOX_NAME_MAX];
+ int switched = 0;
+
+ memset(&result, 0, sizeof(result));
+
+ if (mailbox_name_is_inbox(req->mailbox) ||
+ !mailbox_name_valid(req->mailbox)) {
+ log_debug("session %u: DELETE %s: invalid name", session_id,
+ req->mailbox);
+ result.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ /* visit root first, same as handle_mbox_create() */
+ if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
+ sizeof(saved)) {
+ log_warnx("session %u: DELETE %s: current_mailbox_dir "
+ "truncated -- can't happen (same-size buffers)",
+ session_id, req->mailbox);
+ 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 (stat(req->mailbox, &st) == -1 || !S_ISDIR(st.st_mode)) {
+ log_debug("session %u: DELETE %s: no such mailbox",
+ session_id, req->mailbox);
+ /* RFC 5530 SS3 NONEXISTENT: its own worked example is exactly this */
+ result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+ goto send;
+ }
+
+ if (snprintf(prefix, sizeof(prefix), "%s/", req->mailbox) >=
+ (int)sizeof(prefix)) {
+ result.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ if (remove_maildir_subtree(prefix) == -1) {
+ result.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+ if (rmdir(req->mailbox) == -1 && errno != ENOENT) {
+ log_warn("session %u: DELETE: rmdir %s", session_id,
+ req->mailbox);
+ result.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ result.error = MBOX_OP_OK;
+
+send:
+ if (switched && select_mailbox_dir(saved) == -1)
+ log_warnx("session %u: DELETE %s: couldn't restore "
+ "previously selected mailbox %s", session_id,
+ req->mailbox, saved);
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+ sizeof(result)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+ session_id);
+ imsgev_add(iev);
+}
+
+/* IMSG_MBOX_RENAME (RFC 9051 SS6.3.6); INBOX refused as source since its dir can't be renamed away (store.c's chroot assumes it's always root) */
+
+void
+handle_mbox_rename(struct imsg_mbox_rename *req, struct imsgev *iev)
+{
+ struct imsg_mbox_result result;
+ struct stat st;
+ char saved[MBOX_NAME_MAX];
+ int switched = 0;
+
+ memset(&result, 0, sizeof(result));
+
+ if (mailbox_name_is_inbox(req->oldname) ||
+ !mailbox_name_valid(req->oldname) ||
+ !mailbox_name_valid(req->newname)) {
+ log_debug("session %u: RENAME %s -> %s: invalid name(s)",
+ session_id, req->oldname, req->newname);
+ result.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
+ sizeof(saved)) {
+ log_warnx("session %u: RENAME %s -> %s: current_mailbox_dir "
+ "truncated -- can't happen (same-size buffers)",
+ session_id, req->oldname, req->newname);
+ 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 (stat(req->oldname, &st) == -1 || !S_ISDIR(st.st_mode)) {
+ log_debug("session %u: RENAME %s: no such mailbox",
+ session_id, req->oldname);
+ /* RFC 5530 SS3 NONEXISTENT: its own worked example is a RENAME failing on a missing source */
+ result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+ goto send;
+ }
+
+ /* SS6.3.6: error to rename to an existing name; checked explicitly rather than relying on rename(2)'s semantics */
+ if (stat(req->newname, &st) == 0 || errno != ENOENT) {
+ log_debug("session %u: RENAME %s -> %s: destination exists",
+ session_id, req->oldname, req->newname);
+ /* RFC 5530 SS3 ALREADYEXISTS: its own worked example is this exact RENAME case */
+ result.error = MBOX_OP_ERR_ALREADY_EXISTS;
+ goto send;
+ }
+
+ if (rename(req->oldname, req->newname) == -1) {
+ log_warn("session %u: RENAME: rename %s -> %s", session_id,
+ req->oldname, req->newname);
+ result.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ result.error = MBOX_OP_OK;
+
+send:
+ if (switched) {
+ const char *restore = saved;
+
+ /* if renamed mailbox was this session's own selection, follow it to its new name */
+ if (result.error == MBOX_OP_OK && strcmp(saved, req->oldname) == 0)
+ restore = req->newname;
+ if (select_mailbox_dir(restore) == -1)
+ log_warnx("session %u: RENAME %s -> %s: couldn't "
+ "restore previously selected mailbox (as %s)",
+ session_id, req->oldname, req->newname, restore);
+ }
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+ sizeof(result)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+ session_id);
+ imsgev_add(iev);
+}
+
+/* IMSG_MBOX_LIST (RFC 9051 SS6.3.9); streams IMSG_MBOX_LIST_ITEM per mailbox subdir, INBOX excluded (listener.c handles it); visits root first */
+
+void
+handle_mbox_list(struct imsgev *iev)
+{
+ struct imsg_mbox_result result;
+ struct imsg_mbox_list_item item;
+ DIR *dp;
+ struct dirent *de;
+ char saved[MBOX_NAME_MAX];
+ int switched = 0;
+
+ memset(&result, 0, sizeof(result));
+
+ if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
+ sizeof(saved)) {
+ log_warnx("session %u: LIST: current_mailbox_dir truncated "
+ "-- can't happen (same-size buffers)", session_id);
+ 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) {
+ log_warn("session %u: LIST: opendir .", session_id);
+ result.error = MBOX_OP_ERR_GENERIC;
+ goto send;
+ }
+
+ while ((de = readdir(dp)) != NULL) {
+ struct stat st;
+
+ if (strcmp(de->d_name, ".") == 0 ||
+ strcmp(de->d_name, "..") == 0)
+ continue;
+ if (strcmp(de->d_name, "tmp") == 0 ||
+ strcmp(de->d_name, "new") == 0 ||
+ strcmp(de->d_name, "cur") == 0 ||
+ strcmp(de->d_name, STORE_INDEX_NAME) == 0)
+ continue;
+ if (!mailbox_name_valid(de->d_name))
+ continue;
+ if (stat(de->d_name, &st) == -1 || !S_ISDIR(st.st_mode))
+ continue;
+
+ memset(&item, 0, sizeof(item));
+ if (strlcpy(item.mailbox, de->d_name, sizeof(item.mailbox)) >=
+ sizeof(item.mailbox)) {
+ log_warnx("session %u: LIST: %s truncated -- can't "
+ "happen (mailbox_name_valid() already bounds it "
+ "under MBOX_NAME_MAX)", session_id, de->d_name);
+ continue;
+ }
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_LIST_ITEM, 0, 0, -1,
+ &item, sizeof(item)) == -1) {
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_LIST_ITEM", session_id);
+ continue;
+ }
+ result.count++;
+ }
+ closedir(dp);
+ result.error = MBOX_OP_OK;
+
+send:
+ if (switched && select_mailbox_dir(saved) == -1)
+ log_warnx("session %u: LIST: couldn't restore previously "
+ "selected mailbox %s", session_id, saved);
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+ sizeof(result)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+ session_id);
+ imsgev_add(iev);
+}
+
+/* IMSG_MBOX_APPEND: index is updated before the tmp/->cur/ rename, so a rename failure leaves an "indexed but missing on disk" entry, not an orphaned file */
+
+/* host name for the maildir basename uniquer; cached after first gethostname(2), falls back to "imapd" (not fatal, uniqueness doesn't depend on it) */
+const char *
+append_hostname(void)
+{
+ static char hostbuf[256];
+ static int resolved;
+
+ if (!resolved) {
+ if (gethostname(hostbuf, sizeof(hostbuf)) == -1) {
+ log_warn("session %u: gethostname", session_id);
+ if (strlcpy(hostbuf, "imapd", sizeof(hostbuf)) >=
+ sizeof(hostbuf))
+ log_warnx("session %u: gethostname fallback "
+ "truncated -- can't happen", session_id);
+ }
+ resolved = 1;
+ }
+ return (hostbuf);
+}
+
+
+void
+handle_mbox_append(struct imsg_mbox_append *req, const char *msgbody,
+ size_t msglen, struct imsgev *iev)
+{
+ struct mbox_index idx;
+ struct imsg_mbox_appended reply;
+ int fd = -1, tmpfd = -1, locked = 0;
+ char basename[256];
+ char tmppath[300], curpath[320];
+ char target[MBOX_NAME_MAX];
+ char letters[8], line[STORE_INDEX_LINE_MAX];
+ int64_t delivery_ts;
+ char saved[MBOX_NAME_MAX];
+ int switched = 0;
+
+ memset(&idx, 0, sizeof(idx));
+ memset(&reply, 0, sizeof(reply));
+
+ /* must actually chdir into the target mailbox: index_save() always operates on "imapd.index" relative to cwd, no prefix parameter */
+ if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
+ sizeof(saved)) {
+ log_warnx("session %u: APPEND %s: current_mailbox_dir "
+ "truncated -- can't happen (same-size buffers)",
+ session_id, req->mailbox);
+ reply.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+ goto done_reply;
+ }
+
+ if (mailbox_name_is_inbox(req->mailbox)) {
+ target[0] = '\0';
+ } else if (mailbox_name_valid(req->mailbox)) {
+ if (strlcpy(target, req->mailbox, sizeof(target)) >=
+ sizeof(target)) {
+ log_warnx("session %u: APPEND %s: target truncated "
+ "-- can't happen (mailbox_name_valid() already "
+ "bounds it under MBOX_NAME_MAX)", session_id,
+ req->mailbox);
+ reply.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+ goto done_reply;
+ }
+ } else {
+ log_debug("session %u: APPEND %s: invalid mailbox name",
+ session_id, req->mailbox);
+ reply.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+ goto done_reply;
+ }
+
+ if (select_mailbox_dir(target) == -1) {
+ log_debug("session %u: APPEND %s: no such mailbox",
+ session_id, req->mailbox);
+ reply.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+ goto done_reply;
+ }
+ switched = 1;
+
+ if (ensure_maildir_dirs("") == -1)
+ goto done_reply;
+
+ delivery_ts = req->has_date ? req->date : (int64_t)time(NULL);
+
+ if (snprintf(basename, sizeof(basename), "%lld.%d_%u.%s",
+ (long long)delivery_ts, (int)getpid(), append_counter++,
+ append_hostname()) >= (int)sizeof(basename)) {
+ log_warnx("session %u: generated basename too long",
+ session_id);
+ goto done_reply;
+ }
+ if (snprintf(tmppath, sizeof(tmppath), "tmp/%s", basename) >=
+ (int)sizeof(tmppath)) {
+ log_warnx("session %u: tmp path too long", session_id);
+ goto done_reply;
+ }
+
+ if ((tmpfd = open(tmppath, O_WRONLY | O_CREAT | O_EXCL, 0600)) == -1) {
+ log_warn("session %u: open %s", session_id, tmppath);
+ goto done_reply;
+ }
+ {
+ size_t written = 0;
+
+ while (written < msglen) {
+ ssize_t n;
+
+ n = write(tmpfd, msgbody + written, msglen - written);
+ if (n == -1) {
+ if (errno == EINTR)
+ continue;
+ log_warn("session %u: write %s", session_id,
+ tmppath);
+ close(tmpfd);
+ tmpfd = -1;
+ unlink(tmppath);
+ goto done_reply;
+ }
+ written += (size_t)n;
+ }
+ }
+ if (fsync(tmpfd) == -1)
+ log_warn("session %u: fsync %s (continuing)", session_id,
+ tmppath);
+ close(tmpfd);
+ tmpfd = -1;
+
+ if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+ log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+ goto done_unlink;
+ }
+ if (flock(fd, LOCK_EX) == -1) {
+ log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+ goto done_unlink;
+ }
+ locked = 1;
+ if (index_load(fd, &idx) == -1)
+ goto done_unlink;
+
+ reply.uid = idx.uidnext;
+ if (index_append(&idx, reply.uid, basename) == -1)
+ goto done_unlink;
+ idx.uidnext++;
+
+ if (req->keywords[0] != '\0') {
+ int n;
+
+ /* carry forward the modseq index_append() just assigned, or an initial flag-list loses its mod-sequence */
+ n = snprintf(line, sizeof(line), "%u:%s:%s:%llu", reply.uid,
+ basename, req->keywords,
+ (unsigned long long)idx.highestmodseq);
+ if (n < 0 || (size_t)n >= sizeof(line)) {
+ log_warnx("session %u: index line too long for %s",
+ session_id, basename);
+ goto done_unlink;
+ }
+ free(idx.lines[idx.nlines - 1]);
+ if ((idx.lines[idx.nlines - 1] = strdup(line)) == NULL) {
+ log_warn("session %u: strdup index line", session_id);
+ goto done_unlink;
+ }
+ }
+
+ if (index_save(&idx) == -1)
+ goto done_unlink;
+
+ reply.uidvalidity = idx.uidvalidity;
+ reply.exists = (uint32_t)idx.nlines;
+
+ flock(fd, LOCK_UN);
+ close(fd);
+ fd = -1;
+ locked = 0;
+ index_free(&idx);
+
+ /* index committed -- now the rename; index-then-rename is the safer order to fail partway through */
+ sysflags_to_letters(req->sysflags, letters, sizeof(letters));
+ if (snprintf(curpath, sizeof(curpath), "cur/%s:2,%s", basename,
+ letters) >= (int)sizeof(curpath)) {
+ log_warnx("session %u: cur path too long for %s", session_id,
+ basename);
+ unlink(tmppath);
+ goto done_reply;
+ }
+ if (rename(tmppath, curpath) == -1) {
+ log_warn("session %u: rename %s -> %s", session_id, tmppath,
+ curpath);
+ unlink(tmppath);
+ goto done_reply;
+ }
+
+ reply.error = MBOX_OP_OK;
+ goto done_reply;
+
+done_unlink:
+ unlink(tmppath);
+ if (locked)
+ flock(fd, LOCK_UN);
+ if (fd != -1)
+ close(fd);
+ index_free(&idx);
+
+done_reply:
+ if (switched && select_mailbox_dir(saved) == -1)
+ log_warnx("session %u: APPEND %s: couldn't restore "
+ "previously selected mailbox %s", session_id,
+ req->mailbox, saved);
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_APPENDED, 0, 0, -1, &reply,
+ sizeof(reply)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_APPENDED",
+ session_id);
+ imsgev_add(iev);
+}
+
+/* resolves a mailbox name to select_mailbox_dir()'s expected "target" string; checks syntax only, not existence */
blob - /dev/null
blob + 300c2c9bd0fed6acd4ee626f7994d3596fb66784 (mode 644)
--- /dev/null
+++ src/mbox_search.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.
+ */
+
+/*
+ * mbox_search.c -- SEARCH key evaluation.
+ */
+
+#include <sys/types.h>
+#include <sys/file.h>
+#include <sys/stat.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <event.h>
+#include <fcntl.h>
+#include <imsg.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "store_internal.h"
+
+/* True if comma-separated list contains kw as a whole token (not substring); used by merge_keywords() to dedupe/REMOVE. */
+static int
+kw_list_contains(const char *list, const char *kw)
+{
+ char tmp[MBOX_FLAGS_MAX];
+ const char *tok;
+ char *save;
+
+ if (strlcpy(tmp, list, sizeof(tmp)) >= sizeof(tmp)) {
+ log_warnx("session %u: kw_list_contains: keyword list "
+ "truncated -- can't happen (both same-size "
+ "MBOX_FLAGS_MAX buffers); treating as not-found",
+ session_id);
+ return (0);
+ }
+ for (tok = strtok_r(tmp, ",", &save); tok != NULL;
+ tok = strtok_r(NULL, ",", &save)) {
+ if (strcmp(tok, kw) == 0)
+ return (1);
+ }
+ return (0);
+}
+
+/* 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. */
+void
+merge_keywords(int mode, const char *old_kws, const char *new_kws,
+ char *out, size_t outsize)
+{
+ char tmp[MBOX_FLAGS_MAX];
+ const char *tok;
+ char *save;
+ int first = 1;
+
+ out[0] = '\0';
+
+ if (mode == MBOX_STORE_REMOVE) {
+ if (strlcpy(tmp, old_kws, sizeof(tmp)) >= sizeof(tmp)) {
+ log_warnx("session %u: merge_keywords: old_kws "
+ "truncated -- can't happen (same-size "
+ "MBOX_FLAGS_MAX buffers); refusing to guess at "
+ "the keyword set", session_id);
+ return;
+ }
+ for (tok = strtok_r(tmp, ",", &save); tok != NULL;
+ tok = strtok_r(NULL, ",", &save)) {
+ if (kw_list_contains(new_kws, tok))
+ continue;
+ if (!first) {
+ if (strlcat(out, ",", outsize) >= outsize) {
+ log_warnx("session %u: "
+ "merge_keywords: out truncated "
+ "-- can't happen (same-size "
+ "MBOX_FLAGS_MAX buffers)",
+ session_id);
+ return;
+ }
+ }
+ if (strlcat(out, tok, outsize) >= outsize) {
+ log_warnx("session %u: merge_keywords: out "
+ "truncated -- can't happen (same-size "
+ "MBOX_FLAGS_MAX buffers)", session_id);
+ return;
+ }
+ first = 0;
+ }
+ return;
+ }
+
+ if (mode == MBOX_STORE_ADD) {
+ if (strlcpy(tmp, old_kws, sizeof(tmp)) >= sizeof(tmp)) {
+ log_warnx("session %u: merge_keywords: old_kws "
+ "truncated -- can't happen (same-size "
+ "MBOX_FLAGS_MAX buffers); refusing to guess at "
+ "the keyword set", session_id);
+ return;
+ }
+ for (tok = strtok_r(tmp, ",", &save); tok != NULL;
+ tok = strtok_r(NULL, ",", &save)) {
+ if (!first) {
+ if (strlcat(out, ",", outsize) >= outsize) {
+ log_warnx("session %u: "
+ "merge_keywords: out truncated "
+ "-- can't happen (same-size "
+ "MBOX_FLAGS_MAX buffers)",
+ session_id);
+ return;
+ }
+ }
+ if (strlcat(out, tok, outsize) >= outsize) {
+ log_warnx("session %u: merge_keywords: out "
+ "truncated -- can't happen (same-size "
+ "MBOX_FLAGS_MAX buffers)", session_id);
+ return;
+ }
+ first = 0;
+ }
+ }
+
+ /* SET starts from nothing; ADD continues from old_kws already built above; either way append new_kws not already present */
+ if (strlcpy(tmp, new_kws, sizeof(tmp)) >= sizeof(tmp)) {
+ log_warnx("session %u: merge_keywords: new_kws truncated -- "
+ "can't happen (same-size MBOX_FLAGS_MAX buffers); "
+ "refusing to guess at the keyword set", session_id);
+ return;
+ }
+ for (tok = strtok_r(tmp, ",", &save); tok != NULL;
+ tok = strtok_r(NULL, ",", &save)) {
+ if (kw_list_contains(out, tok))
+ continue;
+ if (!first) {
+ if (strlcat(out, ",", outsize) >= outsize) {
+ log_warnx("session %u: merge_keywords: out "
+ "truncated -- can't happen (same-size "
+ "MBOX_FLAGS_MAX buffers)", session_id);
+ return;
+ }
+ }
+ if (strlcat(out, tok, outsize) >= outsize) {
+ log_warnx("session %u: merge_keywords: out truncated "
+ "-- can't happen (same-size MBOX_FLAGS_MAX "
+ "buffers)", session_id);
+ return;
+ }
+ first = 0;
+ }
+}
+
+/* Per-message context handed to search_eval()/search_eval_leaf() during handle_mbox_search()'s scan below. */
+struct search_msg_ctx {
+ uint32_t seqno;
+ uint32_t uid;
+ uint32_t sysflags;
+ const char *keywords; /* comma-separated, same convention as everywhere else in this file */
+ int64_t internaldate;
+ uint64_t size; /* 64-bit for SEARCH LARGER/SMALLER */
+ uint64_t modseq; /* RFC 7162 SS3.1.5 MODSEQ search key */
+};
+
+/* One postfix node's evaluation against a message; SEARCH_OP_AND/OR/NOT never reach here, handled by search_eval()'s stack machine. */
+static int
+search_eval_leaf(const struct search_node *n, const struct search_msg_ctx *m)
+{
+ switch (n->op) {
+ case SEARCH_OP_ALL:
+ return (1);
+ case SEARCH_OP_ANSWERED:
+ return ((m->sysflags & MBOX_FLAG_ANSWERED) != 0);
+ case SEARCH_OP_UNANSWERED:
+ return ((m->sysflags & MBOX_FLAG_ANSWERED) == 0);
+ case SEARCH_OP_DELETED:
+ return ((m->sysflags & MBOX_FLAG_DELETED) != 0);
+ case SEARCH_OP_UNDELETED:
+ return ((m->sysflags & MBOX_FLAG_DELETED) == 0);
+ case SEARCH_OP_DRAFT:
+ return ((m->sysflags & MBOX_FLAG_DRAFT) != 0);
+ case SEARCH_OP_UNDRAFT:
+ return ((m->sysflags & MBOX_FLAG_DRAFT) == 0);
+ case SEARCH_OP_FLAGGED:
+ return ((m->sysflags & MBOX_FLAG_FLAGGED) != 0);
+ case SEARCH_OP_UNFLAGGED:
+ return ((m->sysflags & MBOX_FLAG_FLAGGED) == 0);
+ case SEARCH_OP_SEEN:
+ return ((m->sysflags & MBOX_FLAG_SEEN) != 0);
+ case SEARCH_OP_UNSEEN:
+ return ((m->sysflags & MBOX_FLAG_SEEN) == 0);
+ case SEARCH_OP_KEYWORD:
+ return (kw_list_contains(m->keywords, n->keyword));
+ case SEARCH_OP_UNKEYWORD:
+ return (!kw_list_contains(m->keywords, n->keyword));
+ case SEARCH_OP_BEFORE:
+ return (m->internaldate < n->num);
+ case SEARCH_OP_ON:
+ return (m->internaldate >= n->num &&
+ m->internaldate < n->num + 86400);
+ case SEARCH_OP_SINCE:
+ return (m->internaldate >= n->num);
+ case SEARCH_OP_LARGER:
+ return ((int64_t)m->size > n->num);
+ case SEARCH_OP_SMALLER:
+ return ((int64_t)m->size < n->num);
+ case SEARCH_OP_SEQSET:
+ return (m->seqno >= n->seq_lo && m->seqno <= n->seq_hi);
+ case SEARCH_OP_UIDSET:
+ return (m->uid >= n->seq_lo && m->uid <= n->seq_hi);
+ case SEARCH_OP_MODSEQ:
+ return (m->modseq >= (uint64_t)n->num);
+ default:
+ return (0);
+ }
+}
+
+/* Evaluates nodes[0..nnodes), a postfix boolean expression from listener.c's parse_search_key(), against one message. */
+static int
+search_eval(const struct search_node *nodes, uint32_t nnodes,
+ const struct search_msg_ctx *m)
+{
+ int stack[SEARCH_PROGRAM_MAX_NODES];
+ uint32_t sp = 0, i;
+
+ for (i = 0; i < nnodes; i++) {
+ const struct search_node *n = &nodes[i];
+
+ switch (n->op) {
+ case SEARCH_OP_AND:
+ if (sp < 2)
+ return (0);
+ sp--;
+ stack[sp - 1] = stack[sp - 1] && stack[sp];
+ break;
+ case SEARCH_OP_OR:
+ if (sp < 2)
+ return (0);
+ sp--;
+ stack[sp - 1] = stack[sp - 1] || stack[sp];
+ break;
+ case SEARCH_OP_NOT:
+ if (sp < 1)
+ return (0);
+ stack[sp - 1] = !stack[sp - 1];
+ break;
+ default:
+ if (sp >= SEARCH_PROGRAM_MAX_NODES)
+ return (0);
+ stack[sp++] = search_eval_leaf(n, m);
+ break;
+ }
+ }
+
+ return (sp == 1 ? stack[0] : 0);
+}
+
+/* IMSG_MBOX_SEARCH: LOCK_SH, iterates every message (no shortcut range), evaluating search_eval() for each; streams matches, then IMSG_MBOX_RESULT. */
+void
+handle_mbox_search(struct imsg_mbox_search *req, struct search_node *nodes,
+ uint32_t nnodes, struct imsgev *iev)
+{
+ struct mbox_index idx;
+ struct imsg_mbox_result result;
+ int fd;
+ int ok = 1;
+ uint32_t sent = 0;
+ uint32_t max_uid = 0;
+ uint32_t i;
+
+ (void)req; /* nnodes, passed separately, is currently its only field */
+
+ memset(&idx, 0, sizeof(idx));
+
+ if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+ log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+ ok = 0;
+ goto done;
+ }
+ if (flock(fd, LOCK_SH) == -1) {
+ log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+ close(fd);
+ ok = 0;
+ goto done;
+ }
+ if (index_load(fd, &idx) == -1) {
+ flock(fd, LOCK_UN);
+ close(fd);
+ ok = 0;
+ goto done;
+ }
+ flock(fd, LOCK_UN);
+ close(fd);
+
+ max_uid = index_max_uid(&idx); /* shared with UID FETCH/STORE/EXPUNGE's own "*" resolution */
+
+ for (i = 0; i < nnodes; i++) {
+ struct search_node *n = &nodes[i];
+
+ if (n->op != SEARCH_OP_SEQSET && n->op != SEARCH_OP_UIDSET)
+ continue;
+
+ if (n->lo_is_star)
+ n->seq_lo = (n->op == SEARCH_OP_SEQSET) ?
+ (uint32_t)idx.nlines : max_uid;
+ if (n->hi_is_star)
+ n->seq_hi = (n->op == SEARCH_OP_SEQSET) ?
+ (uint32_t)idx.nlines : max_uid;
+ }
+
+ for (i = 1; i <= (uint32_t)idx.nlines; i++) {
+ struct search_msg_ctx m;
+ struct index_rec rec;
+ const char *letters;
+ off_t size = 0;
+ char suffix[64];
+
+ if (index_parse_line(idx.lines[i - 1], &rec) == -1)
+ continue;
+
+ memset(&m, 0, sizeof(m));
+ m.seqno = i;
+ m.uid = rec.uid;
+ m.modseq = rec.modseq;
+
+ if (locate_message_file(rec.basename, &size, suffix,
+ sizeof(suffix)) == -1) {
+ log_warnx("session %u: message %s (uid %u) indexed "
+ "but missing on disk -- skipped", session_id,
+ rec.basename, m.uid);
+ continue;
+ }
+
+ letters = strstr(suffix, "2,");
+ letters = (letters != NULL) ? letters + 2 : "";
+ m.sysflags = letters_to_sysflags(letters);
+ m.keywords = rec.keywords;
+ m.internaldate = parse_maildir_timestamp(rec.basename);
+ m.size = (uint64_t)size;
+
+ if (search_eval(nodes, nnodes, &m)) {
+ struct imsg_mbox_search_match match;
+
+ memset(&match, 0, sizeof(match));
+ match.seqno = i;
+ match.uid = m.uid;
+ match.modseq = m.modseq;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_SEARCH_MATCH,
+ 0, 0, -1, &match, sizeof(match)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_SEARCH_MATCH", session_id);
+ else
+ sent++;
+ }
+ }
+
+done:
+ index_free(&idx);
+
+ memset(&result, 0, sizeof(result));
+ result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
+ result.count = sent;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+ sizeof(result)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+ session_id);
+ imsgev_add(iev);
+}
+
blob - /dev/null
blob + d87a5cd657f34a7b8e207398875e4bd8e2f99e1a (mode 644)
--- /dev/null
+++ src/mbox_store.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.
+ */
+
+/*
+ * mbox_store.c -- STORE and EXPUNGE request handling.
+ */
+
+#include <sys/types.h>
+#include <sys/file.h>
+#include <sys/stat.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <event.h>
+#include <fcntl.h>
+#include <imsg.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "store_internal.h"
+
+/* IMSG_MBOX_STORE (RFC 9051 SS6.4.6); LOCK_EX (mutates index+filename); RFC 7162 UNCHANGEDSINCE misses go out as STORE_MODIFIED and force the FETCH echo despite .SILENT; one shared modseq bump per command. */
+void
+handle_mbox_store(struct imsg_mbox_store *req, struct imsgev *iev)
+{
+ struct mbox_index idx;
+ struct imsg_mbox_result result;
+ int fd = -1;
+ uint32_t lo, hi, i, sent = 0;
+ uint64_t new_modseq;
+ int ok = 1, changed = 0, locked = 0;
+
+ memset(&idx, 0, sizeof(idx));
+
+ if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+ log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+ ok = 0;
+ goto done;
+ }
+ if (flock(fd, LOCK_EX) == -1) {
+ log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+ ok = 0;
+ goto done;
+ }
+ locked = 1;
+
+ if (index_load(fd, &idx) == -1) {
+ ok = 0;
+ goto done;
+ }
+
+ new_modseq = idx.highestmodseq + 1;
+
+ /* RFC 9051 SS6.4.9: UID STORE's sequence-set is UID-space, same lo/hi resolution switch as handle_mbox_fetch() */
+ if (req->by_uid) {
+ uint32_t max_uid = index_max_uid(&idx);
+
+ lo = req->lo_is_star ? max_uid : req->seq_lo;
+ hi = req->hi_is_star ? max_uid : req->seq_hi;
+ } else {
+ lo = req->lo_is_star ? (uint32_t)idx.nlines : req->seq_lo;
+ hi = req->hi_is_star ? (uint32_t)idx.nlines : req->seq_hi;
+ }
+ if (lo < 1)
+ lo = 1;
+ if (!req->by_uid && hi > (uint32_t)idx.nlines)
+ hi = (uint32_t)idx.nlines;
+
+ for (i = 1; i <= (uint32_t)idx.nlines; i++) {
+ struct imsg_mbox_fetch_meta meta;
+ struct index_rec rec;
+ const char *lp;
+ uint32_t old_sysflags, new_sysflags;
+ char suffix[64], newletters[8];
+ char newkeywords[MBOX_FLAGS_MAX];
+ char newline[STORE_INDEX_LINE_MAX];
+ char oldpath[600], newpath[600];
+ off_t size;
+ uint64_t this_modseq;
+ 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 < lo)
+ continue;
+ if (rec.uid > hi)
+ break;
+ } else {
+ if (i < lo)
+ continue;
+ if (i > hi)
+ break;
+ }
+
+ if (req->has_unchangedsince &&
+ rec.modseq > req->unchangedsince) {
+ struct imsg_mbox_store_modified mod;
+
+ memset(&mod, 0, sizeof(mod));
+ mod.seqno = i;
+ mod.uid = rec.uid;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_STORE_MODIFIED,
+ 0, 0, -1, &mod, sizeof(mod)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_STORE_MODIFIED", session_id);
+ continue;
+ }
+
+ if (locate_message_file(rec.basename, &size, suffix,
+ sizeof(suffix)) == -1) {
+ log_warnx("session %u: message %s (uid %u) indexed "
+ "but missing on disk -- skipped", session_id,
+ rec.basename, rec.uid);
+ continue;
+ }
+ in_new = (suffix[0] == '\0');
+
+ lp = strstr(suffix, "2,");
+ old_sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
+
+ switch (req->mode) {
+ case MBOX_STORE_SET:
+ new_sysflags = req->sysflags;
+ break;
+ case MBOX_STORE_ADD:
+ new_sysflags = old_sysflags | req->sysflags;
+ break;
+ case MBOX_STORE_REMOVE:
+ default:
+ new_sysflags = old_sysflags & ~req->sysflags;
+ break;
+ }
+ merge_keywords(req->mode, rec.keywords, req->keywords,
+ newkeywords, sizeof(newkeywords));
+ sysflags_to_letters(new_sysflags, newletters,
+ sizeof(newletters));
+
+ real_change = (new_sysflags != old_sysflags) ||
+ (strcmp(newkeywords, rec.keywords) != 0);
+ this_modseq = real_change ? new_modseq : rec.modseq;
+
+ /* once targeted by STORE, message moves new/ -> cur/ with explicit ":2,<letters>" suffix; never cur/ -> new/ */
+ if (in_new || strcmp(lp != NULL ? lp + 2 : "",
+ newletters) != 0) {
+ if (snprintf(oldpath, sizeof(oldpath), "%s/%s%s",
+ in_new ? "new" : "cur", rec.basename, suffix) >=
+ (int)sizeof(oldpath) ||
+ snprintf(newpath, sizeof(newpath), "cur/%s:2,%s",
+ rec.basename, newletters) >= (int)sizeof(newpath)) {
+ log_warnx("session %u: path too long for %s",
+ session_id, rec.basename);
+ continue;
+ }
+ if (rename(oldpath, newpath) == -1) {
+ log_warn("session %u: rename %s -> %s",
+ session_id, oldpath, newpath);
+ continue;
+ }
+ }
+
+ len = snprintf(newline, sizeof(newline), "%u:%s:%s:%llu",
+ rec.uid, rec.basename, newkeywords,
+ (unsigned long long)this_modseq);
+ if (len < 0 || (size_t)len >= sizeof(newline)) {
+ log_warnx("session %u: new index line too long for "
+ "%s", session_id, rec.basename);
+ continue;
+ }
+ free(idx.lines[i - 1]);
+ if ((idx.lines[i - 1] = strdup(newline)) == NULL) {
+ log_warn("session %u: strdup index line", session_id);
+ ok = 0;
+ goto done;
+ }
+ if (real_change)
+ changed = 1;
+
+ /* SS3.1.3: UNCHANGEDSINCE forces the FETCH echo even under .SILENT, for every message that passed */
+ send_fetch = !req->silent || req->has_unchangedsince;
+
+ if (send_fetch) {
+ char newsuffix[16];
+
+ memset(&meta, 0, sizeof(meta));
+ meta.seqno = i;
+ meta.uid = rec.uid;
+ meta.modseq = this_modseq;
+ snprintf(newsuffix, sizeof(newsuffix), "2,%s",
+ newletters);
+ build_flags_string(newsuffix, newkeywords, meta.flags,
+ sizeof(meta.flags));
+
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_FETCH_META, 0,
+ 0, -1, &meta, sizeof(meta)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_FETCH_META", session_id);
+ else
+ sent++;
+ }
+ }
+
+ if (changed) {
+ idx.highestmodseq = new_modseq;
+ if (index_save(&idx) == -1)
+ ok = 0;
+ }
+
+done:
+ if (locked)
+ flock(fd, LOCK_UN);
+ if (fd != -1)
+ close(fd);
+
+ memset(&result, 0, sizeof(result));
+ result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
+ result.count = sent;
+ result.highestmodseq = idx.highestmodseq;
+ index_free(&idx);
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+ sizeof(result)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+ session_id);
+ imsgev_add(iev);
+}
+
+/* IMSG_MBOX_EXPUNGE (also used, silent=1, by CLOSE); LOCK_EX; two-pointer compaction yields SS7.5.1's decremented seqnos for free; unclassifiable messages kept, not dropped. */
+void
+handle_mbox_expunge(struct imsg_mbox_expunge *req, struct imsgev *iev)
+{
+ struct mbox_index idx;
+ struct imsg_mbox_result result;
+ int fd = -1;
+ size_t in, out;
+ uint32_t sent = 0;
+ uint32_t uid_lo, uid_hi;
+ int ok = 1, changed = 0, locked = 0;
+
+ memset(&idx, 0, sizeof(idx));
+
+ if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+ log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+ ok = 0;
+ goto done;
+ }
+ if (flock(fd, LOCK_EX) == -1) {
+ log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+ ok = 0;
+ goto done;
+ }
+ locked = 1;
+
+ if (index_load(fd, &idx) == -1) {
+ ok = 0;
+ goto done;
+ }
+
+ /* UID EXPUNGE: resolve the UID range once, same index_max_uid() "*" resolution as UID FETCH/STORE */
+ uid_lo = uid_hi = 0;
+ if (req->by_uid) {
+ uint32_t max_uid = index_max_uid(&idx);
+
+ uid_lo = req->lo_is_star ? max_uid : req->seq_lo;
+ uid_hi = req->hi_is_star ? max_uid : req->seq_hi;
+ if (uid_lo < 1)
+ uid_lo = 1;
+ }
+
+ out = 0;
+ for (in = 0; in < idx.nlines; in++) {
+ struct index_rec rec;
+ const char *lp;
+ uint32_t sysflags;
+ char suffix[64], path[600];
+ off_t size;
+
+ if (index_parse_line(idx.lines[in], &rec) == -1) {
+ idx.lines[out++] = idx.lines[in];
+ continue;
+ }
+
+ if (locate_message_file(rec.basename, &size, suffix,
+ sizeof(suffix)) == -1) {
+ log_warnx("session %u: message %s indexed but missing "
+ "on disk -- kept in index, not counted as "
+ "expunged", session_id, rec.basename);
+ idx.lines[out++] = idx.lines[in];
+ continue;
+ }
+
+ lp = strstr(suffix, "2,");
+ sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
+ if (!(sysflags & MBOX_FLAG_DELETED)) {
+ idx.lines[out++] = idx.lines[in];
+ continue;
+ }
+
+ /* SS6.4.9: a \Deleted message outside the UID EXPUNGE range is kept, same as "not \Deleted" above */
+ if (req->by_uid && (rec.uid < uid_lo || rec.uid > uid_hi)) {
+ idx.lines[out++] = idx.lines[in];
+ continue;
+ }
+
+ if (snprintf(path, sizeof(path), "%s/%s%s",
+ suffix[0] == '\0' ? "new" : "cur", rec.basename, suffix) >=
+ (int)sizeof(path)) {
+ log_warnx("session %u: path too long for %s -- kept "
+ "in index", session_id, rec.basename);
+ idx.lines[out++] = idx.lines[in];
+ continue;
+ }
+ if (unlink(path) == -1 && errno != ENOENT) {
+ log_warn("session %u: unlink %s", session_id, path);
+ idx.lines[out++] = idx.lines[in];
+ continue;
+ }
+
+ if (!req->silent) {
+ struct imsg_mbox_expunged exp;
+
+ memset(&exp, 0, sizeof(exp));
+ exp.seqno = (uint32_t)(out + 1);
+ exp.uid = rec.uid;
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0,
+ -1, &exp, sizeof(exp)) == -1)
+ log_warn("session %u: imsg_compose "
+ "IMSG_MBOX_EXPUNGED", session_id);
+ else
+ sent++;
+ }
+
+ free(idx.lines[in]);
+ changed = 1;
+ }
+ idx.nlines = out;
+
+ if (changed) {
+ idx.highestmodseq++;
+ if (index_save(&idx) == -1)
+ ok = 0;
+ }
+
+done:
+ if (locked)
+ flock(fd, LOCK_UN);
+ if (fd != -1)
+ close(fd);
+
+ memset(&result, 0, sizeof(result));
+ result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
+ result.count = sent;
+ result.highestmodseq = idx.highestmodseq;
+ index_free(&idx);
+ if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+ sizeof(result)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+ session_id);
+ imsgev_add(iev);
+}
blob - /dev/null
blob + aee52ad2f5fc396dbbc89817817034937985e94e (mode 644)
--- /dev/null
+++ src/mime.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.
+ */
+
+/*
+ * 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 <sys/stat.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <event.h>
+#include <fcntl.h>
+#include <imsg.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "store_internal.h"
+
+int
+locate_message_file(const char *basename, off_t *size_out, char *suffix_out,
+ size_t suffix_out_size)
+{
+ struct stat st;
+ char path[600];
+
+ suffix_out[0] = '\0';
+
+ if (snprintf(path, sizeof(path), "new/%s", basename) >=
+ (int)sizeof(path)) {
+ log_warnx("session %u: basename too long: %s", session_id,
+ basename);
+ return (-1);
+ }
+ if (stat(path, &st) == 0) {
+ *size_out = st.st_size;
+ return (0);
+ }
+ if (errno != ENOENT) {
+ log_warn("session %u: stat %s", session_id, path);
+ return (-1);
+ }
+
+ {
+ DIR *dp;
+ const struct dirent *de;
+ size_t baselen = strlen(basename);
+ int rv = -1;
+
+ if ((dp = opendir("cur")) == NULL) {
+ if (errno != ENOENT)
+ log_warn("session %u: opendir cur", session_id);
+ return (-1);
+ }
+ while ((de = readdir(dp)) != NULL) {
+ if (strncmp(de->d_name, basename, baselen) != 0 ||
+ de->d_name[baselen] != ':')
+ continue;
+ if (snprintf(path, sizeof(path), "cur/%s", de->d_name)
+ >= (int)sizeof(path))
+ break;
+ if (stat(path, &st) == 0) {
+ if (strlcpy(suffix_out, de->d_name + baselen,
+ suffix_out_size) >= suffix_out_size) {
+ log_warnx("session %u: %s: flag "
+ "suffix truncated -- refusing to "
+ "report a possibly-wrong flag "
+ "set", session_id, de->d_name);
+ } else {
+ *size_out = st.st_size;
+ rv = 0;
+ }
+ }
+ break;
+ }
+ closedir(dp);
+ return (rv);
+ }
+}
+
+/* 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)
+{
+ char path[600];
+ int fd;
+
+ if (snprintf(path, sizeof(path), "new/%s", basename) >=
+ (int)sizeof(path)) {
+ log_warnx("session %u: basename too long: %s", session_id,
+ basename);
+ return (-1);
+ }
+ if ((fd = open(path, O_RDONLY)) != -1)
+ return (fd);
+ if (errno != ENOENT) {
+ log_warn("session %u: open %s", session_id, path);
+ return (-1);
+ }
+
+ {
+ DIR *dp;
+ const struct dirent *de;
+ size_t baselen = strlen(basename);
+ int rv = -1;
+
+ if ((dp = opendir("cur")) == NULL) {
+ if (errno != ENOENT)
+ log_warn("session %u: opendir cur", session_id);
+ return (-1);
+ }
+ while ((de = readdir(dp)) != NULL) {
+ if (strncmp(de->d_name, basename, baselen) != 0 ||
+ de->d_name[baselen] != ':')
+ continue;
+ if (snprintf(path, sizeof(path), "cur/%s", de->d_name)
+ >= (int)sizeof(path))
+ break;
+ rv = open(path, O_RDONLY);
+ break;
+ }
+ closedir(dp);
+ return (rv);
+ }
+}
+
+/* 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)
+{
+ char readbuf[FETCH_HEADER_MAX + 1];
+ int fd;
+ ssize_t n, total = 0;
+ size_t i;
+ int sepindex = -1;
+
+ if ((fd = open_message_file(basename)) == -1)
+ return (-1);
+
+ while (total < (ssize_t)sizeof(readbuf)) {
+ n = read(fd, readbuf + total, sizeof(readbuf) - total);
+ if (n == -1) {
+ log_warn("session %u: read message header (%s)",
+ session_id, basename);
+ close(fd);
+ return (-1);
+ }
+ if (n == 0)
+ break;
+ total += n;
+ }
+ close(fd);
+
+ for (i = 0; i + 1 < (size_t)total; i++) {
+ if (readbuf[i] == '\0') {
+ log_warnx("session %u: message %s has a NUL byte in "
+ "its header -- BODY.PEEK[HEADER] skipped",
+ session_id, basename);
+ return (-1);
+ }
+ if (i + 3 < (size_t)total && readbuf[i] == '\r' &&
+ readbuf[i + 1] == '\n' && readbuf[i + 2] == '\r' &&
+ readbuf[i + 3] == '\n') {
+ sepindex = (int)i + 4;
+ break;
+ }
+ if (readbuf[i] == '\n' && readbuf[i + 1] == '\n') {
+ sepindex = (int)i + 2;
+ break;
+ }
+ }
+
+ if (sepindex == -1 || sepindex > FETCH_HEADER_MAX) {
+ log_warnx("session %u: message %s: no header/body separator "
+ "found within %d bytes -- BODY.PEEK[HEADER] skipped",
+ session_id, basename, FETCH_HEADER_MAX);
+ return (-1);
+ }
+
+ if ((*buf_out = malloc((size_t)sepindex)) == NULL) {
+ log_warn("session %u: malloc message header (%s)",
+ session_id, basename);
+ return (-1);
+ }
+ memcpy(*buf_out, readbuf, (size_t)sepindex);
+ *len_out = (uint32_t)sepindex;
+ 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 */
+int
+read_message_body(const char *basename, int text_only, size_t maxlen,
+ const char *label, char **buf_out, uint32_t *len_out)
+{
+ char *readbuf;
+ size_t readbuf_size = maxlen + 1;
+ int fd;
+ ssize_t n, total = 0;
+ size_t i;
+ int sepindex = -1;
+
+ *buf_out = NULL;
+ *len_out = 0;
+
+ if ((readbuf = malloc(readbuf_size)) == NULL) {
+ log_warn("session %u: malloc message body readbuf (%s)",
+ session_id, basename);
+ return (-1);
+ }
+
+ if ((fd = open_message_file(basename)) == -1) {
+ free(readbuf);
+ return (-1);
+ }
+
+ while (total < (ssize_t)readbuf_size) {
+ n = read(fd, readbuf + total, readbuf_size - total);
+ if (n == -1) {
+ log_warn("session %u: read message body (%s)",
+ session_id, basename);
+ close(fd);
+ free(readbuf);
+ return (-1);
+ }
+ if (n == 0)
+ break;
+ total += n;
+ }
+ close(fd);
+
+ if (total > (ssize_t)maxlen) {
+ log_warnx("session %u: message %s exceeds %zu bytes -- "
+ "%s skipped", session_id, basename, maxlen, label);
+ free(readbuf);
+ return (-1);
+ }
+
+ for (i = 0; i < (size_t)total; i++) {
+ if (readbuf[i] == '\0') {
+ log_warnx("session %u: message %s has a NUL byte -- "
+ "%s skipped", session_id, basename, label);
+ free(readbuf);
+ return (-1);
+ }
+ }
+
+ if (text_only) {
+ for (i = 0; i + 1 < (size_t)total; i++) {
+ if (i + 3 < (size_t)total && readbuf[i] == '\r' &&
+ readbuf[i + 1] == '\n' && readbuf[i + 2] == '\r' &&
+ readbuf[i + 3] == '\n') {
+ sepindex = (int)i + 4;
+ break;
+ }
+ if (readbuf[i] == '\n' && readbuf[i + 1] == '\n') {
+ sepindex = (int)i + 2;
+ break;
+ }
+ }
+ if (sepindex == -1) {
+ log_warnx("session %u: message %s: no header/body "
+ "separator found -- %s skipped",
+ session_id, basename, label);
+ free(readbuf);
+ return (-1);
+ }
+ } else {
+ sepindex = 0;
+ }
+
+ *len_out = (uint32_t)((size_t)total - (size_t)sepindex);
+ if (*len_out > 0) {
+ if ((*buf_out = malloc(*len_out)) == NULL) {
+ log_warn("session %u: malloc message body (%s)",
+ session_id, basename);
+ *len_out = 0;
+ free(readbuf);
+ return (-1);
+ }
+ memcpy(*buf_out, readbuf + sepindex, *len_out);
+ }
+ free(readbuf);
+ 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 */
+int
+header_field_name_matches(const char *name, size_t namelen, const char *list)
+{
+ char listcopy[HEADER_FIELDS_MAX];
+ char *tok, *save;
+
+ if (namelen == 0 || namelen >= sizeof(listcopy))
+ return (0);
+
+ if (strlcpy(listcopy, list, sizeof(listcopy)) >= sizeof(listcopy)) {
+ log_warnx("session %u: header_field_name_matches: list "
+ "truncated -- can't happen (list bounded by same-size "
+ "HEADER_FIELDS_MAX buffer upstream); treating as "
+ "not-found", session_id);
+ return (0);
+ }
+ for (tok = strtok_r(listcopy, " ", &save); tok != NULL;
+ tok = strtok_r(NULL, " ", &save)) {
+ if (strlen(tok) == namelen &&
+ strncasecmp(tok, name, namelen) == 0)
+ return (1);
+ }
+ 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 */
+int
+read_message_header_fields(const char *basename, const char *fields_spec,
+ int want_not, char **buf_out, uint32_t *len_out)
+{
+ char *hdrbuf = NULL;
+ uint32_t hdrlen = 0;
+ char *out;
+ size_t outlen = 0;
+ size_t off = 0;
+ int rc = -1;
+
+ *buf_out = NULL;
+ *len_out = 0;
+
+ if (read_message_header(basename, &hdrbuf, &hdrlen) == -1)
+ return (-1);
+
+ /* filtered output can never exceed the unfiltered header's size -- every byte copied below comes verbatim from hdrbuf */
+ if ((out = malloc(hdrlen)) == NULL) {
+ log_warn("session %u: malloc HEADER.FIELDS buffer (%s)",
+ session_id, basename);
+ free(hdrbuf);
+ return (-1);
+ }
+
+ while (off < hdrlen) {
+ size_t field_start = off, line_end, i;
+ int is_blank, matched, include;
+
+ 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;
+ 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;
+
+ while (j < hdrlen && hdrbuf[j] != '\n')
+ j++;
+ if (j >= hdrlen) {
+ off = hdrlen;
+ break;
+ }
+ off = j + 1;
+ }
+
+ {
+ 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);
+
+ if (rc == -1) {
+ free(out);
+ return (-1);
+ }
+
+ *buf_out = out;
+ *len_out = (uint32_t)outlen;
+ 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 */
+int
+extract_header_field(const char *hdr, size_t hdrlen, const char *name,
+ char **val_out, size_t *vallen_out)
+{
+ size_t namelen = strlen(name);
+ size_t off = 0;
+
+ *val_out = NULL;
+ *vallen_out = 0;
+
+ while (off < hdrlen) {
+ size_t field_start = off, line_end, i, k;
+
+ 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;
+
+ 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)
+ continue;
+
+ {
+ size_t vstart = k + 1;
+ size_t vend = off;
+ size_t p;
+ char *out;
+ size_t outlen = 0;
+
+ 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);
+
+ 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++;
+ }
+
+ *val_out = out;
+ *vallen_out = outlen;
+ return (0);
+ }
+ }
+
+ return (-1);
+}
+
+int
+find_header_body_split(const char *buf, size_t len, size_t *hdrend_out)
+{
+ size_t i;
+
+ for (i = 0; i + 1 < len; i++) {
+ if (i + 3 < len && buf[i] == '\r' && buf[i + 1] == '\n' &&
+ buf[i + 2] == '\r' && buf[i + 3] == '\n') {
+ *hdrend_out = i + 4;
+ return (0);
+ }
+ if (buf[i] == '\n' && buf[i + 1] == '\n') {
+ *hdrend_out = i + 2;
+ return (0);
+ }
+ }
+ return (-1);
+}
+
+/* RFC 2045 SS5.1 tspecials; a `token` is any US-ASCII CHAR except SPACE, CTLs, or one of these */
+int
+mime_is_tspecial(char c)
+{
+ return (strchr("()<>@,;:\\\"/[]?=", c) != NULL);
+}
+
+/* reads one RFC 2045 token/quoted-string at s[*pos], advancing *pos; undoes RFC 822 quoted-pair escaping ("\" + one CHAR) in quotes */
+int
+mime_read_token_or_qstring(const char *s, size_t len, size_t *pos,
+ char *out, size_t outsize)
+{
+ size_t outlen = 0;
+
+ if (outsize == 0)
+ return (-1);
+
+ if (*pos < len && s[*pos] == '"') {
+ (*pos)++;
+ while (*pos < len && s[*pos] != '"') {
+ char c = s[*pos];
+
+ if (c == '\\' && *pos + 1 < len) {
+ (*pos)++;
+ c = s[*pos];
+ }
+ if (outlen + 1 >= outsize)
+ return (-1);
+ out[outlen++] = c;
+ (*pos)++;
+ }
+ if (*pos >= len)
+ return (-1); /* unterminated quoted-string */
+ (*pos)++;
+ out[outlen] = '\0';
+ return (0);
+ }
+
+ while (*pos < len && !mime_is_tspecial(s[*pos]) &&
+ s[*pos] != ' ' && s[*pos] != '\t' &&
+ (unsigned char)s[*pos] >= 0x20 && (unsigned char)s[*pos] != 0x7f) {
+ if (outlen + 1 >= outsize)
+ return (-1);
+ out[outlen++] = s[*pos];
+ (*pos)++;
+ }
+ out[outlen] = '\0';
+ if (outlen == 0)
+ return (-1);
+ return (0);
+}
+
+/* in-place ASCII-range uppercase, to canonicalize MIME type/subtype/attribute names (RFC 9051 SS7.5.2 examples); values left as-is */
+void
+mime_str_upper(char *s)
+{
+ for (; *s != '\0'; s++) {
+ if (*s >= 'a' && *s <= 'z')
+ *s -= ('a' - 'A');
+ }
+}
+
+/* RFC 2045 SS5.1 Content-Type parse into type/subtype (uppercased) + params_fmt_out (RFC 9051 body-fld-param); SS5.2 default on failure */
+int
+parse_content_type(const char *hdr, size_t hdrlen, char *type_out,
+ size_t typesize, char *subtype_out, size_t subtypesize,
+ char *params_fmt_out, size_t params_fmt_outsize, char *boundary_out,
+ size_t boundary_outsize, int *has_boundary_out)
+{
+ char *val = NULL;
+ size_t vallen = 0;
+ size_t pos = 0;
+ size_t plen = 0;
+ int nparams = 0;
+ int use_default = 0;
+ size_t pfsize = (params_fmt_outsize > 0) ? params_fmt_outsize - 1 : 0; /* -1: envbuf_append*() never NUL-terminates, reserve a byte */
+
+ *has_boundary_out = 0;
+ boundary_out[0] = '\0';
+
+ if (pfsize == 0)
+ return (-1);
+
+ if (extract_header_field(hdr, hdrlen, "Content-Type", &val,
+ &vallen) == -1 || vallen == 0)
+ use_default = 1;
+
+ if (!use_default && (mime_read_token_or_qstring(val, vallen, &pos,
+ type_out, typesize) == -1 || pos >= vallen || val[pos] != '/'))
+ use_default = 1;
+ if (!use_default) {
+ pos++;
+ if (mime_read_token_or_qstring(val, vallen, &pos, subtype_out,
+ subtypesize) == -1)
+ use_default = 1;
+ }
+
+ if (use_default) {
+ free(val);
+ if (strlcpy(type_out, "TEXT", typesize) >= typesize ||
+ strlcpy(subtype_out, "PLAIN", subtypesize) >=
+ subtypesize) {
+ log_warnx("session %u: parse_content_type: default "
+ "type/subtype truncated -- caller-supplied "
+ "buffer too small for \"TEXT\"/\"PLAIN\"",
+ session_id);
+ return (-1);
+ }
+ if (envbuf_append_str(params_fmt_out, pfsize, &plen,
+ "(\"CHARSET\" \"US-ASCII\")") == -1)
+ return (-1);
+ params_fmt_out[plen] = '\0';
+ return (0);
+ }
+
+ mime_str_upper(type_out);
+ mime_str_upper(subtype_out);
+
+ for (;;) {
+ char attr[64], value[256];
+
+ while (pos < vallen && (val[pos] == ' ' || val[pos] == '\t'))
+ pos++;
+ if (pos >= vallen || val[pos] != ';')
+ break;
+ pos++;
+ while (pos < vallen && (val[pos] == ' ' || val[pos] == '\t'))
+ pos++;
+ if (mime_read_token_or_qstring(val, vallen, &pos, attr,
+ sizeof(attr)) == -1)
+ break;
+ while (pos < vallen && (val[pos] == ' ' || val[pos] == '\t'))
+ pos++;
+ if (pos >= vallen || val[pos] != '=')
+ break;
+ pos++;
+ while (pos < vallen && (val[pos] == ' ' || val[pos] == '\t'))
+ pos++;
+ if (mime_read_token_or_qstring(val, vallen, &pos, value,
+ sizeof(value)) == -1)
+ break;
+
+ mime_str_upper(attr);
+ if (envbuf_append_str(params_fmt_out, pfsize,
+ &plen, nparams == 0 ? "(" : " ") == -1 ||
+ envbuf_append_nstring(params_fmt_out, pfsize,
+ &plen, attr, strlen(attr)) == -1 ||
+ envbuf_append(params_fmt_out, pfsize, &plen,
+ " ", 1) == -1 ||
+ envbuf_append_nstring(params_fmt_out, pfsize,
+ &plen, value, strlen(value)) == -1) {
+ free(val);
+ return (-1);
+ }
+ nparams++;
+
+ if (strcasecmp(attr, "BOUNDARY") == 0) {
+ /* boundary_out is RFC 2046 SS5.1.1-sized (70+1); an oversized value is "no BOUNDARY found", not truncated */
+ if (strlcpy(boundary_out, value, boundary_outsize) <
+ boundary_outsize)
+ *has_boundary_out = 1;
+ }
+ }
+
+ free(val);
+ if (nparams > 0) {
+ if (envbuf_append_str(params_fmt_out, pfsize, &plen,
+ ")") == -1)
+ return (-1);
+ params_fmt_out[plen] = '\0';
+ return (0);
+ }
+ if (envbuf_append_str(params_fmt_out, pfsize, &plen, "NIL") == -1)
+ return (-1);
+ params_fmt_out[plen] = '\0';
+ return (0);
+}
+
+/* RFC 2046 SS5.1.1: splits multipart body into body-part spans (not yet header/body split, caller uses find_header_body_split()) */
+int
+split_multipart(const char *body, size_t bodylen, const char *boundary,
+ size_t *part_starts, size_t *part_ends, int *nparts_out, int maxparts)
+{
+ char needle[2 + 70 + 1]; /* "--" + boundary; RFC 2046 SS5.1.1 caps boundary at 70 characters */
+ size_t needlelen;
+ size_t pos = 0;
+ int found_first = 0;
+ int n = 0;
+ int closed = 0;
+
+ if (strlcpy(needle, "--", sizeof(needle)) >= sizeof(needle)) {
+ log_warnx("session %u: split_multipart: \"--\" truncated -- "
+ "can't happen (2 bytes into a 73-byte buffer)",
+ session_id);
+ return (-1);
+ }
+ needlelen = strlcat(needle, boundary, sizeof(needle));
+ if (needlelen >= sizeof(needle))
+ return (-1); /* boundary too long -- non-conformant */
+
+ *nparts_out = 0;
+
+ while (pos < bodylen) {
+ int at_bol = (pos == 0) ||
+ (pos >= 2 && body[pos - 2] == '\r' &&
+ body[pos - 1] == '\n') ||
+ (pos >= 1 && body[pos - 1] == '\n');
+ size_t after;
+ int is_close = 0;
+
+ if (!at_bol || pos + needlelen > bodylen ||
+ memcmp(body + pos, needle, needlelen) != 0) {
+ pos++;
+ continue;
+ }
+
+ after = pos + needlelen;
+ if (after + 1 < bodylen && body[after] == '-' &&
+ body[after + 1] == '-') {
+ is_close = 1;
+ after += 2;
+ }
+ while (after < bodylen &&
+ (body[after] == ' ' || body[after] == '\t'))
+ after++;
+ if (after < bodylen && body[after] != '\r' &&
+ body[after] != '\n') {
+ pos++; /* not followed by CRLF/LF/EOF -- coincidental match inside content, not a delimiter */
+ continue;
+ }
+
+ if (found_first) {
+ size_t content_end = pos;
+ size_t part_start = part_starts[n - 1];
+
+ /* bound strip at part_start: else an empty part underflows the unsigned length into a huge memcpy/scan bound */
+ if (content_end >= part_start + 2 &&
+ body[content_end - 2] == '\r' &&
+ body[content_end - 1] == '\n')
+ content_end -= 2;
+ else if (content_end >= part_start + 1 &&
+ body[content_end - 1] == '\n')
+ content_end -= 1;
+ part_ends[n - 1] = content_end;
+ }
+
+ if (is_close) {
+ closed = 1;
+ break;
+ }
+
+ if (after < bodylen && body[after] == '\r' &&
+ after + 1 < bodylen && body[after + 1] == '\n')
+ after += 2;
+ else if (after < bodylen && body[after] == '\n')
+ after += 1;
+ else
+ break; /* delimiter runs to end of body with no CRLF -- no body-part can follow */
+
+ if (n >= maxparts)
+ return (-1);
+ part_starts[n] = after;
+ n++;
+ found_first = 1;
+ pos = after;
+ }
+
+ if (!closed || !found_first)
+ return (-1);
+
+ *nparts_out = n;
+ return (0);
+}
+
+/* parses a dotted section-part string (e.g. "1.2.3") into path[]; RFC 9051 SS6.4.5 section-part := nz-number *("." nz-number) */
+int
+parse_section_part(const char *s, int *path, int maxpath)
+{
+ int n = 0;
+
+ if (s == NULL || *s == '\0')
+ return (-1);
+
+ while (*s != '\0') {
+ long val;
+ char *end;
+
+ if (*s < '1' || *s > '9') /* nz-number: digit-nz first */
+ return (-1);
+ if (n >= maxpath)
+ return (-1);
+
+ errno = 0;
+ val = strtol(s, &end, 10);
+ if (val <= 0 || val > INT_MAX || errno != 0)
+ return (-1);
+ path[n++] = (int)val;
+
+ s = end;
+ if (*s == '\0')
+ break;
+ if (*s != '.')
+ return (-1);
+ s++;
+ if (*s == '\0')
+ return (-1); /* trailing dot */
+ }
+ return (n);
+}
+
+/* recursive descent once path[0] establishes MULTIPART; walks exactly one child per level (the one path[0] names), mirrors build_body_structure() */
+int
+find_mime_part(int depth, const char *hdr, size_t hdrlen, const char *body,
+ size_t bodylen, const int *path, int pathlen, const char **part_out,
+ size_t *partlen_out)
+{
+ char type[64], subtype[64];
+ char params_fmt[600];
+ char boundary[70 + 1];
+ int has_boundary;
+ size_t part_starts[MIME_MAX_PARTS], part_ends[MIME_MAX_PARTS];
+ int n, want;
+ const char *pbuf;
+ size_t plen, phdrend;
+
+ if (depth > MIME_MAX_DEPTH)
+ return (-1);
+
+ params_fmt[0] = '\0';
+ if (parse_content_type(hdr, hdrlen, type, sizeof(type), subtype,
+ sizeof(subtype), params_fmt, sizeof(params_fmt), boundary,
+ sizeof(boundary), &has_boundary) == -1)
+ return (-1);
+ if (strcasecmp(type, "MULTIPART") != 0 || !has_boundary)
+ return (-1);
+ if (split_multipart(body, bodylen, boundary, part_starts, part_ends,
+ &n, MIME_MAX_PARTS) == -1)
+ return (-1);
+
+ want = path[0];
+ if (want < 1 || want > n)
+ return (-1); /* no such part at this level */
+
+ pbuf = body + part_starts[want - 1];
+ plen = part_ends[want - 1] - part_starts[want - 1];
+ /* same empty-part handling as build_body_structure() */
+ if (plen == 0)
+ phdrend = 0;
+ else if (find_header_body_split(pbuf, plen, &phdrend) == -1)
+ return (-1);
+
+ if (pathlen == 1) {
+ /* path consumed; must be a genuine leaf (not MULTIPART/MESSAGE-RFC822|GLOBAL) -- no "combined children" concept exists */
+ char ctype[64], csub[64], cparams[600];
+ char cboundary[70 + 1];
+ int chb;
+
+ cparams[0] = '\0';
+ if (parse_content_type(pbuf, phdrend, ctype, sizeof(ctype),
+ csub, sizeof(csub), cparams, sizeof(cparams), cboundary,
+ sizeof(cboundary), &chb) == -1)
+ return (-1);
+ if (strcasecmp(ctype, "MULTIPART") == 0 ||
+ (strcasecmp(ctype, "MESSAGE") == 0 &&
+ (strcasecmp(csub, "RFC822") == 0 ||
+ strcasecmp(csub, "GLOBAL") == 0)))
+ return (-1);
+
+ *part_out = pbuf + phdrend;
+ *partlen_out = plen - phdrend;
+ return (0);
+ }
+
+ return (find_mime_part(depth + 1, pbuf, phdrend, pbuf + phdrend,
+ plen - phdrend, path + 1, pathlen - 1, part_out, partlen_out));
+}
+
+/* locates a leaf MIME part by dotted path; handles RFC 9051 SS6.4.5.1's non-multipart top-level case (own body = section-part "1") */
+int
+locate_mime_part(const char *hdr, size_t hdrlen, const char *body,
+ size_t bodylen, const int *path, int pathlen, const char **part_out,
+ size_t *partlen_out)
+{
+ char type[64], subtype[64];
+ char params_fmt[600];
+ char boundary[70 + 1];
+ int has_boundary;
+
+ if (pathlen < 1)
+ return (-1);
+
+ params_fmt[0] = '\0';
+ if (parse_content_type(hdr, hdrlen, type, sizeof(type), subtype,
+ sizeof(subtype), params_fmt, sizeof(params_fmt), boundary,
+ sizeof(boundary), &has_boundary) == -1)
+ return (-1);
+
+ if (strcasecmp(type, "MULTIPART") == 0)
+ return (find_mime_part(1, hdr, hdrlen, body, bodylen, path,
+ pathlen, part_out, partlen_out));
+
+ if (pathlen != 1 || path[0] != 1)
+ return (-1);
+ *part_out = body;
+ *partlen_out = bodylen;
+ return (0);
+}
+
+/* applies RFC 9051 SS6.4.5 "<start.count>" to content, computing *out and *outlen (a subrange, no copy); clamped to FETCH_PART_MAX */
+void
+apply_partial_range(const char *content, size_t contentlen, int has_partial,
+ uint32_t partial_start, uint32_t partial_count, const char **out,
+ size_t *outlen)
+{
+ if (!has_partial) {
+ *out = content;
+ *outlen = contentlen;
+ if (*outlen > FETCH_PART_MAX)
+ *outlen = FETCH_PART_MAX;
+ return;
+ }
+
+ if (partial_start >= contentlen) {
+ *out = content;
+ *outlen = 0;
+ return;
+ }
+
+ *out = content + partial_start;
+ *outlen = contentlen - partial_start;
+ if (*outlen > partial_count)
+ *outlen = partial_count;
+ if (*outlen > FETCH_PART_MAX)
+ *outlen = FETCH_PART_MAX;
+}
+
+/* BODY.PEEK[<section-part>] entry point: reads the whole message (bodystructure_read_max cap), locates the part, applies partial range */
+int
+extract_mime_part(const char *basename, const int *path, int pathlen,
+ int has_partial, uint32_t partial_start, uint32_t partial_count,
+ char **buf_out, uint32_t *len_out)
+{
+ char *wholebuf = NULL;
+ uint32_t wholelen = 0;
+ size_t hdrend;
+ const char *part = NULL;
+ size_t partlen = 0;
+ const char *out;
+ size_t outlen;
+
+ *buf_out = NULL;
+ *len_out = 0;
+
+ if (read_message_body(basename, 0, bodystructure_read_max,
+ "BODY[<part>]", &wholebuf, &wholelen) == -1)
+ return (-1);
+ if (wholelen == 0 ||
+ find_header_body_split(wholebuf, wholelen, &hdrend) == -1) {
+ free(wholebuf);
+ return (-1);
+ }
+
+ if (locate_mime_part(wholebuf, hdrend, wholebuf + hdrend,
+ wholelen - hdrend, path, pathlen, &part, &partlen) == -1) {
+ free(wholebuf);
+ return (-1);
+ }
+
+ apply_partial_range(part, partlen, has_partial, partial_start,
+ partial_count, &out, &outlen);
+
+ if (outlen > 0) {
+ if ((*buf_out = malloc(outlen)) == NULL) {
+ log_warn("session %u: malloc BODY[<part>] buffer (%s)",
+ session_id, basename);
+ free(wholebuf);
+ return (-1);
+ }
+ memcpy(*buf_out, out, outlen);
+ }
+ *len_out = (uint32_t)outlen;
+ free(wholebuf);
+ return (0);
+}
+
+/* builds the space-separated IMAP flag-atom list from maildir flag-suffix letters + index keywords; letters per Courier's maildir(5) */
+void
+build_flags_string(const char *maildir_suffix, const char *keywords,
+ char *out, size_t outsize)
+{
+ static const struct {
+ char letter;
+ const char *flag;
+ } stdflags[] = {
+ { 'D', "\\Draft" },
+ { 'F', "\\Flagged" },
+ { 'R', "\\Answered" },
+ { 'S', "\\Seen" },
+ { 'T', "\\Deleted" },
+ };
+ const char *letters;
+ size_t i;
+ int first = 1;
+
+ out[0] = '\0';
+
+ letters = strstr(maildir_suffix, "2,");
+ letters = (letters != NULL) ? letters + 2 : "";
+
+ for (i = 0; i < sizeof(stdflags) / sizeof(stdflags[0]); i++) {
+ if (strchr(letters, stdflags[i].letter) == NULL)
+ continue;
+ if (!first && strlcat(out, " ", outsize) >= outsize) {
+ log_warnx("session %u: build_flags_string: out "
+ "truncated -- caller-supplied buffer too small "
+ "for the flag list; stopping early", session_id);
+ return;
+ }
+ if (strlcat(out, stdflags[i].flag, outsize) >= outsize) {
+ log_warnx("session %u: build_flags_string: out "
+ "truncated -- caller-supplied buffer too small "
+ "for the flag list; stopping early", session_id);
+ return;
+ }
+ first = 0;
+ }
+
+ if (keywords != NULL && keywords[0] != '\0') {
+ char kwbuf[512];
+ char *kw, *save;
+
+ if (strlcpy(kwbuf, keywords, sizeof(kwbuf)) >= sizeof(kwbuf)) {
+ log_warnx("session %u: build_flags_string: keywords "
+ "too long for kwbuf -- omitting keyword flags "
+ "rather than guessing at a truncated list",
+ session_id);
+ return;
+ }
+ for (kw = strtok_r(kwbuf, ",", &save); kw != NULL;
+ kw = strtok_r(NULL, ",", &save)) {
+ if (!first && strlcat(out, " ", outsize) >= outsize) {
+ log_warnx("session %u: build_flags_string: "
+ "out truncated -- caller-supplied "
+ "buffer too small for the flag list; "
+ "stopping early", session_id);
+ return;
+ }
+ if (strlcat(out, kw, outsize) >= outsize) {
+ log_warnx("session %u: build_flags_string: "
+ "out truncated -- caller-supplied "
+ "buffer too small for the flag list; "
+ "stopping early", session_id);
+ return;
+ }
+ first = 0;
+ }
+ }
+}
+
+/* RFC 9051 SS2.3.1.1 INTERNALDATE: uses the maildir basename's leading timestamp field, not mtime; falls back to now if non-conforming */
+int64_t
+parse_maildir_timestamp(const char *basename)
+{
+ char *ep;
+ long long ts;
+
+ errno = 0;
+ ts = strtoll(basename, &ep, 10);
+ if (ep == basename || *ep != '.' || errno != 0 || ts < 0)
+ return ((int64_t)time(NULL));
+ return ((int64_t)ts);
+}
+
blob - /dev/null
blob + ad9e1ac1f13afb0e026401cb2f682807957c83e1 (mode 644)
--- /dev/null
+++ src/search_cmd.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.
+ */
+
+/*
+ * 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>
+#include <sys/socket.h>
+
+#include <netinet/in.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <event.h>
+#include <imsg.h>
+#include <resolv.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <tls.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "listener.h"
+
+int
+parse_search_date(const char *s, int64_t *out)
+{
+ struct tm tm;
+ char mon[4];
+ int day, year, i, n;
+ time_t t;
+
+ memset(&tm, 0, sizeof(tm));
+ n = 0;
+ if (sscanf(s, "%2d-%3s-%4d%n", &day, mon, &year, &n) != 3)
+ return (-1);
+ if (s[n] != '\0')
+ return (-1); /* trailing garbage after date-text */
+
+ if (day < 1 || day > 31 || year < 1970)
+ return (-1);
+
+ for (i = 0; i < 12; i++) {
+ if (strcasecmp(mon, fetch_month_names[i]) == 0)
+ break;
+ }
+ if (i == 12)
+ return (-1);
+
+ tm.tm_mday = day;
+ tm.tm_mon = i;
+ tm.tm_year = year - 1900;
+ /* tm_hour/tm_min/tm_sec already 0 from memset -- UTC midnight */
+
+ if ((t = timegm(&tm)) == (time_t)-1)
+ return (-1);
+
+ *out = (int64_t)t;
+ return (0);
+}
+
+/* accumulator for parse_search_key()'s compiled postfix program (flat-array wire format is in imapd.h) */
+#define SEARCH_MAX_DEPTH 64 /* max parse_search_key() recursion */
+struct search_parse_ctx {
+ struct search_node nodes[SEARCH_PROGRAM_MAX_NODES];
+ uint32_t n;
+ int depth; /* current recursion depth */
+ int uses_modseq; /* set when a MODSEQ key is pushed; see cmd_search() */
+};
+
+
+static int
+search_push(struct search_parse_ctx *ctx, const struct search_node *node,
+ const char **errmsg)
+{
+ if (ctx->n >= SEARCH_PROGRAM_MAX_NODES) {
+ *errmsg = "search criteria too complex";
+ return (-1);
+ }
+ ctx->nodes[ctx->n++] = *node;
+ return (0);
+}
+
+/* reads one sequence-set token, rejects an internal comma (v1's single-range restriction), via parse_seq_range() */
+static int
+parse_search_seqset_token(char **pp, uint32_t *lo, uint32_t *hi,
+ int *lo_star, int *hi_star, const char **errmsg)
+{
+ char *p = *pp;
+ const char *start = p;
+
+ while (*p != '\0' && *p != ' ' && *p != ')')
+ p++;
+
+ if (p == start) {
+ *errmsg = "missing sequence set";
+ return (-1);
+ }
+
+ {
+ char tok[32];
+ size_t len = (size_t)(p - start);
+
+ if (len >= sizeof(tok)) {
+ *errmsg = "sequence set too long";
+ return (-1);
+ }
+ memcpy(tok, start, len);
+ tok[len] = '\0';
+
+ if (strchr(tok, ',') != NULL) {
+ *errmsg = "comma-separated sequence sets not "
+ "supported in v1 -- issue separate SEARCH "
+ "commands";
+ return (-1);
+ }
+
+ if (parse_seq_range(tok, lo, hi, lo_star, hi_star) == -1) {
+ *errmsg = "invalid sequence set";
+ return (-1);
+ }
+ }
+
+ *pp = p;
+ return (0);
+}
+
+/* RFC 9051 SS6.4.4 search-key grammar, recursive-descent; returns 0 ok, -1 (BAD) malformed, -2 (NO) recognized-but-unsupported key. */
+int
+parse_search_key(char **pp, struct search_parse_ctx *ctx, const char **errmsg)
+{
+ int rc;
+
+ /* every nesting level (parens, NOT, OR) re-enters this function; bound against crafted deep nesting */
+ if (++ctx->depth > SEARCH_MAX_DEPTH) {
+ ctx->depth--;
+ *errmsg = "search criteria nested too deeply";
+ return (-1);
+ }
+ rc = parse_search_key_inner(pp, ctx, errmsg);
+ ctx->depth--;
+ return (rc);
+}
+
+
+int
+parse_search_key_inner(char **pp, struct search_parse_ctx *ctx,
+ const char **errmsg)
+{
+ char *p = *pp;
+ char word[32];
+ size_t wlen;
+
+ while (*p == ' ')
+ p++;
+
+ if (*p == '\0') {
+ *errmsg = "missing search key";
+ return (-1);
+ }
+
+ if (*p == '(') {
+ int rc;
+
+ p++;
+ rc = parse_search_key_list(&p, ctx, errmsg, 1);
+ if (rc != 0)
+ return (rc);
+ while (*p == ' ')
+ p++;
+ if (*p != ')') {
+ *errmsg = "unterminated parenthesized search key list";
+ return (-1);
+ }
+ p++;
+ *pp = p;
+ return (0);
+ }
+
+ if (isdigit((unsigned char)*p) || *p == '*') {
+ struct search_node node;
+
+ memset(&node, 0, sizeof(node));
+ node.op = SEARCH_OP_SEQSET;
+ if (parse_search_seqset_token(&p, &node.seq_lo, &node.seq_hi,
+ &node.lo_is_star, &node.hi_is_star, errmsg) == -1)
+ return (-1);
+ if (search_push(ctx, &node, errmsg) == -1)
+ return (-1);
+ *pp = p;
+ return (0);
+ }
+
+ {
+ char *start = p;
+
+ while (*p != '\0' && *p != ' ' && *p != ')')
+ p++;
+ wlen = (size_t)(p - start);
+ if (wlen == 0 || wlen >= sizeof(word)) {
+ *errmsg = "unknown search key";
+ return (-1);
+ }
+ memcpy(word, start, wlen);
+ word[wlen] = '\0';
+ }
+
+ if (strcasecmp(word, "ALL") == 0) {
+ struct search_node node;
+
+ memset(&node, 0, sizeof(node));
+ node.op = SEARCH_OP_ALL;
+ if (search_push(ctx, &node, errmsg) == -1)
+ return (-1);
+ *pp = p;
+ return (0);
+ }
+
+ {
+ static const struct {
+ const char *name;
+ int op;
+ } boolkeys[] = {
+ { "ANSWERED", SEARCH_OP_ANSWERED },
+ { "UNANSWERED", SEARCH_OP_UNANSWERED },
+ { "DELETED", SEARCH_OP_DELETED },
+ { "UNDELETED", SEARCH_OP_UNDELETED },
+ { "DRAFT", SEARCH_OP_DRAFT },
+ { "UNDRAFT", SEARCH_OP_UNDRAFT },
+ { "FLAGGED", SEARCH_OP_FLAGGED },
+ { "UNFLAGGED", SEARCH_OP_UNFLAGGED },
+ { "SEEN", SEARCH_OP_SEEN },
+ { "UNSEEN", SEARCH_OP_UNSEEN },
+ };
+ size_t i;
+
+ for (i = 0; i < sizeof(boolkeys) / sizeof(boolkeys[0]); i++) {
+ struct search_node node;
+
+ if (strcasecmp(word, boolkeys[i].name) != 0)
+ continue;
+ memset(&node, 0, sizeof(node));
+ node.op = boolkeys[i].op;
+ if (search_push(ctx, &node, errmsg) == -1)
+ return (-1);
+ *pp = p;
+ return (0);
+ }
+ }
+
+ if (strcasecmp(word, "KEYWORD") == 0 ||
+ strcasecmp(word, "UNKEYWORD") == 0) {
+ struct search_node node;
+ const char *start;
+
+ memset(&node, 0, sizeof(node));
+ node.op = (strcasecmp(word, "KEYWORD") == 0) ?
+ SEARCH_OP_KEYWORD : SEARCH_OP_UNKEYWORD;
+
+ while (*p == ' ')
+ p++;
+ start = p;
+ while (*p != '\0' && *p != ' ' && *p != ')')
+ p++;
+ if (p == start || (size_t)(p - start) >= sizeof(node.keyword)) {
+ *errmsg = "missing or too-long KEYWORD/UNKEYWORD "
+ "argument";
+ return (-1);
+ }
+ memcpy(node.keyword, start, (size_t)(p - start));
+ node.keyword[p - start] = '\0';
+
+ if (search_push(ctx, &node, errmsg) == -1)
+ return (-1);
+ *pp = p;
+ return (0);
+ }
+
+ if (strcasecmp(word, "BEFORE") == 0 || strcasecmp(word, "ON") == 0 ||
+ strcasecmp(word, "SINCE") == 0) {
+ struct search_node node;
+ char datebuf[32];
+
+ memset(&node, 0, sizeof(node));
+ if (strcasecmp(word, "BEFORE") == 0)
+ node.op = SEARCH_OP_BEFORE;
+ else if (strcasecmp(word, "ON") == 0)
+ node.op = SEARCH_OP_ON;
+ else
+ node.op = SEARCH_OP_SINCE;
+
+ while (*p == ' ')
+ p++;
+ if (*p == '"') {
+ const char *start = p + 1;
+ char *end = strchr(start, '"');
+ size_t len;
+
+ if (end == NULL) {
+ *errmsg = "unterminated date string";
+ return (-1);
+ }
+ len = (size_t)(end - start);
+ if (len >= sizeof(datebuf)) {
+ *errmsg = "malformed date";
+ return (-1);
+ }
+ memcpy(datebuf, start, len);
+ datebuf[len] = '\0';
+ p = end + 1;
+ } else {
+ const char *start = p;
+ size_t len;
+
+ while (*p != '\0' && *p != ' ' && *p != ')')
+ p++;
+ len = (size_t)(p - start);
+ if (len == 0 || len >= sizeof(datebuf)) {
+ *errmsg = "malformed date";
+ return (-1);
+ }
+ memcpy(datebuf, start, len);
+ datebuf[len] = '\0';
+ }
+
+ if (parse_search_date(datebuf, &node.num) == -1) {
+ *errmsg = "malformed date";
+ return (-1);
+ }
+
+ if (search_push(ctx, &node, errmsg) == -1)
+ return (-1);
+ *pp = p;
+ return (0);
+ }
+
+ if (strcasecmp(word, "LARGER") == 0 ||
+ strcasecmp(word, "SMALLER") == 0) {
+ struct search_node node;
+ char numbuf[24];
+ const char *start;
+ char *numend;
+ unsigned long long v;
+
+ memset(&node, 0, sizeof(node));
+ node.op = (strcasecmp(word, "LARGER") == 0) ?
+ SEARCH_OP_LARGER : SEARCH_OP_SMALLER;
+
+ while (*p == ' ')
+ p++;
+ start = p;
+ while (*p != '\0' && *p != ' ' && *p != ')')
+ p++;
+ if (p == start || (size_t)(p - start) >= sizeof(numbuf)) {
+ *errmsg = "missing or malformed octet count";
+ return (-1);
+ }
+ memcpy(numbuf, start, (size_t)(p - start));
+ numbuf[p - start] = '\0';
+
+ errno = 0;
+ v = strtoull(numbuf, &numend, 10);
+ if (*numend != '\0' || errno == ERANGE) {
+ *errmsg = "malformed octet count";
+ return (-1);
+ }
+ node.num = (int64_t)v;
+
+ if (search_push(ctx, &node, errmsg) == -1)
+ return (-1);
+ *pp = p;
+ return (0);
+ }
+
+ if (strcasecmp(word, "UID") == 0) {
+ struct search_node node;
+
+ memset(&node, 0, sizeof(node));
+ node.op = SEARCH_OP_UIDSET;
+
+ while (*p == ' ')
+ p++;
+ if (parse_search_seqset_token(&p, &node.seq_lo, &node.seq_hi,
+ &node.lo_is_star, &node.hi_is_star, errmsg) == -1)
+ return (-1);
+
+ if (search_push(ctx, &node, errmsg) == -1)
+ return (-1);
+ *pp = p;
+ return (0);
+ }
+
+ if (strcasecmp(word, "MODSEQ") == 0) {
+ struct search_node node;
+ char numbuf[24];
+ const char *start;
+ char *numend;
+ unsigned long long v;
+
+ memset(&node, 0, sizeof(node));
+ node.op = SEARCH_OP_MODSEQ;
+
+ while (*p == ' ')
+ p++;
+
+ /* RFC 7162 SS3.1.5: optional entry-name/entry-type-req (METADATA, unimplemented) parsed past and ignored */
+ if (*p != '\0' && !isdigit((unsigned char)*p)) {
+ if (*p == '"') {
+ char *end = strchr(p + 1, '"');
+
+ if (end == NULL) {
+ *errmsg = "unterminated MODSEQ "
+ "entry-name";
+ return (-1);
+ }
+ p = end + 1;
+ } else {
+ while (*p != '\0' && *p != ' ' && *p != ')')
+ p++;
+ }
+ while (*p == ' ')
+ p++;
+
+ if (strncasecmp(p, "priv", 4) == 0 &&
+ (p[4] == ' ' || p[4] == '\0' || p[4] == ')'))
+ p += 4;
+ else if (strncasecmp(p, "shared", 6) == 0 &&
+ (p[6] == ' ' || p[6] == '\0' || p[6] == ')'))
+ p += 6;
+ else if (strncasecmp(p, "all", 3) == 0 &&
+ (p[3] == ' ' || p[3] == '\0' || p[3] == ')'))
+ p += 3;
+ else {
+ *errmsg = "expected priv/shared/all after "
+ "MODSEQ entry-name";
+ return (-1);
+ }
+ while (*p == ' ')
+ p++;
+ }
+
+ start = p;
+ while (*p != '\0' && *p != ' ' && *p != ')')
+ p++;
+ if (p == start || (size_t)(p - start) >= sizeof(numbuf)) {
+ *errmsg = "missing or malformed MODSEQ value";
+ return (-1);
+ }
+ memcpy(numbuf, start, (size_t)(p - start));
+ numbuf[p - start] = '\0';
+
+ errno = 0;
+ v = strtoull(numbuf, &numend, 10);
+ if (*numend != '\0' || errno == ERANGE) {
+ *errmsg = "malformed MODSEQ value";
+ return (-1);
+ }
+ node.num = (int64_t)v;
+
+ if (search_push(ctx, &node, errmsg) == -1)
+ return (-1);
+ ctx->uses_modseq = 1;
+ *pp = p;
+ return (0);
+ }
+
+ if (strcasecmp(word, "NOT") == 0) {
+ struct search_node node;
+ int rc;
+
+ rc = parse_search_key(&p, ctx, errmsg);
+ if (rc != 0)
+ return (rc);
+
+ memset(&node, 0, sizeof(node));
+ node.op = SEARCH_OP_NOT;
+ if (search_push(ctx, &node, errmsg) == -1)
+ return (-1);
+ *pp = p;
+ return (0);
+ }
+
+ if (strcasecmp(word, "OR") == 0) {
+ struct search_node node;
+ int rc;
+
+ rc = parse_search_key(&p, ctx, errmsg);
+ if (rc != 0)
+ return (rc);
+ rc = parse_search_key(&p, ctx, errmsg);
+ if (rc != 0)
+ return (rc);
+
+ memset(&node, 0, sizeof(node));
+ node.op = SEARCH_OP_OR;
+ if (search_push(ctx, &node, errmsg) == -1)
+ return (-1);
+ *pp = p;
+ return (0);
+ }
+
+ {
+ static const char *content_keys[] = {
+ "BCC", "BODY", "CC", "FROM", "HEADER", "SENTBEFORE",
+ "SENTON", "SENTSINCE", "SUBJECT", "TEXT", "TO",
+ };
+ size_t i;
+
+ for (i = 0; i < sizeof(content_keys) / sizeof(content_keys[0]);
+ i++) {
+ if (strcasecmp(word, content_keys[i]) == 0) {
+ *errmsg = "search keys that require message "
+ "content/header access are not "
+ "supported in this pass";
+ return (-2);
+ }
+ }
+ }
+
+ *errmsg = "unknown search key";
+ return (-1);
+}
+
+/* search-key *(SP search-key), ANDed left to right; in_parens stops at (but doesn't consume) the closing ')' */
+int
+parse_search_key_list(char **pp, struct search_parse_ctx *ctx,
+ const char **errmsg, int in_parens)
+{
+ int rc;
+
+ rc = parse_search_key(pp, ctx, errmsg);
+ if (rc != 0)
+ return (rc);
+
+ for (;;) {
+ char *p = *pp;
+
+ while (*p == ' ')
+ p++;
+
+ if (in_parens && *p == ')') {
+ *pp = p;
+ return (0);
+ }
+ if (*p == '\0') {
+ if (in_parens) {
+ *errmsg = "unterminated parenthesized search "
+ "key list";
+ return (-1);
+ }
+ *pp = p;
+ return (0);
+ }
+ if (!in_parens && *p == ')') {
+ *errmsg = "unexpected ')'";
+ return (-1);
+ }
+
+ *pp = p;
+ rc = parse_search_key(pp, ctx, errmsg);
+ if (rc != 0)
+ return (rc);
+
+ {
+ struct search_node combine;
+
+ memset(&combine, 0, sizeof(combine));
+ combine.op = SEARCH_OP_AND;
+ if (search_push(ctx, &combine, errmsg) == -1)
+ return (-1);
+ }
+ }
+}
+
+/* RFC 9051 SS6.4.4 search-return-opts; SAVE ("$" result variable) recognized but rejected -2/NO, needs cross-cutting support elsewhere */
+int
+parse_search_return_opts(char **pp, uint32_t *opts_out, const char **errmsg)
+{
+ char *p = *pp;
+
+ *opts_out = 0;
+ p++; /* skip the '(' the caller already confirmed is there */
+
+ while (*p == ' ')
+ p++;
+ if (*p == ')') {
+ *pp = p + 1;
+ return (0);
+ }
+
+ for (;;) {
+ const char *start = p;
+ char word[16];
+ size_t len;
+
+ while (*p != '\0' && *p != ' ' && *p != ')')
+ p++;
+ len = (size_t)(p - start);
+ if (len == 0 || len >= sizeof(word)) {
+ *errmsg = "malformed SEARCH RETURN option";
+ return (-1);
+ }
+ memcpy(word, start, len);
+ word[len] = '\0';
+
+ if (strcasecmp(word, "MIN") == 0)
+ *opts_out |= SEARCH_RETURN_MIN;
+ else if (strcasecmp(word, "MAX") == 0)
+ *opts_out |= SEARCH_RETURN_MAX;
+ else if (strcasecmp(word, "ALL") == 0)
+ *opts_out |= SEARCH_RETURN_ALL;
+ else if (strcasecmp(word, "COUNT") == 0)
+ *opts_out |= SEARCH_RETURN_COUNT;
+ else if (strcasecmp(word, "SAVE") == 0) {
+ *errmsg = "SEARCH RETURN (SAVE) -- the \"$\" search "
+ "result variable -- is not supported in this "
+ "pass";
+ return (-2);
+ } else {
+ *errmsg = "unsupported SEARCH RETURN option";
+ return (-1);
+ }
+
+ while (*p == ' ')
+ p++;
+ if (*p == ')') {
+ *pp = p + 1;
+ return (0);
+ }
+ if (*p == '\0') {
+ *errmsg = "unterminated SEARCH RETURN option list";
+ return (-1);
+ }
+ }
+}
+
+/* RFC 9051 SS6.4.4 SEARCH; CHARSET accepted only as US-ASCII/UTF-8, else NO [BADCHARSET]; content/header-based keys unsupported (no content access) */
+int
+cmd_search(struct session *s, const char *tag, char *args)
+{
+ return search_dispatch(s, tag, args, 0);
+}
+
+/* shared by cmd_search() and UID SEARCH; by_uid only changes what's reported per match (UID vs seqno), not parsing/matching (SS6.4.9) */
+int
+search_dispatch(struct session *s, const char *tag, char *args, int by_uid)
+{
+ struct search_parse_ctx ctx;
+ char *p;
+ uint32_t return_opts = 0;
+ int rc;
+ const char *errmsg;
+
+ if (args == NULL) {
+ session_reply(s, tag, "BAD", "SEARCH requires search criteria");
+ return (1);
+ }
+
+ p = args;
+ while (*p == ' ')
+ p++;
+
+ if (strncasecmp(p, "RETURN", 6) == 0 &&
+ (p[6] == ' ' || p[6] == '\0')) {
+ p += 6;
+ while (*p == ' ')
+ p++;
+ if (*p != '(') {
+ session_reply(s, tag, "BAD",
+ "malformed SEARCH RETURN option list");
+ return (1);
+ }
+ rc = parse_search_return_opts(&p, &return_opts, &errmsg);
+ if (rc == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+ if (rc == -2) {
+ session_reply(s, tag, "NO", errmsg);
+ return (1);
+ }
+ while (*p == ' ')
+ p++;
+ }
+
+ if (return_opts == 0)
+ return_opts = SEARCH_RETURN_ALL; /* SS6.4.4's default */
+
+ if (strncasecmp(p, "CHARSET", 7) == 0 &&
+ (p[7] == ' ' || p[7] == '\0')) {
+ const char *start;
+ char charset[64];
+ size_t len;
+
+ p += 7;
+ while (*p == ' ')
+ p++;
+ start = p;
+ while (*p != '\0' && *p != ' ')
+ p++;
+ len = (size_t)(p - start);
+ if (len == 0 || len >= sizeof(charset)) {
+ session_reply(s, tag, "BAD", "malformed CHARSET");
+ return (1);
+ }
+ memcpy(charset, start, len);
+ charset[len] = '\0';
+
+ if (strcasecmp(charset, "US-ASCII") != 0 &&
+ strcasecmp(charset, "UTF-8") != 0) {
+ /* RFC 9051 SS9 ABNF requires parens around the charset list; normative over SS6.4.4.4's prose example */
+ session_reply(s, tag, "NO",
+ "[BADCHARSET (US-ASCII UTF-8)] unsupported "
+ "CHARSET");
+ return (1);
+ }
+
+ while (*p == ' ')
+ p++;
+ }
+
+ memset(&ctx, 0, sizeof(ctx));
+ rc = parse_search_key_list(&p, &ctx, &errmsg, 0);
+ if (rc == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+ if (rc == -2) {
+ session_reply(s, tag, "NO", errmsg);
+ return (1);
+ }
+
+ if (ctx.n == 0) {
+ session_reply(s, tag, "BAD", "SEARCH requires search criteria");
+ return (1);
+ }
+
+ if (s->store_iev == NULL) {
+ /* same invariant check as cmd_fetch()/cmd_store_cmd() */
+ log_warnx("session %u: %s with no store channel wired",
+ s->id, by_uid ? "UID SEARCH" : "SEARCH");
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ /* defensive cleanup of a previous SEARCH's leftovers; shouldn't actually find anything here */
+ free(s->search_matches);
+ s->search_matches = NULL;
+ s->search_nmatches = 0;
+ s->search_matches_cap = 0;
+ s->search_alloc_failed = 0;
+ s->search_return_opts = return_opts;
+ s->search_used_modseq = ctx.uses_modseq;
+ s->search_max_modseq = 0;
+ s->cmd_by_uid = by_uid;
+
+ /* RFC 7162 SS3.1: a SEARCH including the MODSEQ data item is a CONDSTORE enabling command */
+ if (ctx.uses_modseq)
+ session_condstore_enable(s);
+
+ if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->state = SESSION_SEARCHING;
+
+ {
+ struct imsg_mbox_search req;
+ size_t bodylen = (size_t)ctx.n *
+ sizeof(struct search_node);
+ char *combined;
+
+ memset(&req, 0, sizeof(req));
+ req.nnodes = ctx.n;
+
+ if ((combined = malloc(sizeof(req) + bodylen)) == NULL) {
+ log_warn("session %u: malloc SEARCH imsg buffer",
+ s->id);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ s->state = SESSION_SELECTED;
+ return (1);
+ }
+ memcpy(combined, &req, sizeof(req));
+ if (bodylen > 0)
+ memcpy(combined + sizeof(req), ctx.nodes, bodylen);
+
+ if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_SEARCH, 0, 0,
+ -1, combined, sizeof(req) + bodylen) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_SEARCH",
+ s->id);
+ free(combined);
+ imsgev_add(s->store_iev);
+ }
+
+ return (1);
+}
+
+void
+session_handle_mbox_search_match(struct session *s,
+ struct imsg_mbox_search_match *m)
+{
+ if (s->search_alloc_failed)
+ return;
+
+ if (s->search_nmatches == s->search_matches_cap) {
+ uint32_t newcap = s->search_matches_cap ?
+ s->search_matches_cap * 2 : 32;
+ uint32_t *n = reallocarray(s->search_matches, newcap,
+ sizeof(uint32_t));
+
+ if (n == NULL) {
+ log_warn("session %u: realloc SEARCH match array",
+ s->id);
+ s->search_alloc_failed = 1;
+ return;
+ }
+ s->search_matches = n;
+ s->search_matches_cap = newcap;
+ }
+
+ /* RFC 9051 SS6.4.9: UID SEARCH reports UIDs instead of sequence numbers in ESEARCH data */
+ s->search_matches[s->search_nmatches++] = s->cmd_by_uid ?
+ m->uid : m->seqno;
+
+ /* RFC 7162 SS3.1.6: running max modseq; printed only if MODSEQ was used (session_finish_search()) */
+ if (m->modseq > s->search_max_modseq)
+ s->search_max_modseq = m->modseq;
+}
+
+/* builds and sends the ESEARCH response once store.c's SEARCH pass completes (RFC 9051 SS6.4.4/SS9) */
+void
+session_finish_search(struct session *s, struct imsg_mbox_result *res)
+{
+ char buf[8192];
+ size_t len;
+ int truncated = 0;
+
+ s->state = SESSION_SELECTED;
+
+ log_debug("session %u: SEARCH done, error=%d, %u match(es)", s->id,
+ res->error, res->count);
+
+ if (res->error != MBOX_OP_OK || s->search_alloc_failed) {
+ session_reply(s, s->pending_tag, "NO",
+ s->cmd_by_uid ? "UID SEARCH failed" : "SEARCH failed");
+ goto cleanup;
+ }
+
+ len = (size_t)snprintf(buf, sizeof(buf), "* ESEARCH (TAG \"%s\")",
+ s->pending_tag);
+
+ /* RFC 9051 SS9: "UID" indicator comes right after the correlator, before MIN/MAX/ALL/COUNT/MODSEQ */
+ if (s->cmd_by_uid && len < sizeof(buf))
+ len += (size_t)snprintf(buf + len, sizeof(buf) - len, " UID");
+
+ if ((s->search_return_opts & SEARCH_RETURN_MIN) &&
+ s->search_nmatches > 0 && len < sizeof(buf))
+ len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+ " MIN %u", s->search_matches[0]);
+
+ if ((s->search_return_opts & SEARCH_RETURN_MAX) &&
+ s->search_nmatches > 0 && len < sizeof(buf))
+ len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+ " MAX %u", s->search_matches[s->search_nmatches - 1]);
+
+ if ((s->search_return_opts & SEARCH_RETURN_ALL) &&
+ s->search_nmatches > 0 && len < sizeof(buf)) {
+ size_t listlen;
+
+ len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+ " ALL ");
+ if (len < sizeof(buf)) {
+ listlen = format_seq_list(buf + len,
+ sizeof(buf) - len, s->search_matches,
+ s->search_nmatches, &truncated);
+ len += listlen;
+ if (truncated)
+ log_warnx("session %u: SEARCH ALL response "
+ "truncated at %zu bytes (%u total "
+ "matches)", s->id, sizeof(buf),
+ s->search_nmatches);
+ }
+ }
+
+ if ((s->search_return_opts & SEARCH_RETURN_COUNT) && len < sizeof(buf))
+ len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+ " COUNT %u", s->search_nmatches);
+
+ /* RFC 7162 SS3.1.10: non-empty MODSEQ result gets "MODSEQ n" appended with the highest mod-sequence among matches */
+ if (s->search_used_modseq && s->search_nmatches > 0 &&
+ len < sizeof(buf))
+ len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+ " MODSEQ %llu",
+ (unsigned long long)s->search_max_modseq);
+
+ /*
+ * len holds snprintf(3)'s "would-be" length, which can run past
+ * sizeof(buf) (e.g. a huge ALL sequence-set plus COUNT/MODSEQ
+ * tails pushes it over even though format_seq_list() itself was
+ * bounded). The two explicit buf[len++] writes below need two
+ * free slots, so reserve two bytes, not one -- "- 1" here was an
+ * off-by-one that let buf[sizeof(buf)] get written, one byte past
+ * the end of this stack array.
+ */
+ if (len >= sizeof(buf) - 1)
+ len = sizeof(buf) - 2;
+
+ buf[len++] = '\r';
+ buf[len++] = '\n';
+ session_write(s, buf, len);
+
+ /* "UID SEARCH completed" follows SS6.4.9's "UID <cmd> completed" pattern */
+ session_reply(s, s->pending_tag, "OK",
+ s->cmd_by_uid ? "UID SEARCH completed" : "SEARCH completed");
+
+cleanup:
+ free(s->search_matches);
+ s->search_matches = NULL;
+ s->search_nmatches = 0;
+ s->search_matches_cap = 0;
+ s->search_alloc_failed = 0;
+ s->search_used_modseq = 0;
+ s->search_max_modseq = 0;
+}
blob - /dev/null
blob + b93a07a57b01c11ccb66e38b1ab919530c1e1f65 (mode 644)
--- /dev/null
+++ src/store_cmd.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.
+ */
+
+/*
+ * store_cmd.c -- STORE/EXPUNGE/CLOSE/UNSELECT/IDLE/COPY/MOVE/UID:
+ * command-select handlers plus their async completion paths.
+ */
+
+#include <sys/types.h>
+#include <sys/queue.h>
+#include <sys/socket.h>
+
+#include <netinet/in.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <event.h>
+#include <imsg.h>
+#include <resolv.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <tls.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "listener.h"
+
+int
+cmd_idle(struct session *s, const char *tag, char *args)
+{
+ (void)args; /* RFC 2177 takes no args; same leniency as every zero-arg command here */
+
+ if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->idling = 1;
+ session_write(s, "+ idling\r\n", 10);
+
+ if (s->state == SESSION_SELECTED)
+ session_request_idle_refresh(s);
+
+ return (1);
+}
+
+/* RFC 9051 SS6.4.1: CLOSE removes \Deleted with no untagged EXPUNGE -- via session_request_expunge(silent=1). */
+int
+cmd_close(struct session *s, const char *tag, char *args)
+{
+ (void)args;
+ return session_request_expunge(s, tag, 1, 0, 0, 0, 0, 0);
+}
+
+/* RFC 9051 SS6.4.2: like CLOSE but removes nothing -- a purely local state change. */
+int
+cmd_unselect(struct session *s, const char *tag, char *args)
+{
+ (void)args;
+
+ s->state = SESSION_AUTHENTICATED;
+ session_reply(s, tag, "OK", "Unselect completed");
+ return (1);
+}
+
+/* RFC 9051 SS6.4.3: sends the real (non-silent) IMSG_MBOX_EXPUNGE via session_request_expunge(). */
+int
+cmd_expunge(struct session *s, const char *tag, char *args)
+{
+ (void)args;
+ return session_request_expunge(s, tag, 0, 0, 0, 0, 0, 0);
+}
+
+/* Parses a store-att-flags list (parenthesized or bare) into a sysflags bitmap plus comma-joined keywords. */
+int
+parse_store_flags(char *flagspec, uint32_t *sysflags_out, char *keywords_out,
+ size_t keywords_out_size, const char **errmsg)
+{
+ char *p, *tok, *save;
+ size_t len;
+ uint32_t sysflags = 0;
+ int first = 1;
+
+ *errmsg = NULL;
+ *sysflags_out = 0;
+ keywords_out[0] = '\0';
+
+ if (flagspec == NULL || *flagspec == '\0') {
+ *errmsg = "missing flag list";
+ return (-1);
+ }
+
+ p = flagspec;
+ len = strlen(p);
+ if (len >= 2 && p[0] == '(' && p[len - 1] == ')') {
+ p[len - 1] = '\0';
+ p++;
+ }
+ /* Empty flag-list is valid ABNF; only SET (bare FLAGS/FLAGS.SILENT) may use it, enforced by cmd_store_cmd(). */
+ if (*p == '\0')
+ return (0);
+
+ for (tok = strtok_r(p, " ", &save); tok != NULL;
+ tok = strtok_r(NULL, " ", &save)) {
+ if (tok[0] == '\\') {
+ if (strcasecmp(tok, "\\Answered") == 0)
+ sysflags |= MBOX_FLAG_ANSWERED;
+ else if (strcasecmp(tok, "\\Flagged") == 0)
+ sysflags |= MBOX_FLAG_FLAGGED;
+ else if (strcasecmp(tok, "\\Deleted") == 0)
+ sysflags |= MBOX_FLAG_DELETED;
+ else if (strcasecmp(tok, "\\Seen") == 0)
+ sysflags |= MBOX_FLAG_SEEN;
+ else if (strcasecmp(tok, "\\Draft") == 0)
+ sysflags |= MBOX_FLAG_DRAFT;
+ else if (strcasecmp(tok, "\\Recent") == 0) {
+ *errmsg = "\\Recent cannot be set -- RFC 9051 "
+ "deprecates it and excludes it from the "
+ "flag grammar entirely";
+ return (-2);
+ } else {
+ *errmsg = "unsupported system flag -- v1 only "
+ "supports \\Answered/\\Flagged/\\Deleted/"
+ "\\Seen/\\Draft";
+ return (-2);
+ }
+ continue;
+ }
+
+ if (strchr(tok, ':') != NULL || strchr(tok, ',') != NULL) {
+ *errmsg = "keyword contains ':' or ',' -- not "
+ "representable in this server's index format";
+ return (-2);
+ }
+
+ /* No RFC-mandated flag-list length cap; checked explicitly instead of letting strlcat(3) truncate mid-keyword. */
+ if (!first) {
+ if (strlcat(keywords_out, ",", keywords_out_size) >=
+ keywords_out_size) {
+ *errmsg = "keyword list too long";
+ return (-2);
+ }
+ }
+ if (strlcat(keywords_out, tok, keywords_out_size) >=
+ keywords_out_size) {
+ *errmsg = "keyword list too long";
+ return (-2);
+ }
+ first = 0;
+ }
+
+ *sysflags_out = sysflags;
+ return (0);
+}
+
+/* RFC 7162 SS3.1.3: only UNCHANGEDSINCE implemented; value may be 0, so has_unchangedsince flags it, not != 0. */
+int
+parse_store_modifiers(char *modspec, struct imsg_mbox_store *req,
+ const char **errmsg)
+{
+ char *p, *tok, *save;
+ size_t len;
+
+ *errmsg = NULL;
+ len = strlen(modspec);
+ if (len < 2 || modspec[0] != '(' || modspec[len - 1] != ')') {
+ *errmsg = "malformed store-modifier list";
+ return (-1);
+ }
+ modspec[len - 1] = '\0';
+ p = modspec + 1;
+
+ for (tok = strtok_r(p, " ", &save); tok != NULL;
+ tok = strtok_r(NULL, " ", &save)) {
+ if (strcasecmp(tok, "UNCHANGEDSINCE") == 0) {
+ const char *valtok = strtok_r(NULL, " ", &save);
+ char *ep;
+
+ if (valtok == NULL) {
+ *errmsg = "UNCHANGEDSINCE requires a "
+ "mod-sequence value";
+ return (-1);
+ }
+ errno = 0;
+ req->unchangedsince = strtoull(valtok, &ep, 10);
+ if (*ep != '\0' || errno != 0) {
+ *errmsg = "invalid UNCHANGEDSINCE mod-sequence";
+ return (-1);
+ }
+ req->has_unchangedsince = 1;
+ } else {
+ *errmsg = "unrecognized store modifier";
+ return (-1);
+ }
+ }
+
+ return (0);
+}
+
+/* RFC 9051 SS6.4.6: store-modifiers *lead* here, unlike FETCH's trailing fetch-modifiers. */
+int
+cmd_store_cmd(struct session *s, const char *tag, char *args)
+{
+ return store_do(s, tag, args, 0);
+}
+
+/* Shared body for cmd_store_cmd()/cmd_uid()'s STORE branch; s->cmd_by_uid set so the STORE echo includes UID (SS6.4.9). */
+int
+store_do(struct session *s, const char *tag, char *args, int by_uid)
+{
+ struct imsg_mbox_store req;
+ const char *seqtok;
+ char *modetok, *flagspec, *modspec = NULL;
+ uint32_t lo, hi, sysflags;
+ int lo_star, hi_star, mode, silent, rc;
+ char keywords[MBOX_FLAGS_MAX];
+ const char *errmsg;
+ const char *cmdname = by_uid ? "UID STORE" : "STORE";
+
+ if (args == NULL) {
+ session_reply(s, tag, "BAD",
+ "STORE requires a sequence set and store-att-flags");
+ return (1);
+ }
+
+ seqtok = args;
+ while (*args != '\0' && *args != ' ')
+ args++;
+ if (*args == '\0') {
+ session_reply(s, tag, "BAD", "STORE requires store-att-flags");
+ return (1);
+ }
+ *args++ = '\0';
+ while (*args == ' ')
+ args++;
+
+ if (*args == '(') {
+ char *p = args;
+ int depth = 0;
+
+ for (;;) {
+ if (*p == '(')
+ depth++;
+ else if (*p == ')') {
+ depth--;
+ if (depth == 0)
+ break;
+ } else if (*p == '\0') {
+ session_reply(s, tag, "BAD",
+ "unterminated store-modifier list");
+ return (1);
+ }
+ p++;
+ }
+ /* p points at the matching ')' for modspec (== args) */
+ modspec = args;
+ p++; /* just past ')' */
+ if (*p == '\0') {
+ session_reply(s, tag, "BAD",
+ "STORE requires store-att-flags");
+ return (1);
+ }
+ if (*p != ' ') {
+ session_reply(s, tag, "BAD",
+ "expected a space after store-modifier list");
+ return (1);
+ }
+ *p = '\0'; /* terminate modspec right after ')' */
+ p++;
+ while (*p == ' ')
+ p++;
+ args = p;
+ }
+
+ modetok = args;
+ while (*args != '\0' && *args != ' ')
+ args++;
+ if (*args == '\0') {
+ session_reply(s, tag, "BAD", "STORE requires a flag list");
+ return (1);
+ }
+ *args++ = '\0';
+ while (*args == ' ')
+ args++;
+ flagspec = args;
+
+ if (strchr(seqtok, ',') != NULL) {
+ session_reply(s, tag, "BAD",
+ "comma-separated sequence sets not supported in v1 -- "
+ "issue separate STORE commands");
+ return (1);
+ }
+ if (parse_seq_range(seqtok, &lo, &hi, &lo_star, &hi_star) == -1) {
+ session_reply(s, tag, "BAD", "invalid sequence set");
+ return (1);
+ }
+
+ memset(&req, 0, sizeof(req));
+ if (modspec != NULL) {
+ if (parse_store_modifiers(modspec, &req, &errmsg) == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+ }
+
+ if (modetok[0] == '+') {
+ mode = MBOX_STORE_ADD;
+ modetok++;
+ } else if (modetok[0] == '-') {
+ mode = MBOX_STORE_REMOVE;
+ modetok++;
+ } else
+ mode = MBOX_STORE_SET;
+
+ if (strcasecmp(modetok, "FLAGS") == 0)
+ silent = 0;
+ else if (strcasecmp(modetok, "FLAGS.SILENT") == 0)
+ silent = 1;
+ else {
+ session_reply(s, tag, "BAD",
+ "expected FLAGS, FLAGS.SILENT, +FLAGS, +FLAGS.SILENT, "
+ "-FLAGS, or -FLAGS.SILENT");
+ return (1);
+ }
+
+ rc = parse_store_flags(flagspec, &sysflags, keywords, sizeof(keywords),
+ &errmsg);
+ if (rc == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+ if (rc == -2) {
+ session_reply(s, tag, "NO", errmsg);
+ return (1);
+ }
+
+ if (s->mbox_readonly) {
+ /* RFC 9051 SS6.3.3: no changes on EXAMINE'd mailbox (RFC 5530 CANNOT) -- same check as session_request_expunge(). */
+ session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
+ "(selected via EXAMINE)");
+ return (1);
+ }
+
+ if (s->store_iev == NULL) {
+ /* ST_SELECTED requires store_iev to already be wired. */
+ log_warnx("session %u: %s with no store channel wired",
+ s->id, cmdname);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ /* req already memset(3)'d and has_unchangedsince set earlier -- not re-zeroed here, to avoid wiping it out. */
+ req.seq_lo = lo;
+ req.seq_hi = hi;
+ req.lo_is_star = lo_star;
+ req.hi_is_star = hi_star;
+ req.mode = mode;
+ req.silent = silent;
+ req.sysflags = sysflags;
+ req.by_uid = by_uid;
+ if (strlcpy(req.keywords, keywords, sizeof(req.keywords)) >=
+ sizeof(req.keywords) ||
+ strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ /* RFC 7162 SS3.1: UNCHANGEDSINCE is a CONDSTORE-enabling command. */
+ if (req.has_unchangedsince)
+ session_condstore_enable(s);
+
+ s->cmd_by_uid = by_uid;
+ s->state = SESSION_STORING;
+
+ if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_STORE, 0, 0, -1,
+ &req, sizeof(req)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_STORE", s->id);
+ imsgev_add(s->store_iev);
+
+ return (1);
+}
+
+/* RFC 9051 SS6.4.7/SS6.4.8: invalid mailbox name is BAD; nonexistent is TRYCREATE via res->error == MBOX_OP_ERR_NO_SUCH_MAILBOX. */
+int
+copy_move_dispatch(struct session *s, const char *tag, char *args,
+ int by_uid, int is_move)
+{
+ struct imsg_mbox_copy req;
+ char mailbox[MBOX_NAME_MAX];
+ char *seqtok, *p;
+ uint32_t lo, hi;
+ int lo_star, hi_star;
+ const char *errmsg = NULL;
+ const char *cmdname = is_move ?
+ (by_uid ? "UID MOVE" : "MOVE") : (by_uid ? "UID COPY" : "COPY");
+
+ if (args == NULL) {
+ char text[64];
+
+ snprintf(text, sizeof(text),
+ "%s requires a sequence set and mailbox name", cmdname);
+ session_reply(s, tag, "BAD", text);
+ return (1);
+ }
+
+ seqtok = args;
+ p = args;
+ while (*p != '\0' && *p != ' ')
+ p++;
+ if (*p == '\0') {
+ char text[48];
+
+ snprintf(text, sizeof(text), "%s requires a mailbox name",
+ cmdname);
+ session_reply(s, tag, "BAD", text);
+ return (1);
+ }
+ *p++ = '\0';
+ while (*p == ' ')
+ p++;
+
+ if (strchr(seqtok, ',') != NULL) {
+ session_reply(s, tag, "BAD",
+ "comma-separated sequence sets not supported in v1 -- "
+ "issue separate COPY/MOVE commands");
+ return (1);
+ }
+ if (parse_seq_range(seqtok, &lo, &hi, &lo_star, &hi_star) == -1) {
+ session_reply(s, tag, "BAD", "invalid sequence set");
+ return (1);
+ }
+
+ if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+ session_reply(s, tag, "BAD", errmsg);
+ return (1);
+ }
+ if (*p != '\0') {
+ session_reply(s, tag, "BAD", "trailing garbage after "
+ "mailbox name");
+ return (1);
+ }
+
+ if (!listener_mailbox_name_is_inbox(mailbox) && !listener_mailbox_name_valid(mailbox)) {
+ session_reply(s, tag, "BAD", "invalid mailbox name");
+ return (1);
+ }
+
+ if (is_move && s->mbox_readonly) {
+ /* MOVE removes source messages (SS6.4.8) -- a change SS6.3.3 prohibits when EXAMINE'd; COPY is unaffected. */
+ session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
+ "(selected via EXAMINE)");
+ return (1);
+ }
+
+ if (s->store_iev == NULL) {
+ /* ST_SELECTED requires store_iev to already be wired. */
+ log_warnx("session %u: %s with no store channel wired",
+ s->id, cmdname);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ memset(&req, 0, sizeof(req));
+ req.by_uid = by_uid;
+ req.seq_lo = lo;
+ req.seq_hi = hi;
+ req.lo_is_star = lo_star;
+ req.hi_is_star = hi_star;
+ if (strlcpy(req.destname, mailbox, sizeof(req.destname)) >=
+ sizeof(req.destname) ||
+ strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->cmd_by_uid = by_uid;
+ s->cmd_is_move = is_move;
+ s->state = SESSION_COPYING;
+
+ if (imsg_compose(&s->store_iev->ibuf,
+ is_move ? IMSG_MBOX_MOVE : IMSG_MBOX_COPY, 0, 0, -1, &req,
+ sizeof(req)) == -1)
+ log_warn("session %u: imsg_compose %s", s->id, cmdname);
+ imsgev_add(s->store_iev);
+
+ return (1);
+}
+
+
+int
+cmd_copy(struct session *s, const char *tag, char *args)
+{
+ return copy_move_dispatch(s, tag, args, 0, 0);
+}
+
+
+int
+cmd_move(struct session *s, const char *tag, char *args)
+{
+ return copy_move_dispatch(s, tag, args, 0, 1);
+}
+
+/* RFC 9051 SS6.4.9 UID; dispatches to the same *_dispatch() bodies as base commands with by_uid=1. */
+int
+cmd_uid(struct session *s, const char *tag, char *args)
+{
+ char *sub, *subargs;
+
+ if (args == NULL) {
+ session_reply(s, tag, "BAD", "UID requires a sub-command");
+ return (1);
+ }
+
+ sub = args;
+ while (*args != '\0' && *args != ' ')
+ args++;
+ if (*args == ' ') {
+ *args++ = '\0';
+ while (*args == ' ')
+ args++;
+ subargs = (*args != '\0') ? args : NULL;
+ } else
+ subargs = NULL;
+
+ if (strcasecmp(sub, "FETCH") == 0)
+ return fetch_dispatch(s, tag, subargs, 1);
+ if (strcasecmp(sub, "STORE") == 0)
+ return store_do(s, tag, subargs, 1);
+ if (strcasecmp(sub, "SEARCH") == 0)
+ return search_dispatch(s, tag, subargs, 1);
+ if (strcasecmp(sub, "EXPUNGE") == 0)
+ return uid_expunge_dispatch(s, tag, subargs);
+ if (strcasecmp(sub, "COPY") == 0)
+ return copy_move_dispatch(s, tag, subargs, 1, 0);
+ if (strcasecmp(sub, "MOVE") == 0)
+ return copy_move_dispatch(s, tag, subargs, 1, 1);
+
+ session_reply(s, tag, "BAD",
+ "UID sub-command must be COPY, FETCH, MOVE, SEARCH, or STORE");
+ return (1);
+}
+
+/* RFC 9051 SS7.5.1 untagged EXPUNGE; RFC 7162 SS3.2.10.2 sends VANISHED <uid> instead once s->qresync_enabled. */
+void
+session_send_expunge_response(struct session *s,
+ const struct imsg_mbox_expunged *exp)
+{
+ char buf[32];
+
+ if (s->qresync_enabled)
+ snprintf(buf, sizeof(buf), "VANISHED %u", exp->uid);
+ else
+ snprintf(buf, sizeof(buf), "%u EXPUNGE", exp->seqno);
+ session_untagged(s, buf);
+}
+
+/* RFC 7162 SS3.2.6: one VANISHED (EARLIER) range, written immediately -- unlike QRESYNC SELECT's buffered resync. */
+void
+session_handle_fetch_vanished(struct session *s,
+ const struct imsg_mbox_select_vanished *v)
+{
+ char buf[64];
+
+ if (v->uid_lo == v->uid_hi)
+ snprintf(buf, sizeof(buf), "VANISHED (EARLIER) %u", v->uid_lo);
+ else
+ snprintf(buf, sizeof(buf), "VANISHED (EARLIER) %u:%u",
+ v->uid_lo, v->uid_hi);
+ session_untagged(s, buf);
+}
+
+/* Shared by cmd_expunge()/cmd_close()/UID EXPUNGE; SS6.4.9: '*' number is always a seqno, even for UID commands. */
+int
+session_request_expunge(struct session *s, const char *tag, int is_close,
+ int by_uid, uint32_t uid_lo, uint32_t uid_hi, int lo_star, int hi_star)
+{
+ struct imsg_mbox_expunge req;
+ const char *cmdname = is_close ? "CLOSE" :
+ (by_uid ? "UID EXPUNGE" : "EXPUNGE");
+
+ if (s->mbox_readonly) {
+ if (is_close) {
+ /* RFC 9051 SS6.4.1: EXAMINE'd -- skip the round trip, just deselect and reply OK, no error. */
+ s->state = SESSION_AUTHENTICATED;
+ session_reply(s, tag, "OK", "CLOSE completed");
+ return (1);
+ }
+ /* Unlike CLOSE, plain/UID EXPUNGE has no read-only exception -- SS6.3.3 applies directly (RFC 5530 CANNOT). */
+ session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
+ "(selected via EXAMINE)");
+ return (1);
+ }
+
+ if (s->store_iev == NULL) {
+ /* ST_SELECTED requires store_iev to already be wired. */
+ log_warnx("session %u: %s with no store channel wired",
+ s->id, cmdname);
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+
+ memset(&req, 0, sizeof(req));
+ req.silent = is_close;
+ req.by_uid = by_uid;
+ req.seq_lo = uid_lo;
+ req.seq_hi = uid_hi;
+ req.lo_is_star = lo_star;
+ req.hi_is_star = hi_star;
+
+ if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+ sizeof(s->pending_tag)) {
+ session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+ return (1);
+ }
+ s->close_after_expunge = is_close;
+ s->cmd_by_uid = by_uid;
+ s->state = SESSION_EXPUNGING;
+
+ if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_EXPUNGE, 0, 0, -1,
+ &req, sizeof(req)) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_EXPUNGE", s->id);
+ imsgev_add(s->store_iev);
+
+ return (1);
+}
+
+/* cmd_uid()'s EXPUNGE branch: requires a sequence set (plain EXPUNGE takes none); never reached with is_close set. */
+int
+uid_expunge_dispatch(struct session *s, const char *tag, const char *args)
+{
+ uint32_t lo, hi;
+ int lo_star, hi_star;
+
+ if (args == NULL) {
+ session_reply(s, tag, "BAD",
+ "UID EXPUNGE requires a sequence set of UIDs");
+ return (1);
+ }
+ if (strchr(args, ',') != NULL) {
+ session_reply(s, tag, "BAD",
+ "comma-separated sequence sets not supported in v1 -- "
+ "issue separate UID EXPUNGE commands");
+ return (1);
+ }
+ if (parse_seq_range(args, &lo, &hi, &lo_star, &hi_star) == -1) {
+ session_reply(s, tag, "BAD", "invalid sequence set");
+ return (1);
+ }
+
+ return session_request_expunge(s, tag, 0, 1, lo, hi, lo_star, hi_star);
+}
+
+/* Appends one MODIFIED entry (RFC 7162 SS3.1.3) to s->store_modified, growing by doubling from 16. */
+void
+session_handle_store_modified(struct session *s,
+ struct imsg_mbox_store_modified *m)
+{
+ if (s->store_modified_n == s->store_modified_cap) {
+ uint32_t newcap = s->store_modified_cap ?
+ s->store_modified_cap * 2 : 16;
+ uint32_t *n = reallocarray(s->store_modified, newcap,
+ sizeof(*n));
+
+ if (n == NULL) {
+ log_warn("session %u: realloc STORE MODIFIED array",
+ s->id);
+ return;
+ }
+ s->store_modified = n;
+ s->store_modified_cap = newcap;
+ }
+
+ /* RFC 7162 SS3.1.3: MODIFIED lists UIDs for UID STORE, seqnos otherwise (see s->cmd_by_uid in struct session). */
+ s->store_modified[s->store_modified_n++] =
+ s->cmd_by_uid ? m->uid : m->seqno;
+}
+
+/* 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,
+ int *truncated)
+{
+ size_t written = 0;
+ uint32_t i = 0;
+ int first = 1;
+
+ *truncated = 0;
+ buf[0] = '\0';
+
+ while (i < n) {
+ uint32_t start = nums[i];
+ uint32_t end = start;
+ uint32_t j = i + 1;
+ char tok[24];
+ int toklen;
+
+ 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)
+ 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;
+ i = j;
+ }
+
+ return (written);
+}
+
+/* RFC 7162 counterpart to format_seq_list() for VANISHED (EARLIER); ranges are pre-compacted by store.c already. */
+size_t
+format_range_list(char *buf, size_t bufsize, const struct vanished_range *ranges,
+ uint32_t n, int *truncated)
+{
+ size_t written = 0;
+ uint32_t i;
+ int first = 1;
+
+ *truncated = 0;
+ buf[0] = '\0';
+
+ for (i = 0; i < n; i++) {
+ char tok[24];
+ int toklen;
+
+ 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)
+ 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;
+ }
+
+ return (written);
+}
+
+/* Terminal COPY/MOVE reply; COPYUID via format_seq_list() (SS7.1); MOVE emits EXPUNGE/VANISHED before the tagged OK. */
+void
+session_finish_copy_or_move(struct session *s, const struct imsg_mbox_result *res)
+{
+ const char *cmdname = s->cmd_is_move ?
+ (s->cmd_by_uid ? "UID MOVE" : "MOVE") :
+ (s->cmd_by_uid ? "UID COPY" : "COPY");
+ uint32_t i;
+
+ s->state = SESSION_SELECTED;
+
+ if (res->error != MBOX_OP_OK || s->copy_alloc_failed) {
+ char text[48];
+
+ /* RFC 9051 SS6.4.7/SS6.4.8: destname valid but not found -- TRYCREATE, mirroring imsg_mbox_appended's field. */
+ if (!s->copy_alloc_failed &&
+ res->error == MBOX_OP_ERR_NO_SUCH_MAILBOX)
+ snprintf(text, sizeof(text), "[TRYCREATE] no such "
+ "mailbox");
+ else
+ snprintf(text, sizeof(text), "%s failed", cmdname);
+ session_reply(s, s->pending_tag, "NO", text);
+ goto cleanup;
+ }
+
+ /* RFC 7162 SS3.1.2.1: cache post-op HIGHESTMODSEQ (as for STORE/EXPUNGE) for session_condstore_enable()'s later use. */
+ s->mbox_highestmodseq = res->highestmodseq;
+
+ /* RFC 9051 SS6.3.13: notifies idle peers on the dest mailbox too; gated on copy_n > 0 (zero-match sends nothing). */
+ if (s->copy_n > 0)
+ session_notify_idle_peers(s);
+
+ if (s->copy_n > 0) {
+ char srcbuf[2048], destbuf[2048];
+ char text[4096 + 64];
+ int truncated;
+
+ format_seq_list(srcbuf, sizeof(srcbuf), s->copy_src_uids,
+ s->copy_n, &truncated);
+ if (truncated)
+ log_warnx("session %u: COPYUID source list truncated",
+ s->id);
+ format_seq_list(destbuf, sizeof(destbuf), s->copy_dest_uids,
+ s->copy_n, &truncated);
+ if (truncated)
+ log_warnx("session %u: COPYUID dest list truncated",
+ s->id);
+
+ if (s->cmd_is_move) {
+ snprintf(text, sizeof(text), "OK [COPYUID %u %s %s]",
+ res->uidvalidity, srcbuf, destbuf);
+ session_untagged(s, text);
+
+ for (i = 0; i < s->move_expunged_n; i++)
+ session_send_expunge_response(s,
+ &s->move_expunged[i]);
+
+ session_reply(s, s->pending_tag, "OK", "Done");
+ } else {
+ snprintf(text, sizeof(text),
+ "[COPYUID %u %s %s] %s completed",
+ res->uidvalidity, srcbuf, destbuf, cmdname);
+ session_reply(s, s->pending_tag, "OK", text);
+ }
+ } else {
+ /* RFC 9051 SS6.4.7's own worked example, verbatim. */
+ session_reply(s, s->pending_tag, "OK",
+ "No matching messages, so nothing copied");
+ }
+
+cleanup:
+ free(s->copy_src_uids);
+ s->copy_src_uids = NULL;
+ free(s->copy_dest_uids);
+ s->copy_dest_uids = NULL;
+ s->copy_n = 0;
+ s->copy_cap = 0;
+ s->copy_alloc_failed = 0;
+ free(s->move_expunged);
+ s->move_expunged = NULL;
+ s->move_expunged_n = 0;
+ s->move_expunged_cap = 0;
+ s->cmd_is_move = 0;
+}
blob - /dev/null
blob + 14b7c31bb86a1912212a2074a0f5bb4d66a11915 (mode 644)
--- /dev/null
+++ src/store_internal.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.
+ */
+
+/*
+ * store_internal.h -- internal, store-process-only shared declarations.
+ *
+ * Not installed, not part of imapd's public wire protocol (that's
+ * imapd.h) -- this exists solely so store.c's core (the imsg dispatch
+ * loop) and the eight files split out of what used to be one 7,768-line
+ * store.c (index.c, mime.c, envelope.c, mbox_fetch.c, mbox_search.c,
+ * mbox_store.c, mbox_manage.c, mbox_copy.c, and store.c itself) can all
+ * see struct mbox_index/struct index_rec and each other's entry points.
+ * Nothing in here crosses process boundaries -- that's imapd.h's job,
+ * behind the imsg wire structs instead.
+ */
+
+#ifndef STORE_INTERNAL_H
+#define STORE_INTERNAL_H
+
+#include <sys/types.h>
+
+#include <stdint.h>
+
+struct mbox_index {
+ uint32_t uidvalidity;
+ uint32_t uidnext;
+ uint64_t highestmodseq; /* RFC 7162 SS3.1: per-mailbox highest
+ * mod-sequence, persisted as the
+ * index header's third field (see
+ * index_load()/index_save()). v1's
+ * only mailbox always supports this
+ * (there is no on-disk format that
+ * predates it in a real deployment of
+ * this not-yet-released server), so
+ * the NOMODSEQ response code
+ * (RFC 7162 SS3.1.2.2) is simply
+ * unreachable in this implementation. */
+ char **lines; /* raw "UID:basename:keywords:MODSEQ"
+ * lines, no trailing newline, one
+ * malloc(3) each -- the MODSEQ field
+ * is this pass's addition; see
+ * index_parse_line() */
+ size_t nlines;
+ size_t cap;
+};
+
+/*
+ * One index message line, parsed. Introduced this pass (RFC 7162) to stop
+ * handle_mbox_fetch()/handle_mbox_store()/handle_mbox_expunge() from each
+ * hand-rolling their own "UID:basename:keywords" strchr() chain -- adding
+ * a fourth colon-delimited field (MODSEQ) to every one of those independently
+ * would have tripled the size of this change and the chance of one of them
+ * drifting out of sync with the on-disk format. basename/keywords are
+ * fixed-size copies (same 512-byte bound index_load()'s own STORE_INDEX_
+ * LINE_MAX line buffer already implies is generous for either field), not
+ * pointers into the original line -- callers are free to mutate or discard
+ * the line string after parsing.
+ *
+ * Backward compatibility: the MODSEQ field is new this pass. A line with
+ * only three colon-delimited fields (written by a pre-CONDSTORE build of
+ * this server) parses successfully with modseq defaulted to 1 -- this
+ * server has never had a real deployment to be compatible *with*, so this
+ * is a defensive nicety, not a migration guarantee this project is making
+ * any promise about.
+ */
+struct index_rec {
+ uint32_t uid;
+ char basename[512];
+ char keywords[512];
+ uint64_t modseq;
+};
+
+/*
+ * One message staged by stage_copy_messages() (mbox_copy.c) for
+ * handle_mbox_copy()/handle_mbox_move() -- read fully into memory from
+ * the source mailbox, not yet written anywhere. Shared here (not local
+ * to mbox_copy.c) because commit_copy_messages() and move_cross_mailbox()
+ * take it as a parameter type too.
+ */
+struct copy_staged {
+ uint32_t src_uid;
+ uint32_t dest_uid;
+ char basename[256];
+ char keywords[512];
+ uint32_t sysflags;
+ void *body;
+ size_t bodylen;
+};
+
+/*
+ * F6 fix: bound how much message data COPY/MOVE holds in memory at once.
+ * stage_copy_messages() reads every message in the range fully into RAM
+ * before commit (required for RFC 9051 6.4.7 all-or-nothing COPY), so an
+ * unbounded range of large, externally-delivered messages could exhaust
+ * the store child's address space. Exceeding either limit fails the whole
+ * COPY/MOVE cleanly (no partial copy). Tunable for larger deployments.
+ */
+#define COPY_STAGE_MSG_MAX ((uint64_t)64 * 1024 * 1024) /* per message */
+#define COPY_STAGE_TOTAL_MAX ((uint64_t)512 * 1024 * 1024) /* per operation */
+
+/*
+ * Globals shared across the files this process's source is split into
+ * (definitions live in store.c's core; see that file for why each
+ * exists).
+ */
+extern uint32_t session_id;
+extern uint32_t append_counter;
+extern char current_mailbox_dir[MBOX_NAME_MAX];
+
+/*
+ * Cross-file entry points. Same set of forward declarations store.c
+ * carried as `static` prototypes before the file was split into store.c
+ * (core) + index.c + mime.c + envelope.c + mbox_fetch.c + mbox_search.c
+ * + mbox_store.c + mbox_manage.c + mbox_copy.c -- moved here verbatim
+ * (minus `static`) rather than redesigned, so this split is a pure move
+ * with no behavior change.
+ */
+extern uint32_t bodystructure_read_max; /* from IMSG_STORE_INIT --
+ * see imapd.h's struct imsg_store_init
+ * comment and BODYSTRUCTURE_READ_
+ * DEFAULT's comment for what this
+ * gates and where the operator-
+ * configured value comes from. Missing
+ * `extern` here once meant every file
+ * that included this header got its
+ * own tentative definition -- harmless
+ * within a single translation unit
+ * (which is how the original,
+ * unsplit store.c got away with the
+ * same bare declaration), but nine
+ * separate definitions once split
+ * across files, caught as a real
+ * duplicate-symbol link error. */
+
+/*
+ * Per-store-child monotonic counter feeding the maildir basename uniquer
+ * (see handle_mbox_append()'s header comment for the full `<timestamp>.
+ * <pid>_<counter>.<hostname>` scheme). Was originally a function-local
+ * static inside handle_mbox_append() alone; hoisted to file scope this
+ * pass so handle_mbox_copy() -- COPY's own message-duplication path, which
+ * mints a brand new basename per copied message the same way APPEND does
+ * -- shares the same monotonic sequence rather than starting its own at 0,
+ * which could otherwise collide with an APPEND's basename minted in the
+ * same second by the same pid.
+ */
+
+/*
+ * RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 addition (flat multi-mailbox support):
+ * which named mailbox (if any) this store child's cwd is currently
+ * chdir'd into,
+ * relative to the session's own maildir root. Empty string means "at the
+ * root" -- i.e. INBOX, or nothing selected yet -- the same state every
+ * store child has always implicitly been in before this pass, when INBOX
+ * was the only mailbox that could ever exist. handle_mbox_select() is the
+ * only function that ever changes this; every other IMSG_MBOX_* handler
+ * (FETCH/STORE/EXPUNGE/IDLE-refresh/QRESYNC/...) keeps using bare
+ * relative paths exactly as before, unmodified, since cwd is always
+ * "wherever the currently selected mailbox is" by construction.
+ */
+
+void store_dispatch(int, short, void *);
+void store_shutdown(void);
+void handle_mbox_select(struct imsg_mbox_select *, struct imsgev *);
+void handle_mbox_fetch(struct imsg_mbox_fetch *, struct imsgev *);
+void handle_mbox_store(struct imsg_mbox_store *, struct imsgev *);
+void handle_mbox_expunge(struct imsg_mbox_expunge *, struct imsgev *);
+void handle_mbox_append(struct imsg_mbox_append *, const char *,
+ size_t, struct imsgev *);
+void handle_mbox_search(struct imsg_mbox_search *,
+ struct search_node *, uint32_t, struct imsgev *);
+void handle_mbox_status(struct imsg_mbox_status *, struct imsgev *);
+void handle_mbox_copy(struct imsg_mbox_copy *, struct imsgev *);
+void handle_mbox_move(struct imsg_mbox_copy *, struct imsgev *);
+void handle_mbox_create(struct imsg_mbox_create *, struct imsgev *);
+void handle_mbox_delete(struct imsg_mbox_delete *, struct imsgev *);
+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 locate_message_file(const char *, off_t *, char *, size_t);
+int open_message_file(const char *);
+int read_message_header(const char *, char **, uint32_t *);
+int read_message_body(const char *, int, size_t, const char *,
+ char **, uint32_t *);
+int header_field_name_matches(const char *, size_t, const char *);
+int read_message_header_fields(const char *, const char *, int,
+ char **, uint32_t *);
+void build_flags_string(const char *, const char *, char *,
+ size_t);
+int extract_header_field(const char *, size_t, const char *,
+ char **, size_t *);
+int envbuf_append(char *, size_t, size_t *, const char *, size_t);
+int envbuf_append_str(char *, size_t, size_t *, const char *);
+int envbuf_append_nstring(char *, size_t, size_t *, const char *,
+ size_t);
+int envbuf_append_one_address(char *, size_t, size_t *,
+ const char *, size_t);
+int envbuf_append_address_list(char *, size_t, size_t *,
+ const char *, size_t);
+int append_field_nstring(char *, size_t, size_t *, const char *,
+ uint32_t, const char *);
+int build_envelope(const char *, char **, uint32_t *);
+int find_header_body_split(const char *, size_t, size_t *);
+int mime_is_tspecial(char);
+int mime_read_token_or_qstring(const char *, size_t, size_t *,
+ char *, size_t);
+void mime_str_upper(char *);
+int parse_content_type(const char *, size_t, char *, size_t,
+ char *, size_t, char *, size_t, char *, size_t, int *);
+int split_multipart(const char *, size_t, const char *,
+ size_t *, size_t *, int *, int);
+int build_body_structure(int, int *, const char *, size_t,
+ const char *, size_t, char *, size_t, size_t *);
+int build_bodystructure(const char *, char **, uint32_t *);
+int parse_section_part(const char *, int *, int);
+int find_mime_part(int, const char *, size_t, const char *,
+ size_t, const int *, int, const char **, size_t *);
+int locate_mime_part(const char *, size_t, const char *, size_t,
+ const int *, int, const char **, size_t *);
+void apply_partial_range(const char *, size_t, int, uint32_t,
+ uint32_t, const char **, size_t *);
+int extract_mime_part(const char *, const int *, int, int,
+ uint32_t, uint32_t, char **, uint32_t *);
+
+/*
+ * The maildir+index format: stock maildir (tmp/new/cur) for message
+ * bodies, plus one small line-oriented text index per mailbox holding
+ * UIDVALIDITY/UIDNEXT and the UID<->basename(<->keywords) map. v1 is
+ * INBOX-only, and that index lives directly in
+ * the maildir root store.c is now chdir'd into -- a sibling of tmp/new/
+ * cur, not inside any of them. Name is plain and `ls`-visible on
+ * purpose, matching this project's own stated preference for
+ * boring/inspectable formats over hidden dotfiles.
+ */
+#define STORE_INDEX_NAME "imapd.index"
+#define STORE_INDEX_TMP_NAME "imapd.index.tmp"
+#define STORE_INDEX_LINE_MAX 1024 /* matches auth.c's cred_lookup()
+ * line-buffer precedent for the same
+ * kind of small, personal-scope flat
+ * text file */
+
+/*
+ * In-memory copy of one mailbox's index while a single IMSG_MBOX_SELECT
+ * (or, later, any other mutating mbox op) is being handled -- built fresh
+ * from the on-disk file, mutated, and rewritten in full per the design
+ * doc's resolved "whole file rewritten to a temp file and rename(2)'d
+ * over on every mutation" rule, then discarded. Never kept around between
+ * imsg messages.
+ */
+
+int index_load(int, struct mbox_index *);
+int index_has_basename(struct mbox_index *, const char *);
+int index_append(struct mbox_index *, uint32_t, const char *);
+int index_save(const struct mbox_index *);
+void index_free(struct mbox_index *);
+int index_parse_line(const char *, struct index_rec *);
+uint32_t index_max_uid(struct mbox_index *);
+void send_vanished_range(const struct mbox_index *, uint32_t, uint32_t,
+ struct imsgev *);
+int refresh_index(struct mbox_index *, int);
+void handle_mbox_idle_refresh(struct imsgev *);
+void qresync_send_resync(const struct imsg_mbox_select *,
+ const struct mbox_index *, struct imsgev *);
+
+/*
+ * The remaining six are single-purpose helpers that happen to be called
+ * from more than one of the split-out files (unlike the bulk of each
+ * file's own static helpers, which stayed file-local) -- see each
+ * definition's own comment for why.
+ */
+const char *append_hostname(void);
+int ensure_maildir_dirs(const char *);
+uint32_t letters_to_sysflags(const char *);
+void merge_keywords(int, const char *, const char *, char *,
+ size_t);
+int64_t parse_maildir_timestamp(const char *);
+void sysflags_to_letters(uint32_t, char *, size_t);
+
+#endif /* STORE_INTERNAL_H */
blob - /dev/null
blob + 17b76e30c7c676291164a0bd047b0c49a090866d (mode 644)
--- /dev/null
+++ src/store_ipc.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.
+ */
+
+/*
+ * 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 <sys/socket.h>
+
+#include <netinet/in.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <event.h>
+#include <imsg.h>
+#include <resolv.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <tls.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "listener.h"
+
+void
+session_store_dispatch(int fd, short event, void *arg)
+{
+ struct session *s = arg;
+ struct imsg imsg;
+ ssize_t n;
+
+ /* EV_WRITE: queued IMSG_MBOX_* requests need imsgbuf_write() once the fd is writable. */
+ if (event & EV_WRITE) {
+ if (imsgbuf_write(&s->store_iev->ibuf) == -1) {
+ log_warnx("session %u: imsgbuf_write (store)", s->id);
+ session_teardown(s);
+ return;
+ }
+ }
+
+ if (event & EV_READ) {
+ if ((n = imsgbuf_read(&s->store_iev->ibuf)) == -1) {
+ log_warnx("session %u: imsgbuf_read (store)", s->id);
+ session_teardown(s);
+ return;
+ }
+ if (n == 0) {
+ log_warnx("session %u: store closed channel", s->id);
+ session_teardown(s);
+ return;
+ }
+ }
+
+ for (;;) {
+ if ((n = imsg_get(&s->store_iev->ibuf, &imsg)) == -1) {
+ log_warnx("session %u: imsg_get (store)", s->id);
+ session_teardown(s);
+ return;
+ }
+ if (n == 0)
+ break;
+
+ switch (imsg_get_type(&imsg)) {
+ case IMSG_MBOX_SELECTED: {
+ struct imsg_mbox_selected res;
+
+ if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
+ log_warnx("bad IMSG_MBOX_SELECTED");
+ break;
+ }
+ session_handle_mbox_selected(s, &res);
+ break;
+ }
+ case IMSG_MBOX_STATUS_RESULT: {
+ struct imsg_mbox_status_result res;
+
+ if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
+ log_warnx("bad IMSG_MBOX_STATUS_RESULT");
+ break;
+ }
+ session_handle_mbox_status_result(s, &res);
+ break;
+ }
+ case IMSG_MBOX_FETCH_HEADER: {
+ struct imsg_mbox_fetch_header hdr;
+ size_t hdrlen;
+
+ /* Fixed-header-plus-variable-trailing-bytes, same technique as store.c's handle_mbox_append(). */
+ if (imsg_get_buf(&imsg, &hdr, sizeof(hdr)) == -1) {
+ log_warnx("bad IMSG_MBOX_FETCH_HEADER (header)");
+ break;
+ }
+ free(s->pending_header_buf);
+ s->pending_header_buf = NULL;
+ s->pending_header_len = 0;
+ s->pending_header_found = 0;
+
+ hdrlen = imsg_get_len(&imsg);
+ if (!hdr.found)
+ break; /* store.c found nothing for this message */
+ if (hdrlen != hdr.hdrlen) {
+ log_warnx("session %u: IMSG_MBOX_FETCH_HEADER "
+ "length mismatch (header says %u, imsg "
+ "has %zu)", s->id, hdr.hdrlen, hdrlen);
+ break;
+ }
+ if (hdrlen == 0) {
+ /* found but empty is legitimate, if odd (a header-less file) */
+ s->pending_header_found = 1;
+ break;
+ }
+ if ((s->pending_header_buf = malloc(hdrlen)) == NULL) {
+ log_warn("session %u: malloc pending header "
+ "buffer", s->id);
+ break;
+ }
+ if (imsg_get_buf(&imsg, s->pending_header_buf, hdrlen)
+ == -1) {
+ log_warnx("bad IMSG_MBOX_FETCH_HEADER (body)");
+ free(s->pending_header_buf);
+ s->pending_header_buf = NULL;
+ break;
+ }
+ s->pending_header_len = (uint32_t)hdrlen;
+ s->pending_header_found = 1;
+ break;
+ }
+ case IMSG_MBOX_FETCH_BODY: {
+ struct imsg_mbox_fetch_body bodyhdr;
+ size_t bodylen;
+
+ /* Same technique as IMSG_MBOX_FETCH_HEADER just above -- see that case's comment. */
+ if (imsg_get_buf(&imsg, &bodyhdr, sizeof(bodyhdr)) ==
+ -1) {
+ log_warnx("bad IMSG_MBOX_FETCH_BODY (header)");
+ break;
+ }
+ free(s->pending_body_buf);
+ s->pending_body_buf = NULL;
+ s->pending_body_len = 0;
+ s->pending_body_found = 0;
+ /* pending_body_label/has_partial/partial_origin are NOT reset here -- set once per FETCH in fetch_dispatch(). */
+
+ bodylen = imsg_get_len(&imsg);
+ if (!bodyhdr.found)
+ break; /* store.c found nothing for this message */
+ if (bodylen != bodyhdr.bodylen) {
+ log_warnx("session %u: IMSG_MBOX_FETCH_BODY "
+ "length mismatch (header says %u, imsg "
+ "has %zu)", s->id, bodyhdr.bodylen,
+ bodylen);
+ break;
+ }
+ if (bodylen == 0) {
+ /* found but empty is legitimate (zero-length msg, or BODY.PEEK[TEXT] on an all-header msg) */
+ s->pending_body_found = 1;
+ break;
+ }
+ if ((s->pending_body_buf = malloc(bodylen)) == NULL) {
+ log_warn("session %u: malloc pending body "
+ "buffer", s->id);
+ break;
+ }
+ if (imsg_get_buf(&imsg, s->pending_body_buf, bodylen)
+ == -1) {
+ log_warnx("bad IMSG_MBOX_FETCH_BODY (body)");
+ free(s->pending_body_buf);
+ s->pending_body_buf = NULL;
+ break;
+ }
+ s->pending_body_len = (uint32_t)bodylen;
+ s->pending_body_found = 1;
+ break;
+ }
+ case IMSG_MBOX_FETCH_ENVELOPE: {
+ struct imsg_mbox_fetch_envelope envhdr;
+ size_t envlen;
+
+ /* Same technique as IMSG_MBOX_FETCH_HEADER/IMSG_MBOX_FETCH_BODY -- see IMSG_MBOX_FETCH_HEADER's comment. */
+ if (imsg_get_buf(&imsg, &envhdr, sizeof(envhdr)) ==
+ -1) {
+ log_warnx("bad IMSG_MBOX_FETCH_ENVELOPE (header)");
+ break;
+ }
+ free(s->pending_envelope_buf);
+ s->pending_envelope_buf = NULL;
+ s->pending_envelope_len = 0;
+ s->pending_envelope_found = 0;
+
+ envlen = imsg_get_len(&imsg);
+ if (!envhdr.found)
+ break; /* store.c couldn't build an envelope for this message */
+ if (envlen != envhdr.envlen) {
+ log_warnx("session %u: IMSG_MBOX_FETCH_ENVELOPE "
+ "length mismatch (header says %u, imsg "
+ "has %zu)", s->id, envhdr.envlen, envlen);
+ break;
+ }
+ if (envlen == 0) {
+ /* build_envelope() always emits at least all-NIL fields; handled defensively anyway */
+ s->pending_envelope_found = 1;
+ break;
+ }
+ if ((s->pending_envelope_buf = malloc(envlen)) == NULL) {
+ log_warn("session %u: malloc pending envelope "
+ "buffer", s->id);
+ break;
+ }
+ if (imsg_get_buf(&imsg, s->pending_envelope_buf, envlen)
+ == -1) {
+ log_warnx("bad IMSG_MBOX_FETCH_ENVELOPE (body)");
+ free(s->pending_envelope_buf);
+ s->pending_envelope_buf = NULL;
+ break;
+ }
+ s->pending_envelope_len = (uint32_t)envlen;
+ s->pending_envelope_found = 1;
+ break;
+ }
+ case IMSG_MBOX_FETCH_BODYSTRUCTURE: {
+ struct imsg_mbox_fetch_bodystructure bshdr;
+ size_t bslen;
+
+ /* Same technique as IMSG_MBOX_FETCH_HEADER/IMSG_MBOX_FETCH_ENVELOPE -- see IMSG_MBOX_FETCH_HEADER's comment. */
+ if (imsg_get_buf(&imsg, &bshdr, sizeof(bshdr)) ==
+ -1) {
+ log_warnx("bad IMSG_MBOX_FETCH_BODYSTRUCTURE "
+ "(header)");
+ break;
+ }
+ free(s->pending_bodystructure_buf);
+ s->pending_bodystructure_buf = NULL;
+ s->pending_bodystructure_len = 0;
+ s->pending_bodystructure_found = 0;
+
+ bslen = imsg_get_len(&imsg);
+ if (!bshdr.found)
+ break; /* store.c couldn't build a BODYSTRUCTURE for this message */
+ if (bslen != bshdr.bslen) {
+ log_warnx("session %u: "
+ "IMSG_MBOX_FETCH_BODYSTRUCTURE length "
+ "mismatch (header says %u, imsg has %zu)",
+ s->id, bshdr.bslen, bslen);
+ break;
+ }
+ if (bslen == 0) {
+ /* build_bodystructure() always emits at least a minimal single-part structure; handled anyway */
+ s->pending_bodystructure_found = 1;
+ break;
+ }
+ if ((s->pending_bodystructure_buf = malloc(bslen)) ==
+ NULL) {
+ log_warn("session %u: malloc pending "
+ "bodystructure buffer", s->id);
+ break;
+ }
+ if (imsg_get_buf(&imsg, s->pending_bodystructure_buf,
+ bslen) == -1) {
+ log_warnx("bad IMSG_MBOX_FETCH_BODYSTRUCTURE "
+ "(body)");
+ free(s->pending_bodystructure_buf);
+ s->pending_bodystructure_buf = NULL;
+ break;
+ }
+ s->pending_bodystructure_len = (uint32_t)bslen;
+ s->pending_bodystructure_found = 1;
+ break;
+ }
+ case IMSG_MBOX_FETCH_META: {
+ struct imsg_mbox_fetch_meta meta;
+
+ if (imsg_get_data(&imsg, &meta, sizeof(meta)) == -1) {
+ log_warnx("bad IMSG_MBOX_FETCH_META");
+ break;
+ }
+ /* 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)
+ session_handle_select_fetch(s, &meta);
+ else if (s->state == SESSION_STORING)
+ session_send_store_fetch_response(s, &meta);
+ else
+ session_send_fetch_response(s, &meta);
+ break;
+ }
+ case IMSG_MBOX_EXPUNGED: {
+ struct imsg_mbox_expunged exp;
+
+ if (imsg_get_data(&imsg, &exp, sizeof(exp)) == -1) {
+ log_warnx("bad IMSG_MBOX_EXPUNGED");
+ break;
+ }
+ /* During a MOVE, buffered here and flushed after COPYUID by session_finish_copy_or_move() (SS6.4.8 ordering). */
+ if (s->state == SESSION_COPYING) {
+ if (s->move_expunged_n == s->move_expunged_cap) {
+ uint32_t newcap =
+ s->move_expunged_cap ?
+ s->move_expunged_cap * 2 : 16;
+ struct imsg_mbox_expunged *enew =
+ reallocarray(s->move_expunged,
+ newcap, sizeof(*enew));
+
+ if (enew == NULL) {
+ log_warn("session %u: "
+ "realloc move_expunged",
+ s->id);
+ break;
+ }
+ s->move_expunged = enew;
+ s->move_expunged_cap = newcap;
+ }
+ s->move_expunged[s->move_expunged_n++] = exp;
+ } else
+ session_send_expunge_response(s, &exp);
+ break;
+ }
+ case IMSG_MBOX_COPY_MAPPING: {
+ struct imsg_mbox_copy_mapping m;
+
+ if (imsg_get_data(&imsg, &m, sizeof(m)) == -1) {
+ log_warnx("bad IMSG_MBOX_COPY_MAPPING");
+ break;
+ }
+ session_handle_mbox_copy_mapping(s, &m);
+ break;
+ }
+ case IMSG_MBOX_SEARCH_MATCH: {
+ struct imsg_mbox_search_match m;
+
+ if (imsg_get_data(&imsg, &m, sizeof(m)) == -1) {
+ log_warnx("bad IMSG_MBOX_SEARCH_MATCH");
+ break;
+ }
+ session_handle_mbox_search_match(s, &m);
+ break;
+ }
+ case IMSG_MBOX_SELECT_VANISHED: {
+ struct imsg_mbox_select_vanished v;
+
+ if (imsg_get_data(&imsg, &v, sizeof(v)) == -1) {
+ log_warnx("bad IMSG_MBOX_SELECT_VANISHED");
+ break;
+ }
+ /* Also reused for RFC 7162 SS3.2.6's VANISHED UID FETCH modifier; SELECTING buffers, FETCHING writes immediately. */
+ if (s->state == SESSION_SELECTING)
+ session_handle_select_vanished(s, &v);
+ else
+ session_handle_fetch_vanished(s, &v);
+ break;
+ }
+ case IMSG_MBOX_STORE_MODIFIED: {
+ struct imsg_mbox_store_modified m;
+
+ if (imsg_get_data(&imsg, &m, sizeof(m)) == -1) {
+ log_warnx("bad IMSG_MBOX_STORE_MODIFIED");
+ break;
+ }
+ session_handle_store_modified(s, &m);
+ break;
+ }
+ case IMSG_MBOX_IDLE_UID: {
+ struct imsg_mbox_idle_uid item;
+
+ if (imsg_get_data(&imsg, &item, sizeof(item)) == -1) {
+ log_warnx("bad IMSG_MBOX_IDLE_UID");
+ break;
+ }
+ session_handle_idle_uid(s, &item);
+ break;
+ }
+ case IMSG_MBOX_IDLE_REFRESHED: {
+ struct imsg_mbox_idle_refreshed res;
+
+ if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
+ log_warnx("bad IMSG_MBOX_IDLE_REFRESHED");
+ break;
+ }
+ session_handle_idle_refreshed(s, &res);
+ break;
+ }
+ case IMSG_MBOX_RESULT: {
+ struct imsg_mbox_result res;
+
+ if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
+ log_warnx("bad IMSG_MBOX_RESULT");
+ break;
+ }
+ session_handle_mbox_result(s, &res);
+ break;
+ }
+ case IMSG_MBOX_LIST_ITEM: {
+ struct imsg_mbox_list_item item;
+
+ if (imsg_get_data(&imsg, &item, sizeof(item)) == -1) {
+ log_warnx("bad IMSG_MBOX_LIST_ITEM");
+ break;
+ }
+ session_handle_mbox_list_item(s, &item);
+ break;
+ }
+ case IMSG_MBOX_APPENDED: {
+ struct imsg_mbox_appended res;
+
+ if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
+ log_warnx("bad IMSG_MBOX_APPENDED");
+ break;
+ }
+ session_handle_mbox_appended(s, &res);
+ break;
+ }
+ default:
+ log_debug("session %u: store channel: unhandled %d",
+ s->id, imsg_get_type(&imsg));
+ break;
+ }
+ imsg_free(&imsg);
+ }
+ imsgev_add(s->store_iev); /* re-arm for the next round of IMSG_MBOX_* replies */
+ (void)fd;
+
+ /* If the reply just processed above was the terminal one, s->state is idle again -- drain anything pipelined behind it. */
+ if (!session_dequeue_next(s))
+ return; /* s was torn down by a queued LOGOUT -- do not touch */
+}
+
+/* Finishes SELECT (SS6.3.2); flushes QRESYNC resync in RFC order: VANISHED (EARLIER), then FETCH, then tagged OK. */
+void
+session_handle_mbox_selected(struct session *s, const struct imsg_mbox_selected *res)
+{
+ char buf[128];
+
+ if (res->error != MBOX_OP_OK) {
+ /* RFC 9051 SS6.3.2 failure -> authenticated (RFC 5530 NONEXISTENT text, any cause). */
+ s->state = SESSION_AUTHENTICATED;
+ session_reply(s, s->pending_tag, "NO",
+ "[NONEXISTENT] no such mailbox");
+ return;
+ }
+
+ s->state = SESSION_SELECTED;
+ s->mbox_highestmodseq = res->highestmodseq;
+
+ /* Order matches RFC 9051 SS6.3.2's worked example; that section says the order itself isn't actually significant. */
+ snprintf(buf, sizeof(buf), "%u EXISTS", res->exists);
+ session_untagged(s, buf);
+
+ snprintf(buf, sizeof(buf), "OK [UIDVALIDITY %u] UIDs valid",
+ res->uidvalidity);
+ session_untagged(s, buf);
+
+ snprintf(buf, sizeof(buf), "OK [UIDNEXT %u] Predicted next UID",
+ res->uidnext);
+ session_untagged(s, buf);
+
+ if (s->condstore_enabled) {
+ snprintf(buf, sizeof(buf), "OK [HIGHESTMODSEQ %llu]",
+ (unsigned long long)res->highestmodseq);
+ session_untagged(s, buf);
+ }
+
+ /* PERMANENTFLAGS: EXAMINE gets none (SS6.3.3); SELECT adds "\*" since arbitrary keywords may be created (SS6.3.2). */
+ session_untagged(s,
+ "FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)");
+ if (s->mbox_readonly)
+ session_untagged(s,
+ "OK [PERMANENTFLAGS ()] No permanent flags permitted");
+ else
+ session_untagged(s,
+ "OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen "
+ "\\Draft \\*)] System flags and keywords allowed");
+
+ /* RFC 9051 SS6.3.2 LIST; "/" matches cmd_namespace()'s delimiter; name quoted since it may contain a space. */
+ {
+ char listbuf[MBOX_NAME_MAX + 32];
+
+ snprintf(listbuf, sizeof(listbuf), "LIST () \"/\" \"%s\"",
+ s->selected_mailbox);
+ session_untagged(s, listbuf);
+ }
+
+ if (s->vanished_nranges > 0) {
+ char vbuf[8192];
+ char full[8192 + 32];
+ int truncated;
+ size_t vlen;
+ int flen;
+
+ /* session_untagged()'s 512-byte buffer is too small here -- same bypass session_finish_search() uses for ESEARCH. */
+ vlen = format_range_list(vbuf, sizeof(vbuf),
+ s->vanished_ranges, s->vanished_nranges, &truncated);
+ if (truncated)
+ log_warnx("session %u: VANISHED (EARLIER) list "
+ "truncated at %zu bytes", s->id, vlen);
+
+ flen = snprintf(full, sizeof(full), "* VANISHED (EARLIER) %s\r\n",
+ vbuf);
+ if (flen > 0)
+ session_write(s, full, (size_t)flen >= sizeof(full) ?
+ sizeof(full) - 1 : (size_t)flen);
+ }
+ free(s->vanished_ranges);
+ s->vanished_ranges = NULL;
+ s->vanished_nranges = 0;
+ s->vanished_cap = 0;
+
+ {
+ uint32_t i;
+
+ for (i = 0; i < s->qresync_nfetches; i++)
+ session_send_qresync_fetch_response(s,
+ &s->qresync_fetches[i]);
+ }
+ free(s->qresync_fetches);
+ s->qresync_fetches = NULL;
+ s->qresync_nfetches = 0;
+ s->qresync_fetches_cap = 0;
+
+ /* RFC 9051 SS6.3.2/SS6.3.3: READ-WRITE for SELECT, READ-ONLY for EXAMINE, distinguished solely by s->mbox_readonly. */
+ if (s->mbox_readonly)
+ session_reply(s, s->pending_tag, "OK",
+ "[READ-ONLY] EXAMINE completed");
+ else
+ session_reply(s, s->pending_tag, "OK",
+ "[READ-WRITE] SELECT completed");
+}
+
+/* Finishes STATUS (SS6.3.11); attrs emitted in fixed canonical order, not the client's request order. */
+void
+session_handle_mbox_status_result(struct session *s,
+ const struct imsg_mbox_status_result *res)
+{
+ char buf[MBOX_NAME_MAX + 256]; /* F2 fix: buf[256] was too small for a near-max mailbox name + STATUS attrs */
+ size_t len;
+ int n, first = 1;
+
+ s->state = s->status_prev_state;
+
+ if (res->error != MBOX_OP_OK) {
+ /* Valid-but-missing name or store.c's open/flock/index_load failure -- can't tell apart, so NONEXISTENT covers both. */
+ session_reply(s, s->pending_tag, "NO",
+ "[NONEXISTENT] no such mailbox");
+ return;
+ }
+
+ /* Quoted, not bare -- mailbox names can contain spaces; no backslash-escaping, same gap parse_list_token() has. */
+ len = (size_t)snprintf(buf, sizeof(buf), "STATUS \"%s\" (",
+ s->status_mailbox);
+
+#define STATUS_APPEND(fmt, val) do { \
+ if (len < sizeof(buf)) { /* F2 fix: never index past buf */ \
+ n = snprintf(buf + len, sizeof(buf) - len, "%s" fmt, \
+ first ? "" : " ", (val)); \
+ if (n > 0 && (size_t)n < sizeof(buf) - len) \
+ len += (size_t)n; \
+ first = 0; \
+ } \
+} while (0)
+
+ 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. */
+ STATUS_APPEND("RECENT %u", 0U);
+ if (s->status_attrs & STATUS_ATT_UIDNEXT)
+ STATUS_APPEND("UIDNEXT %u", res->uidnext);
+ if (s->status_attrs & STATUS_ATT_UIDVALIDITY)
+ STATUS_APPEND("UIDVALIDITY %u", res->uidvalidity);
+ if (s->status_attrs & STATUS_ATT_UNSEEN)
+ STATUS_APPEND("UNSEEN %u", res->unseen);
+ if (s->status_attrs & STATUS_ATT_DELETED)
+ STATUS_APPEND("DELETED %u", res->deleted);
+ if (s->status_attrs & STATUS_ATT_SIZE)
+ STATUS_APPEND("SIZE %llu", (unsigned long long)res->size);
+ if (s->status_attrs & STATUS_ATT_HIGHESTMODSEQ)
+ STATUS_APPEND("HIGHESTMODSEQ %llu",
+ (unsigned long long)res->highestmodseq);
+
+#undef STATUS_APPEND
+
+ if (len < sizeof(buf) - 1) {
+ buf[len++] = ')';
+ buf[len] = '\0';
+ }
+
+ session_untagged(s, buf);
+ session_reply(s, s->pending_tag, "OK", "STATUS completed");
+}
+
+/* Terminal CREATE/DELETE/RENAME reply; s->state is read (which command) before it's overwritten. */
+void
+session_finish_mbox_op(struct session *s, const struct imsg_mbox_result *res)
+{
+ const char *cmdname;
+ char text[64];
+
+ if (s->state == SESSION_CREATING)
+ cmdname = "CREATE";
+ else if (s->state == SESSION_DELETING)
+ cmdname = "DELETE";
+ else
+ cmdname = "RENAME";
+
+ s->state = s->mbox_op_prev_state;
+
+ switch (res->error) {
+ case MBOX_OP_OK:
+ break;
+ case MBOX_OP_ERR_NO_SUCH_MAILBOX:
+ /* RFC 5530 SS3 NONEXISTENT: DELETE of a missing mailbox, or RENAME with a missing source */
+ session_reply(s, s->pending_tag, "NO",
+ "[NONEXISTENT] no such mailbox");
+ return;
+ case MBOX_OP_ERR_ALREADY_EXISTS:
+ /* RFC 5530 SS3 ALREADYEXISTS: CREATE of an existing mailbox, or RENAME to an existing destination */
+ session_reply(s, s->pending_tag, "NO",
+ "[ALREADYEXISTS] mailbox already exists");
+ return;
+ default:
+ /* mkdir/stat/rename(2) I/O error or truncated buffer -- no RFC 5530 code fits, plain NO. */
+ snprintf(text, sizeof(text), "%s failed", cmdname);
+ session_reply(s, s->pending_tag, "NO", text);
+ return;
+ }
+
+ /* If the renamed mailbox was SELECTed here, s->selected_mailbox must follow (s->state's already restored above). */
+ if (strcmp(cmdname, "RENAME") == 0 &&
+ strcmp(s->selected_mailbox, s->rename_oldname) == 0 &&
+ strlcpy(s->selected_mailbox, s->rename_newname,
+ sizeof(s->selected_mailbox)) >= sizeof(s->selected_mailbox))
+ log_warnx("session %u: selected_mailbox truncated after "
+ "RENAME -- can't happen (both same size)", s->id);
+
+ snprintf(text, sizeof(text), "%s completed", cmdname);
+ session_reply(s, s->pending_tag, "OK", text);
+}
+
+/* One LIST_ITEM entry, matched case-sensitively against s->list_pattern; streamed straight through, no buffering needed. */
+void
+session_handle_mbox_list_item(struct session *s,
+ const struct imsg_mbox_list_item *item)
+{
+ char buf[(2 * MBOX_NAME_MAX) + 32];
+ const char *kw = s->list_is_lsub ? "LSUB" : "LIST";
+
+ 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). */
+ snprintf(buf, sizeof(buf), "%s () \"/\" \"%s\"", kw, item->mailbox);
+ session_untagged(s, buf);
+}
+
+/* Terminal LIST/LSUB reply; a non-OK error means a real I/O error only -- mismatches already produce no item, silently. */
+void
+session_finish_list(struct session *s, const struct imsg_mbox_result *res)
+{
+ const char *cmdname = s->list_is_lsub ? "LSUB" : "LIST";
+ char text[32];
+
+ s->state = s->mbox_op_prev_state;
+
+ if (res->error != MBOX_OP_OK) {
+ snprintf(text, sizeof(text), "%s failed", cmdname);
+ session_reply(s, s->pending_tag, "NO", text);
+ return;
+ }
+
+ snprintf(text, sizeof(text), "%s completed", cmdname);
+ session_reply(s, s->pending_tag, "OK", text);
+}
+
+/* Appends one COPYUID UID pair, growing both arrays together (doubling from 16); copy_alloc_failed short-circuits on OOM. */
+void
+session_handle_mbox_copy_mapping(struct session *s,
+ const struct imsg_mbox_copy_mapping *m)
+{
+ if (s->copy_alloc_failed)
+ return;
+
+ if (s->copy_n == s->copy_cap) {
+ uint32_t newcap = s->copy_cap ? s->copy_cap * 2 : 16;
+ uint32_t *newsrc = reallocarray(s->copy_src_uids, newcap,
+ sizeof(uint32_t));
+ uint32_t *newdest;
+
+ if (newsrc == NULL) {
+ log_warn("session %u: realloc COPY src array", s->id);
+ s->copy_alloc_failed = 1;
+ return;
+ }
+ s->copy_src_uids = newsrc;
+
+ newdest = reallocarray(s->copy_dest_uids, newcap,
+ sizeof(uint32_t));
+ if (newdest == NULL) {
+ log_warn("session %u: realloc COPY dest array", s->id);
+ s->copy_alloc_failed = 1;
+ return;
+ }
+ s->copy_dest_uids = newdest;
+ s->copy_cap = newcap;
+ }
+
+ s->copy_src_uids[s->copy_n] = m->src_uid;
+ s->copy_dest_uids[s->copy_n] = m->dest_uid;
+ s->copy_n++;
+}
+
+/* Appends one SELECT_VANISHED range; a dropped range only delays resync (not a correctness issue), so no alloc-failure flag. */
+void
+session_handle_select_vanished(struct session *s,
+ const struct imsg_mbox_select_vanished *v)
+{
+ if (s->vanished_nranges == s->vanished_cap) {
+ uint32_t newcap = s->vanished_cap ?
+ s->vanished_cap * 2 : 16;
+ struct vanished_range *n = reallocarray(s->vanished_ranges,
+ newcap, sizeof(*n));
+
+ if (n == NULL) {
+ log_warn("session %u: realloc VANISHED range array",
+ s->id);
+ return;
+ }
+ s->vanished_ranges = n;
+ s->vanished_cap = newcap;
+ }
+
+ s->vanished_ranges[s->vanished_nranges].lo = v->uid_lo;
+ s->vanished_ranges[s->vanished_nranges].hi = v->uid_hi;
+ s->vanished_nranges++;
+}
+
+/* Appends one QRESYNC resync FETCH_META; held back for RFC 7162 SS3.2.6's VANISHED-before-FETCH ordering. */
+void
+session_handle_select_fetch(struct session *s, const struct imsg_mbox_fetch_meta *m)
+{
+ if (s->qresync_nfetches == s->qresync_fetches_cap) {
+ uint32_t newcap = s->qresync_fetches_cap ?
+ s->qresync_fetches_cap * 2 : 16;
+ struct imsg_mbox_fetch_meta *n = reallocarray(
+ s->qresync_fetches, newcap, sizeof(*n));
+
+ if (n == NULL) {
+ log_warn("session %u: realloc QRESYNC fetch array",
+ s->id);
+ return;
+ }
+ s->qresync_fetches = n;
+ s->qresync_fetches_cap = newcap;
+ }
+
+ s->qresync_fetches[s->qresync_nfetches++] = *m;
+}
+
+/* Appends one IDLE_UID (SS6.3.13); accumulated until session_handle_idle_refreshed() has the complete list to diff. */
+void
+session_handle_idle_uid(struct session *s, const struct imsg_mbox_idle_uid *item)
+{
+ if (s->idle_incoming_n == s->idle_incoming_cap) {
+ uint32_t newcap = s->idle_incoming_cap ?
+ s->idle_incoming_cap * 2 : 16;
+ uint32_t *n = reallocarray(s->idle_incoming_uids,
+ newcap, sizeof(*n));
+
+ if (n == NULL) {
+ log_warn("session %u: realloc IDLE UID array", s->id);
+ return;
+ }
+ s->idle_incoming_uids = n;
+ s->idle_incoming_cap = newcap;
+ }
+
+ s->idle_incoming_uids[s->idle_incoming_n++] = item->uid;
+}
+
+/* RFC 9051 SS7.5.1: seqnos decrement immediately, so seqno only advances on a present match (SS6.4.3's worked example). */
+void
+session_push_idle_expunges(struct session *s, const uint32_t *old,
+ uint32_t oldn, const uint32_t *cur, uint32_t curn)
+{
+ uint32_t i, j, seqno;
+ char buf[32];
+
+ seqno = 1;
+ for (i = 0; i < oldn; i++) {
+ int present = 0;
+
+ for (j = 0; j < curn; j++) {
+ if (cur[j] == old[i]) {
+ present = 1;
+ break;
+ }
+ }
+ if (present) {
+ seqno++;
+ continue;
+ }
+ snprintf(buf, sizeof(buf), "%u EXPUNGE", seqno);
+ session_untagged(s, buf);
+ }
+}
+
+/* Terminal IDLE_REFRESH reply; push gated on s->idling && SELECTED -- client may've sent DONE/a new SELECT meanwhile. */
+void
+session_handle_idle_refreshed(struct session *s,
+ const struct imsg_mbox_idle_refreshed *res)
+{
+ uint32_t *newlist = s->idle_incoming_uids;
+ uint32_t newn = s->idle_incoming_n;
+
+ s->idle_incoming_uids = NULL;
+ s->idle_incoming_n = 0;
+ s->idle_incoming_cap = 0;
+
+ s->idle_refresh_pending = 0;
+
+ if (!res->ok) {
+ log_warnx("session %u: IDLE refresh failed, keeping last "
+ "known state", s->id);
+ free(newlist);
+ goto maybe_again;
+ }
+
+ if (!s->idle_baseline_valid) {
+ free(s->idle_known_uids);
+ s->idle_known_uids = newlist;
+ s->idle_known_nuids = newn;
+ s->idle_known_cap = newn;
+ s->idle_baseline_valid = 1;
+ goto maybe_again;
+ }
+
+ if (s->idling && s->state == SESSION_SELECTED) {
+ session_push_idle_expunges(s, s->idle_known_uids,
+ s->idle_known_nuids, newlist, newn);
+ if (newn != s->idle_known_nuids) {
+ char buf[32];
+
+ snprintf(buf, sizeof(buf), "%u EXISTS", newn);
+ session_untagged(s, buf);
+ }
+ }
+
+ free(s->idle_known_uids);
+ s->idle_known_uids = newlist;
+ s->idle_known_nuids = newn;
+ s->idle_known_cap = newn;
+
+maybe_again:
+ if (s->idle_refresh_again) {
+ s->idle_refresh_again = 0;
+ session_request_idle_refresh(s);
+ }
+}
+
+/* Sends IMSG_MBOX_IDLE_REFRESH; coalesces via s->idle_refresh_again instead of overlapping requests if one's in flight. */
+void
+session_request_idle_refresh(struct session *s)
+{
+ if (s->idle_refresh_pending) {
+ s->idle_refresh_again = 1;
+ return;
+ }
+ if (s->store_iev == NULL)
+ return; /* no store child wired -- nothing to ask */
+
+ s->idle_refresh_pending = 1;
+ if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_IDLE_REFRESH, 0, 0,
+ -1, NULL, 0) == -1)
+ log_warn("session %u: imsg_compose IMSG_MBOX_IDLE_REFRESH",
+ s->id);
+ imsgev_add(s->store_iev);
+}
+
+/* Notifies same-uid idling sessions after a UID-changing op (not plain STORE); no mailbox filter -- harmless over-notify. */
+void
+session_notify_idle_peers(const struct session *s)
+{
+ struct session *other;
+
+ TAILQ_FOREACH(other, &sessions, entry) {
+ if (other == s)
+ continue;
+ if (other->uid != s->uid)
+ continue;
+ if (!other->idling || other->state != SESSION_SELECTED)
+ continue;
+ session_request_idle_refresh(other);
+ }
+}
+
+/* QRESYNC resync FETCH: always UID+FLAGS+MODSEQ (RFC 7162 SS3.2.5.1); no s->fetch_attrs, the RFC fixes the content. */
+void
+session_send_qresync_fetch_response(struct session *s,
+ const struct imsg_mbox_fetch_meta *meta)
+{
+ char buf[MBOX_FLAGS_MAX + 96];
+
+ snprintf(buf, sizeof(buf), "%u FETCH (UID %u FLAGS (%s) MODSEQ (%llu))",
+ meta->seqno, meta->uid, meta->flags,
+ (unsigned long long)meta->modseq);
+ session_untagged(s, buf);
+}
+
+/* Terminal reply hub; SEARCH/COPY/MOVE/CREATE/DELETE/RENAME/LIST peel off first; FETCH/STORE/EXPUNGE share the rest. */
+void
+session_handle_mbox_result(struct session *s, struct imsg_mbox_result *res)
+{
+ int was_close = 0, was_storing = 0, was_expunging = 0;
+ const char *cmdname;
+
+ if (s->state == SESSION_SEARCHING) {
+ session_finish_search(s, res);
+ return;
+ }
+ if (s->state == SESSION_COPYING) {
+ session_finish_copy_or_move(s, res);
+ return;
+ }
+ if (s->state == SESSION_CREATING || s->state == SESSION_DELETING ||
+ s->state == SESSION_RENAMING) {
+ /* RFC 9051 SS6.3.4-SS6.3.6: same peel-off pattern as SEARCH/COPY -- reply text doesn't fit this function's cmdname table. */
+ session_finish_mbox_op(s, res);
+ return;
+ }
+ if (s->state == SESSION_LISTING) {
+ /* RFC 9051 SS6.3.9: same peel-off reasoning; LIST/LSUB text is keyed off s->list_is_lsub instead. */
+ session_finish_list(s, res);
+ return;
+ }
+
+ /* RFC 9051 SS6.4.9: becomes "UID <CMD>" when s->cmd_by_uid is set -- CLOSE is the exception (no "UID CLOSE"). */
+ if (s->state == SESSION_STORING) {
+ cmdname = s->cmd_by_uid ? "UID STORE" : "STORE";
+ was_storing = 1;
+ } else if (s->state == SESSION_EXPUNGING) {
+ was_close = s->close_after_expunge;
+ cmdname = was_close ? "CLOSE" :
+ (s->cmd_by_uid ? "UID EXPUNGE" : "EXPUNGE");
+ was_expunging = 1;
+ } else
+ cmdname = s->cmd_by_uid ? "UID FETCH" : "FETCH";
+
+ log_debug("session %u: %s done, error=%d, %u response(s) sent",
+ s->id, cmdname, res->error, res->count);
+
+ /* RFC 7162: cache post-op HIGHESTMODSEQ for session_condstore_enable(); only STORE/EXPUNGE change it, not FETCH. */
+ if (was_storing || was_expunging)
+ s->mbox_highestmodseq = res->highestmodseq;
+
+ /* RFC 9051 SS6.4.1: CLOSE returns to authenticated state, unlike FETCH/STORE/a real EXPUNGE, which stay Selected. */
+ s->state = was_close ? SESSION_AUTHENTICATED : SESSION_SELECTED;
+
+ if (res->error != MBOX_OP_OK) {
+ /* v1's only failure mode is an index I/O error; RFC 9051 has no specific code for it, so a plain NO is honest. */
+ char text[32];
+
+ free(s->store_modified);
+ s->store_modified = NULL;
+ s->store_modified_n = 0;
+ s->store_modified_cap = 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; v1 never needs the tagged-NO form. */
+ if (was_storing && s->store_modified_n > 0) {
+ char rbuf[2048];
+ char text[2048 + 32];
+ int truncated;
+
+ format_seq_list(rbuf, sizeof(rbuf), s->store_modified,
+ s->store_modified_n, &truncated);
+ if (truncated)
+ log_warnx("session %u: MODIFIED list truncated",
+ s->id);
+ /*
+ * text's ~32-byte margin over rbuf's 2048 is enough for the
+ * wrapper words but not always for wrapper + a near-max
+ * rbuf + "UID STORE" together (worst case ~2106 bytes) --
+ * session_reply() itself can no longer mangle the line if
+ * this truncates (it preserves the trailing CRLF), but the
+ * MODIFIED list could still lose its tail silently. Check
+ * and log it, same as rbuf's own truncation just above.
+ */
+ if (snprintf(text, sizeof(text), "[MODIFIED %s] Conditional "
+ "%s failed for some messages", rbuf, cmdname) >=
+ (int)sizeof(text))
+ log_warnx("session %u: MODIFIED response text "
+ "truncated", s->id);
+ session_reply(s, s->pending_tag, "OK", text);
+
+ free(s->store_modified);
+ s->store_modified = NULL;
+ s->store_modified_n = 0;
+ s->store_modified_cap = 0;
+ return;
+ }
+ free(s->store_modified);
+ s->store_modified = NULL;
+ s->store_modified_n = 0;
+ s->store_modified_cap = 0;
+
+ /* RFC 9051 SS6.3.13: gated on was_expunging not res->count (CLOSE's count is always 0) -- harmless extra refresh. */
+ if (was_expunging)
+ session_notify_idle_peers(s);
+
+ /* RFC 7162 SS3.2.7: real EXPUNGE (not CLOSE -- SS3.2.8 forbids it) with count>0 gets HIGHESTMODSEQ, once CONDSTORE-aware. */
+ if (was_expunging && !was_close && s->condstore_enabled &&
+ res->count > 0) {
+ char text[64];
+
+ snprintf(text, sizeof(text), "[HIGHESTMODSEQ %llu] %s "
+ "completed", (unsigned long long)res->highestmodseq,
+ cmdname);
+ session_reply(s, s->pending_tag, "OK", text);
+ return;
+ }
+
+ {
+ char text[32];
+
+ snprintf(text, sizeof(text), "%s completed", cmdname);
+ session_reply(s, s->pending_tag, "OK", text);
+ }
+}
+