commit 7d9334a7d40e0d8a8bbba80f1e1026a34ec35911 from: David Williams date: Sat Sep 26 04:08:40 2026 UTC re-architecture: parser process, account worker, admission control Twenty-five changes, made and checked on the test machine one at a time, committed together. Each follows in the order it was made, under its own subject line, with its own account of how it was checked. Together: COPY and MOVE link messages instead of reading them into memory; SEARCH parsing moves out of its own process and message parsing into a parser-worker; each logged-in account gets one store child shared by its sessions, with a parser only while one is needed; the auth-worker exits after its grant; three admission limits bound what one account, one address and the whole daemon may hold; TCP keepalive ends the sessions of clients that vanished; and SEARCH answers SUBJECT, HEADER, SENTBEFORE, SENTON, SENTSINCE, FROM, TO, CC and BCC in the parser-worker. Change 1 of 25: COPY and MOVE link messages instead of reading them into memory COPY and cross-mailbox MOVE used to read every message in the range whole into the store child's memory before writing anything, bounded at 64 MiB per message and 512 MiB per command. With 64 store children that is a large amount of memory reachable by ordinary clients, and the per-message bound refused to copy messages the server had accepted: "append max" goes up to 1 GiB, and mail from an MTA is not bound by it. A maildir message is never rewritten once delivered; its flags live in its name. So the copy is now the same file under a new name: pass 1 records each source path and its new name and reads nothing, and pass 2 linkat(2)s each one straight into the destination's cur/. If the link fails for any reason other than EEXIST (another filesystem, the link count at LINK_MAX, a file flag), the message is streamed through a 64 KiB buffer into tmp/, fsynced and renamed, as before but without holding it whole. EEXIST fails the COPY instead of falling back, because the fallback's rename(2) would replace the file already there. AT_SYMLINK_FOLLOW keeps the old behaviour for a symlinked message, whose target was what openat(2) used to read. pledge(2) and unveil(2) already allow this: SYS_linkat is PLEDGE_CPATH, which the store holds, and its "rwc" unveil gives the read and create permissions dolinkat() checks. The existing rollback is unchanged and covers both paths. Cross-mailbox MOVE still commits the destination before unlinking the source, so it now moves no data. COPY_STAGE_MSG_MAX and COPY_STAGE_TOTAL_MAX are gone, as is the banner comment that said commit_copy_messages() had no rollback. It has one, and a reviewer had believed the comment over the code. stage_copy_messages() lost a directory descriptor parameter that disagreed with the global it also used. A new wire test APPENDs a message one octet over the old cap and checks COPY to another mailbox, COPY into the selected one, and MOVE, each by size and by octets at the start, middle and end. Against the old code it failed all three for the right reason, the cap, as maillog showed; after, 24 of 24. It needs "append max" and "attachment max" raised, so it is run by hand. The copy test's optional low-cap section is removed with the cap. Not exercised: the fallback copy. Nothing a client can do makes a link fail. Change 2 of 25: remove the per-connection SEARCH-parsing process Every connection forked a third process whose only job was to parse the SEARCH grammar and send the parsed program back over imsg. The parse now happens in the listener-worker, which already parses every other command in that same process, holds no private key, no password hashes and no mail, runs as _imapd in /var/empty and is pledged "stdio recvfd". A grammar bug there reaches one session, because every connection has its own listener-worker. The isolation that remains is the one that faces a stranger's data: message content is parsed by the store child, and moving that parse into a process without write access to mail is separate work. A connection before login now costs two processes rather than three. On a 2013 Celeron J1900 with 8 GB, each process costs about 1.07 MiB of machine memory whatever it does, measured by free page count across 0, 10, 20 and 40 connections. Gone with it: PROC_SEARCH, IMSG_SETUP_SEARCH_PEER, IMSG_SEARCH_PARSE_REQUEST and IMSG_SEARCH_PARSE_RESULT, SESSION_SEARCH_PARSING, iev_search, listener_dispatch_search(), the parent's per-connection fork, its reaping and its search_pid bookkeeping, and the _imapsearch account the role dropped to. An installation that created that account can remove it. search_dispatch() now parses and calls search_dispatch_finish() directly; both, and the parser, are static to search_cmd.c. The parser is search_program_parse(), named for what it does rather than for the process that used to call it. struct imsg_search_parse_result is struct search_parse_result, and the two constants lose their ORACLE infix, since neither crosses an imsg any more. Clients see no change: the same BAD and NO replies from the same parser, and the same 8192-octet bound on criteria, which is smaller than the command line it is sliced from. The two "[UNAVAILABLE] search temporarily unavailable" replies are gone; both existed only for a missing or dead oracle channel. The build and the test suite are clean on OpenBSD 8.0-current, and the lock-timeout test of SEARCH passes 5 of 5 with "lock timeout 5". Change 3 of 25: accept a MIME body part that declares no headers A multipart body part may carry no header fields at all. RFC 2046 section 5.1.1 says the boundary delimiter is terminated "by either another CRLF and the header fields for the next part, or by two CRLFs, in which case there are no header fields for the next part", and that a part with no Content-Type field is text/plain. find_header_body_split() looked only for two consecutive line breaks, which such a part does not contain: it begins with the second CRLF and then the body. The function returned -1 and both callers read that as malformed. build_body_structure() failed the whole structure, so FETCH BODYSTRUCTURE answered "* n FETCH ()" with a tagged NO; find_mime_part() found no part, so FETCH BODY[n] answered an empty string with a tagged OK. Such a message arrives through APPEND, so producing one needs no access to the server. The function now treats a buffer that begins with a line break as having an empty header section, before the scan for a blank line. A part that does declare headers is unaffected: the scan it relies on is unchanged and still runs. Checked on the test machine against a twelve-message corpus covering nested multiparts, a message/rfc822 part, a base64 attachment, body text that nearly matches the boundary, an empty body, a folded header and quoted Content-Type parameters. Of every FETCH result that corpus records, exactly two moved. The part with no headers now reports ("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 26 0) inside its MIXED parent, and BODY[1] returns its 26 octets. Every other line was byte-identical, including the two BODYSTRUCTURE refusals that are deliberate: message/rfc822 is still scoped out, and a part specifier naming a part that is itself multipart still returns nothing. Change 4 of 25: add a parser-worker process beside each store child The store child parses hostile message content in the same process that holds the account's maildir. A bug in the MIME or RFC 5322 parsing of a message reaches the mail it is parsing. This adds the process that separates the two, and nothing else: it answers no requests yet, so the tree's behaviour is unchanged. parser.c is the new role. It drains its init message and its peer descriptor on fd 3, refuses to run as uid or gid 0, chroots to /var/empty, drops to the account's own uid, and pledges "stdio recvfd". No "rpath", so openat(2) is denied (kern_pledge.c) and the confinement is a property of the process rather than of the file's care. It opens nothing: every byte it will parse arrives on a descriptor its peer passes with the request. The parent forks one beside each store child, because the store child cannot fork its own: its pledge has no "proc exec". The parser is told the account's uid and given that store child's channel, and nothing else; it is told no paths because it opens no files. Its lifetime needs no bookkeeping. The store channel is its only one, so it reads EOF and exits whenever that child does, however the child dies. The one window between the fork and the pairing, where it would have no store to outlive, is closed by hand. A store child now has two peers, so setup_recv_one_peer() grew a variant that reports the imsg id. The listener's IMSG_SETUP_PEER carries the session number and the parser's carries 0, which is safe as a discriminator because next_session_id starts at 1 and wraps to 1, so no session ever carries it. The store sorts its peers by that id rather than by arrival order, and refuses a duplicate or a missing one. Checked on the test machine. The build and the test suite are clean, and the twelve-message FETCH corpus is byte-identical, as it must be when no request type exists. Over six live sessions, ps shows one parser per store child, each at its own store's uid and none at root. Their state flags read Ipc against the store's IpUc: pledged and chrooted, with no unveil at all, which is the intended shape showing up in the kernel's own accounting. The store carries a locked unveil because it opens the maildir; the parser has none to lock because it opens nothing. Change 5 of 25: build ENVELOPE in the parser-worker, and check what comes back This moves the first attribute behind the parser-worker added in the previous change. build_envelope() now runs there, on a descriptor the store opens and passes, so the RFC 5322 header parsing and address-list decomposition of a hostile message no longer runs in the process that holds the maildir. read_message_header() keeps its signature and becomes an open, a call to a new descriptor-taking core, and a close. The core is what the parser can run: it has no "rpath" and cannot open a message itself. build_envelope() takes the message descriptor in place of the mailbox directory descriptor, which is the same argument shape, so no declaration changed. The request and its reply share one imsg type, as the keymgr forwarders in listener.c do, with a per-request serial on the imsg id field and the reply checked on both type and id so a stale one is discarded rather than answered. The store's side of the channel is a bare imsgbuf rather than an imsgev: every exchange is synchronous, so a libevent dispatcher would only race the reply the child is already blocked waiting for. The wait is bounded, which the comparable wait on keymgr is not. The peer here is the one process in this design assumed to be attackable, so a parser looping on crafted MIME would otherwise pin its store child for the session's life, and store children are a fixed, machine-wide resource. relayd bounds the same shape of wait at one second (RELAY_TLS_PRIV_TIMEOUT, usr.sbin/relayd/relayd.h); this is longer because a parse is not one private-key operation. sshd's privsep client blocks on its monitor with no bound at all (usr.bin/ssh/monitor_wrap.c), but there the process waited on is the privileged one. A parser that misses the deadline once is not asked again, since retrying would let a stuck one charge the full timeout per message. What comes back is checked before it is used. ENVELOPE text is spliced into an untagged FETCH response as raw bytes, which was safe while the store built it and was trusting its own builder. It is not safe when another process supplies it: a CR or LF inside the reply ends the untagged line early and what follows is read as a new server response. RFC 9051 section 4.3 excludes NUL, CR and LF from a quoted string, and parser_reply_safe() refuses a reply carrying any of them, or one that does not close its own parenthesised list and would swallow what follows it on the line. A confined process whose output is trusted is not confined. A refused or missing reply is reported the way an unreadable message already is, so no wire shape and no listener code changed. A fuzzing harness drives the check, built two ways on purpose: without the check, as the tree stood before it existed, it must report violations. It found 243988 of them over 308466 cases, and none with the check in place, with 8531 replies still accepted as safe so the check is not passing by refusing everything. Writing its cases found a fault in the check before any of it shipped: the first draft walked the text once and skipped a backslash-escaped byte without examining it, so a quoted backslash followed by CR would have passed. The scan for line breaks is now a separate unconditional pass over every byte. Checked on the test machine. The build and the test suite are clean and the twelve-message FETCH corpus is byte-identical, which is the result that matters here: every one of those envelopes is now built in another process, on a descriptor rather than a directory, marshalled back and validated, and not a byte moved. Change 6 of 25: strip NUL, CR and LF from an address display name envbuf_append_nstring() has always substituted a space for NUL, CR and LF, citing RFC 9051 section 4.3, which excludes them from a quoted string, and substituting rather than rejecting so one bad byte does not drop the field. envbuf_append_one_address() open-codes a second quoted string emitter for the display name, because it also has to unescape the source's backslash escapes, and that copy was missing the substitution. Two emitters in one file, one of which sanitised. The mailbox and host parts go through the sanitising one and were never affected; only the display name was. So a From header reading From: "quotedname" produced an ENVELOPE carrying that CR inside a quoted string, which went into an untagged FETCH response as raw bytes. Such a message needs no access to the server: APPEND literal octets reach the store unfiltered, so a client can store one itself. Only a bare CR reaches this far. A full CRLF does not: hdr_next_field() ends the field there, and a folding continuation does not carry it through. So the effect is a corrupted response line rather than a forged one, and since the previous change it is neither, because parser_reply_safe() refuses the text. That is the wrong outcome too: the message loses its ENVELOPE rather than its CR. The substitution now runs after the unescape, so a quoted backslash followed by CR is caught as well. vis(3) is how base makes untrusted text safe to emit, and mda.c wraps it in quotes exactly as a quoted string is wrapped (usr.sbin/smtpd/mda.c). It does not fit here. VIS_SAFE deliberately treats CR as visible and would have left this alone (lib/libc/gen/vis.c), and without VIS_SAFE every byte above 0x7f is escaped, which would mangle the UTF-8 that RFC 9051 admits in QUOTED-CHAR. The exclusion set is not a judgement call: TEXT-CHAR is any character except CR and LF, so those two and NUL are the whole of it and every other octet is preserved. A fuzzing harness over the ENVELOPE builder found this. Its property is not crash-freedom, which the sanitisers already cover, but that every envelope the builder can produce is one parser_reply_safe() will send. A gap between those two sets is not an attack, it is a message quietly losing its ENVELOPE. It reported three violations over 402003 cases, all this one cause, and none after the fix. This is the path the parser-worker exists to contain, and it had no harness at all before this one, which is where the headerless body part bug lived until a characterisation run found it. Checked on the test machine: the build and the test suite are clean and the twelve-message FETCH corpus is byte-identical, which is what it should be, since none of those messages carries a bare CR and the substitution must not fire on ordinary mail. Change 7 of 25: build BODYSTRUCTURE in the parser-worker too This moves the attribute the split was built for. build_body_structure() is the recursive RFC 2045/2046 walk bounded by MIME_MAX_DEPTH and MIME_MAX_PARTS, and it is where hostile MIME does its real work. It now runs in the parser, on a descriptor the store opens and passes. What MIME parsing the store still does, for BODY[], moves in the next change, with HEADER.FIELDS. read_message_body() splits the way read_message_header() did in the previous change: it keeps its signature and becomes an open, a call to a new descriptor-taking core, and a close. BODY.PEEK[] still uses the whole-message read from the store and is unaffected; it moves in its own change. build_bodystructure() takes the message descriptor in place of the mailbox directory descriptor. The parser reads whole messages now, so it needs the same ceiling the store has: bodystructure_read_max rides on IMSG_PARSER_INIT, as it already rides on IMSG_STORE_INIT. The buffer is still sized to the message rather than to the ceiling, so the cap costs nothing on ordinary mail, and the parser frees it before answering, holding no state between requests. The channel is now written once rather than per attribute. The request and reply structs lose their attribute-specific names, and parser_envelope() becomes parser_request(type, label, dfd, basename, maxlen, ...) called directly from each of the two sites, with no wrapper in between. That is sshd's shape: mm_request_send() and mm_request_receive_expect() take the message type as an argument and sixteen typed callers use them directly, with no intermediate layer (usr.bin/ssh/monitor_wrap.c). The label names the attribute for the log, as read_message_body()'s already does. The alternative was a second copy of the serial, the poll(2) deadline, the id check and the reply validation; this tree already has one duplicated request loop, keymgr_forward_rsa() and keymgr_forward_ecdsa() in listener.c, and the timeout missing from it is missing from both copies. BODYSTRUCTURE needs no new check on the way back. It is spliced into the untagged FETCH response as raw bytes two lines below ENVELOPE, so parser_reply_safe() already covered it. The ENVELOPE harness grew to drive the recursive builder before this change was made, so that a fault would surface on the old code rather than after the move: build_body_structure, parse_content_type, split_multipart, mime_read_token_or_qstring, mime_str_upper and mime_is_tspecial. It found nothing. Unlike the address path, every quoted field here goes through envbuf_append_nstring(), which has always substituted a space for NUL, CR and LF, so the fault fixed in the previous change never existed here. That the result is not vacuous was checked by hand: a bare CR in a Content-ID, a Content-Description or a Content-Transfer-Encoding does reach the builder and does come back substituted. Checked on the test machine. The build and the test suite are clean and the twelve-message FETCH corpus is byte-identical, which is the whole claim: build_envelope() and build_bodystructure() now have exactly one caller each, both in the parser, so every envelope and every body structure in that corpus was produced in another process, read from a descriptor, marshalled back and validated, and not a byte moved. The two deliberate refusals are intact: message/rfc822 is still scoped out, and a part specifier naming a multipart part still returns nothing. Over seven live sessions ps shows one parser per store child at its own store's uid, none at root, each pledged and chrooted with no unveil, and their resident sizes unchanged from before they read whole messages. Change 8 of 25: move BODY[] and HEADER.FIELDS into the parser-worker These were the last two FETCH items the store still parsed. A section-part ran the whole MIME walk, parse_content_type() and split_multipart() included, and HEADER.FIELDS split RFC 5322 fields and their folds, both in the process that holds the maildir. Both now run in the parser on a descriptor the store passes. The store still looks for the blank line that ends a header, for BODY[HEADER], BODY[TEXT] and BODY[], as smtpd's queue does when it bounces a message with headers only (usr.sbin/smtpd/bounce.c), and does nothing else with message content. Neither item is spliced into the response line the way ENVELOPE is. Both go out as literals, whose length is framed, so a CR or LF from a hostile parser is only an octet. What such a parser could still do is put a NUL on the wire, which RFC 9051 section 9 allows only in literal8, or name a range the listener cannot read, which it answers by shutting the connection because the literal's length has already gone out. So each reply has its own check in the store, chosen by type in parser_request(): parser_literal_safe() header octets: no NUL, within FETCH_HEADER_MAX parser_extent_safe() a part's offset and length inside the file, with no overflow extent_has_nul() no NUL in that range, read by the store The part's octets never cross the channel. The parser returns where the part lies, and the store checks that against fstat(2) on its own descriptor, never against a size the parser reported. It is the same descriptor the listener then reads: the caller opens the message once and parser_request() sends a dup(2) of it, so the extent is checked against the file the client is actually sent. ENVELOPE and BODYSTRUCTURE open at the call site too, so all four callers use one shape. read_message_header_fields() splits into filter_header_fields(), which works on a header already in memory, and a descriptor-taking caller. extract_mime_part() takes the message descriptor in place of the mailbox directory descriptor. read_message_body() lost its last caller and is removed. A section-part that names a multipart or message/rfc822 part still comes back empty, exactly as a missing part does. That is a deliberate divergence, and this change keeps it. The harnesses and tests landed before the change: - The parser baseline fetches HEADER.FIELDS, HEADER.FIELDS.NOT, a partial part and a missing part. Before this, its reference had never seen HEADER.FIELDS at all. - The ENVELOPE harness drives find_mime_part() and locate_mime_part(). It pins which parts are found, including the divergence above, and checks that every part found lies inside its body. Both directed-case checks were mutation-tested: flipping one expectation makes the harness fail. After the split it also drives filter_header_fields(). A selection and its .NOT must together make up the whole header plus one blank line, the filter must be idempotent, and any header the store would have read must pass parser_literal_safe(). - The reply-check harness has directed and swept cases for the three new checks. Built without them it reports NULs in literals, an oversized literal and short reads; built with them it reports none, with 20288 header literals and 473 part extents still accepted. - The header-walk test's generator had gone stale: its stub no longer matched read_message_header_fields()'s signature, so it could not build against the tree. It is repaired and now splices filter_header_fields(). Its 24 rows are byte-identical to the committed test's, both before and after the split. Checked on the test machine. The build is clean with no compiler warnings, and the test suite passes, 16 scripts of 16. Before this change was installed, the extended FETCH corpus was recorded against the old build: its earlier items were byte-identical to the previous reference, and all 48 new ones answered OK. After installing, the corpus is byte-identical to that record, all 144 fetches. read_message_header_fields() and extract_mime_part() now have exactly one caller each, both in the parser, so every header selection and every part in that corpus was found in another process and checked by the store, and not a byte moved. The two deliberate refusals are intact: message/rfc822 BODYSTRUCTURE is still scoped out, and a part specifier naming a multipart part still comes back empty. ps shows the parser beside its store child at the store's uid, pledged and chrooted with no unveil, at the same resident size as before, and maillog has no refused reply and no missed deadline. Change 9 of 25: gather a session's FETCH walk, IDLE and APPEND state into one structure The store keeps everything about its one session in file scope. That is right while a store child serves exactly one session, and it is what stands between this tree and a process that serves several sessions of one account. This is the first step of undoing it, and it changes no behaviour. struct store_session (store_internal.h) now holds the listener's channel and four pieces of per-session state that were statics until now: the paced FETCH walk and its batch counters mbox_fetch.c the IDLE change probe index.c the IDLE baseline, the UIDs last reported index.c the one APPEND in flight mbox_manage.c store.c keeps the one instance and registers it as the listener channel's callback argument, so store_dispatch() receives it. It reaches the handlers that use this state as an explicit argument: handle_mbox_select(), handle_mbox_fetch(), handle_mbox_idle_refresh() and the three APPEND handlers, together with the fetch_walk_*(), idle_*_reset() and append_abort() helpers. The lock-wait machinery records the session rather than the channel, so a deferred command is replayed against the same state it was deferred with. No handler reaches the structure by name, so nothing in it can be read on behalf of the wrong session once a process holds more than one. Still in file scope, and moved by the next change: the selected mailbox and its descriptor, the cur/ snapshot, and the lock-wait record itself. session_id stays a process global; it labels log lines. A wire test was added first. It fetches forty bodies in one command, which makes the walk pause twice at FETCH_FD_MAX and resume from the store's EV_WRITE handler. Nothing in the suite had fetched more than sixteen bodies in one command, so the pause and the resume had never run under it. It is now in the suite. Checked on the test machine. The new test passed against the build before this change, so it pins existing behaviour, and passes against this one. The build is clean with no compiler warnings, and the test suite passes, 17 scripts of 17. The FETCH corpus is byte-identical to the reference, all 144 fetches. With a five-second lock timeout, a FETCH, SELECT and APPEND held behind another session's STORE each wait, are refused NO [INUSE] within the bound, and leave the session usable, and an IDLE whose seed waited reports the holder's change once the lock is free. With seven sessions over three accounts logged in, ps shows one parser beside each store child at the store's uid, and maillog has no refused reply and no missed deadline. Change 10 of 25: drop the cur/ listing of a command that finished after a lock wait A command reads cur/ once and keeps the listing for as long as it runs (cur_snap, mime.c), and store_dispatch() drops it after every request. A command that could not take the index lock is not finished there: it is re-run from a timer by deferred_retry(), and nothing dropped the listing that run built. A STORE renames the files whose flags it changes, so after a STORE that had waited, the listing named files that were gone. The session's next command then found the old name, failed to stat it, and skipped the message as "indexed but missing on disk". A FETCH left the message out of its answer. A second STORE on it answered OK and changed nothing. A name absent from the listing falls through to a real scan of cur/; a stale name present in it did not. deferred_retry() now drops the listing when the command it re-ran is done, under the same rule as store_dispatch(): not while a FETCH is paused mid-walk, since that command has not finished. A wire test was added first. It makes a second session's STORE wait behind a STORE 1:* over a large mailbox, then checks that the next FETCH answers for the message with its new flags, and that a following STORE takes effect. Like the lock-timeout test it needs a large mailbox, so it is run by hand. Checked on the test machine. Against the build before this change the new test failed 4 of its 12 checks: in both rounds the FETCH after the waited command gave no response for the message, and maillog logged it as missing on disk. In the second round maillog shows only the FETCH skipping it, not the STORE between (a STORE plans in two passes and would log twice), which fits that STORE having waited too and left a stale listing of its own. With this change the build is clean with no compiler warnings, the new test passes 12 of 12, and the suite passes, 17 scripts of 17. Change 11 of 25: move the selection, the cur/ listing and the lock wait into the session The second step of gathering a store child's per-session state into struct store_session, after the FETCH walk, IDLE and APPEND. It changes no behaviour. What moved, from file scope into the session: mailbox_selected, the store's own gate store.c selected_mailbox, the selection's name store.c mailbox_dir_fd, the selection's directory store.c cur_snap, one command's listing of cur/ mime.c deferred, the command waiting for a lock store.c The handlers that reach any of it now take the session in place of the listener's channel: STORE, EXPUNGE, SEARCH, STATUS, COPY, MOVE, DELETE and RENAME, with the helpers under them. SELECT, FETCH and the IDLE refresh already did. CREATE, LIST, SUBSCRIBE and UNSUBSCRIBE touch none of it and keep the channel. The cur/ listing functions in mime.c take the listing they work on, struct cur_snapshot, rather than a static, so locate_message_file(), open_message_file(), read_message_header() and message_body_range() gain it as their first argument. The parser links mime.c and calls none of them. The lock-wait record keeps its one slot, now per session. Its retry timer carries the session as the callback argument, as the listener channel already does, so deferred_retry() and the functions around it take the session instead of reaching a static. What is left in file scope is per process: session_id, which labels log lines, the maildir root, the configured limits, append_counter and the parser channel. The delete-selected wire test was extended first. After a RENAME of the selected mailbox, a COPY and a MOVE to the new name must take the same-mailbox path. They choose it by comparing the destination with the selection's name, which is one of the things that moved. Checked on the test machine. The build is clean with no compiler warnings, and the test suite passes, 17 scripts of 17, the extended delete-selected test 18 of 18. The FETCH corpus is byte-identical to the reference, all 144 fetches. With a five-second lock timeout, each of the eleven commands that can wait for the lock (STORE, EXPUNGE, FETCH, SEARCH, CLOSE, SELECT, STATUS, COPY, MOVE, APPEND and IDLE) waits behind another session's STORE and is bounded, refused NO [INUSE] and followed by a working session, or for IDLE reports the holder's change. The lock-wait snapshot test passes 12 of 12. With seven sessions over three accounts logged in, ps shows one parser beside each store child at the store's uid. maillog has no refused reply and no missed deadline, and nothing has been logged as missing on disk since the lock-wait fix went in. Change 12 of 25: attach a store child's session at runtime; the parent says when it exits A store child used to receive its one listener channel during setup, serve it, and exit when the session ended. It now takes its sessions at runtime over its parent channel, as keymgr takes listener peers, and exits only when the parent tells it to. The parent still attaches exactly one session to each child, so nothing a client can see changes; every login now goes through the attach path, and every logout through the detach path, before any child carries a second session. In the store child (store.c): setup brings only the parser peer, then IMSG_SETUP_DONE store_parent_dispatch() keeps fd 3 on an event and takes IMSG_SETUP_PEER (attach a session, its number in the id) and IMSG_STORE_EXIT (detach everything and exit) store_attach() allocates the session and opens its directory; a duplicate id, or id 0, is refused store_detach(), on IMSG_STORE_SHUTDOWN or the listener's channel closing, frees one session and leaves the child running the sessions are a list, not a static pledge gains recvfd, the set smtpd's queue pledges The handshake can read the first attach along with IMSG_SETUP_DONE, so the dispatcher is run once by hand before the event loop starts; without that the session would sit in the buffer until the parent next wrote. If the parent goes away, the child serves the sessions it has and exits with the last of them, which is what it did before. In the parent (parent.c), the listener's channel is sent after IMSG_SETUP_DONE rather than before the parser's, and reap_child() sends IMSG_STORE_EXIT to a session's store child when it reaps that session's listener. IMSG_STORE_EXIT is new in imapd.h. A wire test was added first. It opens and ends 80 sessions one after another, half with LOGOUT and half by dropping the connection, and checks that every one logs in. A store child that outlived its session would count against STORE_CHILD_MAX (64) until logins were refused. It is in the suite, now eighteen scripts. Also, a comment in store_dispatch() that pointed at mime.c for the cur/ listing now points at struct cur_snapshot, where that description moved. Checked on the test machine. The new test passed against the build before this change, and passes against this one. The build is clean with no compiler warnings, the suite passes, 18 scripts of 18, and the FETCH corpus is byte-identical to the reference, all 144 fetches. The lock-wait snapshot test passes 12 of 12. ps lists the same processes before and after the store-exit test's 81 sessions, and with a mail client logged in to three accounts it shows one parser beside each store child at the store's uid. maillog has no refused reply, no missed deadline and no refused session. Change 13 of 25: one store child per account: later logins join it The parent now keeps one store child per account, keyed by uid, gid and maildir, and attaches each later login of that account to it over the parent channel, the way the first one is attached. The child serves the account's sessions side by side, each with its own selection, FETCH walk, IDLE state and lock wait, as the last three changes arranged. Twelve connections of one account used to cost twelve store children and twelve parsers; they now cost one of each. In the parent (parent.c): struct store_child records the account it serves, how many of its sessions' listeners are alive, and whether it has been told to exit; open_session records its store child parent_handle_store_fork() attaches to a live child of the same account, or forks one if there is none store_child_session_gone() sends IMSG_STORE_EXIT when the last listener goes, and a child told to exit takes no new login store_child_teardown() fails every session attached to a child that dies during setup, not only the first STORE_CHILD_MAX now counts accounts In the store child (store.c), a read, write or framing failure on one session's channel detaches that session rather than calling fatal(), since the account's other sessions are served by the same process. session_id, which labels log lines, is set to the session being served on every entry from the event loop. The store child's title is now "store uid N" and the parser's "parser uid N", since neither serves one session any more. One behaviour does change. A long command in one session, such as a STORE over a large mailbox, now delays every other session of the same account until it finishes, because one process serves them all. Before, only sessions that needed the same mailbox's lock waited, and a wait was bounded by "lock timeout" and answered NO [INUSE]. Between sessions of one account the lock wait no longer happens at all; it still guards against a second process. The parser is still one per store child, so it is now shared by the account's sessions, and a parser that misses its deadline or dies stops message parsing for the whole account until its sessions end. Starting it on demand and bounding its CPU is the next change. A wire test was added first. Two sessions of one account each see the other's APPEND and STORE, including while idling; one pages a 40-message FETCH through its pause while the other runs a command; one EXAMINEs INBOX without moving the other; and each goes on working after the other logs out or drops its connection. It is in the suite, now nineteen scripts. Checked on the test machine. The new test passed 14 of 14 against the build before this change and passes 14 of 14 against this one. The build is clean with no compiler warnings, the suite passes, 19 scripts of 19, and the FETCH corpus is byte-identical to the reference, all 144 fetches. With a five-second lock timeout, the lock-timeout test now fails its "bounded" and "NO [INUSE]" checks, as intended: the waiter is served after the holder's 47-second STORE, with OK, because both sessions are in one process. It passed before. ps shows two sessions of one account behind a single store child and a single parser, and six Mail.app sessions over three accounts behind three of each, where there were six. maillog has no refused reply, no missed deadline and no refused session. Change 14 of 25: parser on demand, with a CPU bound and three strikes per message With one store child per account, the parser became one per account too, and a parser that missed its deadline or died stopped message parsing for every session of the account until the last one ended. The parser is now started when a request needs one, let go when it is idle or used up, and replaced when it fails. In the store child (store.c, mbox_fetch.c), there is no parser at setup. A FETCH walk that needs one and has none asks the parent (IMSG_PARSER_WANT) and waits at that message, as it already waits for its queue to drain. It goes on when IMSG_SETUP_PEER with id 0 brings a parser, or without one on IMSG_PARSER_NONE or after PARSER_REPLY_TIMEOUT_SEC. Before each message the walk checks the parser's channel without blocking, so a parser that has died since its last request is replaced rather than blamed. A deadline missed, or the channel lost once a request has been sent, lets the parser go and counts against that message. At PARSER_STRIKES (3) the message is not sent to a parser again while the child lives; the count is kept in the child, never in the parser. A parser unused for PARSER_IDLE_SEC (60) is let go by closing its channel. The parser-channel failures that called fatal() now let the parser go instead, since the far end of that channel is the process assumed to be attackable. In the parent (parent.c), IMSG_PARSER_WANT forks a parser for that store child, ending any earlier one it still has, so there is at most one per account. RLIMIT_CPU is set between fork and exec, 8 seconds soft and 10 hard, because the parser cannot set it under pledge. In the parser (parser.c), a reply sent once it has used PARSER_CPU_RETIRE_SEC (4 s, half the soft limit) of CPU says so, and the parser exits after sending it; the store lets it go on reading that. Honest work therefore never reaches the limit. The worst honest request measured 1.7 s of CPU on the test machine. A wire test was added first. It fetches ENVELOPE and BODYSTRUCTURE, waits while the account's parser is killed on the server, and fetches them again. It needs a person on the server, so it is run by hand. Checked on the test machine. The new test fails 3 of 8 against the build before this change: once the parser is killed, FETCH answers neither ENVELOPE nor BODYSTRUCTURE, and says NO. It passes 8 of 8 against this one, with nothing new in maillog. The build is clean with no compiler warnings, the suite passes, 19 scripts of 19, and the FETCH corpus is byte-identical to the reference, all 144 fetches. ps, sampled every five seconds through one session, shows no parser after login, one from the first FETCH ENVELOPE, none again about a minute later with the session still open, and the store child gone at LOGOUT. maillog has no refused reply, no missed deadline, no refused session, no parser that failed to arrive and no message struck out. Change 15 of 25: hold a mailbox's index lock from outside imapd, for the lock tests One store process now serves every session of an account and runs one command at a time, so two sessions of one account never contend for an index lock: the second session's command is served after the first and never meets the lock. The two lock tests made their holder a second session of the same account, so they no longer reached the code that waits for a busy lock, which is kept for a second process. A small program added here is that second process. Run on the server as root and given an account's maildir and a mailbox in it, it takes flock(2) LOCK_EX on the mailbox's imapd.index.lock while a mailbox named lockhold exists at the top of the same maildir, and releases it when that goes. The tests CREATE and DELETE lockhold from a third session, so the test decides when the lock is held, and nothing on the two machines has to be started at the same moment. Taking one maildir for both means the lock and the signal cannot name different accounts. A hold ends after -t seconds (default 60) whatever the signal says, so a test that dies cannot leave the mailbox locked. It only reads: the lock file is opened O_RDONLY without O_CREAT, flock(2) takes LOCK_EX on a read-only descriptor, and it pledges "stdio rpath flock". The lock-timeout test can use either holder, the external one by default. The imap holder is kept: against the shared process it shows that two sessions of an account are served one after the other. With the external holder it makes 4 checks rather than 5, since the hold cannot be seen from the client; "not early" and "refused" show it together. IDLE is not tested with the external holder, which changes nothing: "+ idling" is sent before the seed, so a seed that met the lock would leave nothing on the wire to check. The lock-wait snapshot test uses only the external holder, since the imap one would now pass without reaching the lock-wait code. The hold is 2 seconds, so it runs under the same short "lock timeout" as the other test, on any mailbox holding the message it works on. It makes 10 checks rather than 12: the holder's own STORE is gone, and the check that the waiter really waited now times it against the release. Its docstring also named the cur/ listing by its place before it moved into struct cur_snapshot. No daemon code changes. Checked on the test machine, against the installed account-worker build with "lock timeout 5" and a mailbox of 100,000 messages. The holder builds with no compiler warnings. With it holding the mailbox's lock from outside imapd, the lock-timeout test passed 4 of 4 for each of the ten commands it sends (store, expunge, fetch, search, close, select, status, copy, move, append): each waited, was refused NO [INUSE] within the bound and not early, and left the session working. The holder logged ten holds of 6.3 to 6.5 seconds, none of which had to wait for imapd. That run used an earlier build of the holder that took the lock file and the signal as two paths; the rest used this one. The snapshot test passed 10 of 10. With the holder stopped, the timeout test failed 2 of 4, the two checks that need a held lock, so those checks depend on it. maillog has no "missing on disk", "cannot be sent", "did not answer within" or "refusing a session" line. Change 16 of 25: end the auth-worker once the parent holds its grant Each connection has its own auth-worker. Once it has granted a login it has nothing left to do: it refuses a second grant for its session, and IMAP has no way to authenticate again after login. It stayed alive only until the listener's channel closed at the end of the session, so every logged-in connection kept one process that did nothing. The parent now sends IMSG_AUTH_EXIT once it has accepted the worker's IMSG_AUTH_CRED, and the worker flushes its answer to the listener and exits. The parent says when, rather than the worker exiting as soon as it has answered, because reap_child() closes a child's channel without reading it: a worker that exited on its own could take with it a grant the parent had not yet read. The worker's end matches sshd's, whose pre-authentication child exits once authentication is done, and the parent deciding matches IMSG_STORE_EXIT. The listener now expects its auth channel to close after a login and logs that at debug level. Before a login it is still a warning. A connection that never logs in is unchanged: its auth-worker takes every attempt, and exits when the listener goes. One path changes as a consequence. After a login whose store spawn failed, a retried AUTHENTICATE reached a worker that refuses an already granted session without answering, so the client waited until the login grace ended the session, or for ever with "login grace 0". It is now answered NO [UNAVAILABLE], or, if the listener has not yet seen the worker go, the listener's write to it fails and the session ends. A wire test was added to the suite first. It checks that a failed attempt leaves the next one able to succeed on the same connection, that the session works once the worker is gone, that AUTHENTICATE and LOGIN after login are refused BAD, and that twenty logins in a row each succeed. A client cannot see the change, so it passes before and after; ps(1) on the server shows the difference. Checked on the test machine. Against the build before this change, the new test passed 8 of 8, and ps(1), sampled every 5 seconds, showed the held session's auth-worker alive for the whole two minutes it was logged in. With this change the build is clean with no compiler warnings, the suite passes, 20 scripts of 20, with the new test at 8 of 8, and the parser baseline is byte-identical to the reference. ps(1) over four minutes showed no auth-worker behind any logged-in session, the held one or the four others logged in at the time. maillog has no "auth-worker closed channel", "refusing IMSG_AUTH_CRED", "IMSG_AUTH_EXIT", "imsgbuf_flush IMSG_AUTH_RESULT", "cannot be sent", "did not answer within" or "refusing a session" line. Change 17 of 25: imapd.8: one store child per account, and what lock timeout bounds Three statements in the manual had gone stale. The description said a store child is forked per authenticated session. One store child now serves each logged-in account, shared by all of that account's sessions. "login grace" counted three processes per accepted connection, including the search-oracle, which is gone. A connection costs two until it logs in: its listener-worker and its auth-worker. "lock timeout" said a command waits for another session of the same user. That user's sessions share one store child, which runs one command at a time, so they never wait for each other's lock; a long command delays the account's other sessions until it is done, and the directive does not bound that. What it bounds is a wait on a lock held by another process: a store child whose sessions have all ended, still finishing a command, while a new login for the same account starts another. Checked on the test machine. mandoc -Tlint reports only the two STYLE notes it reported before: the missing RCS id, and imapduser(8) not found. The build it was checked on also carries the "account sessions" change. Change 18 of 25: limit the sessions one account may hold: "account sessions" Nothing bounded how many sessions one account could open. Each costs a listener process, all of them share the account's store child, and a client configured to open many connections could have as many as it asked for. The new directive "account sessions", default 8, caps them. An account is the store child's key, a uid, gid and maildir from the credentials file, and the parent already counts the sessions attached to each. When a login would pass the cap, the parent refuses to attach it, and the listener answers NO [LIMIT] (RFC 9051 section 7.1) and closes the connection. It does not keep the connection for a retry: the connection's auth-worker has spent its one grant, as sshd's pre-authentication child does, and sshd likewise disconnects when a limit is reached during authentication. No BYE is sent, since RFC 9051 section 7.1.5 lists four conditions for one and a refused login is not among them. The client may log in again on a new connection once one of the account's sessions has ended. The other failures on that path, a store child that could not be started or did not finish its setup, and a second grant for a session already authenticated, now close the connection the same way, after the same NO as before. Left open, such a connection could not log in again, and it was no longer counted by the startups throttle, which counts only connections not yet authenticated, so it lasted until the login grace ended it, or for ever with "login grace 0". The directive is reloaded on SIGHUP and documented in imapd.8 and imapd.conf.example. A wire test came first. It logs one account in up to the cap, and checks that one more login is refused NO [LIMIT] and closed without a BYE, that the sessions already open still work, and that once one logs out the next login succeeds and the one after it is refused again. It needs an account no other client is logged in as, so it is run by hand. Checked on the test machine. Against the build before this change the new test failed 4 of 7, as predicted: the ninth login and the one after the logout were both answered OK. With this change the build is clean, with no compiler warnings and no yacc conflicts, mandoc reports only its two existing STYLE notes, the suite passes, 20 scripts of 20, the parser baseline is byte-identical to the reference, and the new test passes 7 of 7. maillog shows each of its two refusals as the parent's "refusing login: uid ... already has 8 sessions (account sessions)" followed by the listener's "closed ... reason=limit-exceeded". Change 19 of 25: limit the connections held at once: "connections max" Nothing bounded how many connections imapd accepted. Each costs a listener process, and one more until it logs in, and the parent and keymgr keep a descriptor for each. On OpenBSD's defaults, the process table and the parent's descriptors run out at about a thousand connections, long before memory does, and would run out in the middle of spawning a session. The new directive "connections max", default 256, bounds them. The parent counts every session it tracks, logged in or not, and checks the count in its accept loop before MaxStartups and before any fork. Past the limit a connection on the cleartext port gets RFC 9051's rejected-connection greeting, "* BYE [UNAVAILABLE]", written best effort as sshd writes its own refusal, and is closed. On the TLS port that greeting would have to travel inside TLS, which the parent does not speak, so the connection is closed with nothing sent, as httpd does at its client limit. The parent logs once when the limit is reached and once when it accepts again, as smtpd does. C connections, L of them not yet logged in, for A accounts need up to C + L + 2A processes plus two, and the parent keeps a descriptor to each. The default leaves room for the worst case, every connection a different account, within OpenBSD's default kern.maxproc and the daemon login class's openfiles. imapd.8 gives that rule, and says to raise those limits before raising this one. STORE_CHILD_MAX goes. It capped store children, one per account, at 64, so it refused the 65th account to log in, well inside the new default, and "connections max" bounds the accounts anyway. accept(2) failing for descriptors now pauses the listening sockets for a second, as httpd does. Before, the listen event stayed armed and fired again at once. The comment on the parent's duplicate-grant path said it was reachable by an ordinary retry after a failed store spawn. It has not been since the auth-worker began exiting after its grant; only a broken auth-worker gets there. The directive is reloaded on SIGHUP and documented in imapd.8 and imapd.conf.example. A wire test came first. It needs a low "connections max" in the running imapd.conf, so it is run by hand. It opens connections until one is refused, and checks the refusal on port 143 is the BYE greeting and a close, the refusal on port 993 a close with nothing sent, that the connections held still work, and that once one logs out a new connection is accepted and the one after it refused again. Checked on the test machine. Against the build before this change the new test failed 4 of 6, as predicted: nothing was refused, and port 143 answered with its OK greeting. With this change the build is clean, with no compiler warnings and no yacc conflicts, mandoc reports only its two existing STYLE notes, the suite passes, 20 scripts of 20, and the parser baseline is byte-identical to the reference. With "connections max 6" the new test passes 6 of 6, itself holding 3 connections with 3 already open, and maillog shows "connections max reached (6 open), refusing new connections", then "below connections max again (5 open), accepting", then the first line again. Change 20 of 25: cap connections not yet logged in per address: "startups per-source" MaxStartups counts the connections not yet logged in across all addresses. One address could therefore hold every one of its slots, a hundred by default, with bare TCP connections and no password, and every other client was refused until the login grace closed them. The new directive "startups per-source", as sshd's PerSourceMaxStartups, caps what one address may hold, default 5, below "startups begin" so one address cannot even start the ramp; "none" turns it off. The parent keeps each session's address and counts, at accept and before any fork, the sessions from that address not yet logged in, as sshd's srclimit_check_allow() does. A session stops counting once it logs in. Each address counts on its own, IPv6 ones included, as sshd's default grouping does; imapd.8 says what that leaves open for IPv6, and that users behind one NAT address share the limit. sshd refuses PerSourceMaxStartups and MaxStartups through one function. All three admission limits now share one refusal here too: "connections max", the new cap, and MaxStartups, which until now closed silently on both ports. On the cleartext port a refused connection gets RFC 9051's rejected-connection greeting, "* BYE [UNAVAILABLE]", best effort, and is closed. On the TLS port it is closed with nothing sent, since the greeting would have to travel inside TLS. The directive is reloaded on SIGHUP and documented in imapd.8 and imapd.conf.example. A wire test came first, and joins the suite, which now runs twenty-one scripts, since the cap is on by default. From one address it holds the cap's worth of connections without logging in, and checks that one more is refused on each port in its own way, that the connections held still work, that one of them logging in makes room for another, that the next is then refused again, and that one logging out makes room too. Checked on the test machine. Against the build before this change the new test failed 3 of 7, as predicted: one more connection on each port was accepted. With this change the build is clean, with no compiler warnings and no yacc conflicts, mandoc reports only its two existing STYLE notes, the suite passes, 20 scripts of 20 before the new test joined it, the parser baseline is byte-identical to the reference, and the new test passes 7 of 7. maillog has no line for its refusals, which are logged at debug level, as MaxStartups' are. Once it had joined, the suite passed 21 scripts of 21. Change 21 of 25: set SO_KEEPALIVE on client connections After login nothing ended a session but its client. A client that vanished without closing its connection, a laptop put to sleep or a phone that lost its network, left its session open until the server next wrote to it, and a session that was never written to stayed for ever. With "account sessions" that is also a lockout: every such session counts against the account's limit, so a client that dropped off often enough could fill it until imapd was restarted. The parent now sets SO_KEEPALIVE on every connection it accepts, as sshd does by default for TCPKeepAlive, and as syslogd does for its TLS connections, so the kernel probes a quiet connection and drops it when the probes go unanswered. How long that takes is the kernel's: the net.inet.tcp.keepidle sysctl plus eight net.inet.tcp.keepintvl intervals, 7200 and 75 seconds by default, so 2 hours 10 minutes. imapd.8 says so under "account sessions". There is no directive: a connection is always probed, as syslogd's are. A failure to set the option is logged and the connection goes on. No inactivity autologout is added. RFC 9051 section 5.4 would allow one of at least 30 minutes, but once keepalive ends the sessions whose client has gone, all it would add is logging out clients that are alive and quiet, each of which then reconnects. A wire test cannot make its own client vanish, so the check is ktrace. Checked on the test machine. Against the build before this change, ktrace(1) on the parent while one connection was made showed accept(2) and the two fcntl(2) calls that set O_NONBLOCK, and no setsockopt(2). With this change the build is clean, mandoc reports only its two existing STYLE notes, the suite passes, 21 scripts of 21, the parser baseline is byte-identical to the reference, and the same ktrace shows "setsockopt(9,SOL_SOCKET,SO_KEEPALIVE,...,4)" returning 0 after the fcntl(2) calls. Change 22 of 25: parse the SEARCH content keys' operands before refusing them RFC 9051 section 6.4.4 lists eleven search keys that need a message's header or body: BCC, BODY, CC, FROM, HEADER, SENTBEFORE, SENTON, SENTSINCE, SUBJECT, TEXT and TO. imapd refused each by name with NO as soon as it read the key, so it never read the operand that follows. The listener now parses those operands, as the first step to answering the keys. The string keys take an RFC 9051 section 9 astring: an atom or a quoted string, with only the two quoted-specials escaped. HEADER takes a field name and a string. The three SENT keys take a date, read by the same function as BEFORE, ON and SINCE, now shared. Having parsed the whole program, SEARCH still answers NO for any of the eleven, with the same text as before, because nothing yet answers them. Two answers change now, because they can be given without matching: A literal operand is refused BAD, "literals are not supported in SEARCH, send a quoted string". A synchronizing literal is never sent its continuation, which section 2.2.1 allows for a command refused with BAD. Literals in commands other than APPEND are a change of their own. The string operands of one SEARCH may hold 4096 octets in all, counted after unquoting and including HEADER's field name. More is refused "NO [LIMIT]", the section 7.1 response code for an implementation limit. 4096 is section 4.3's cap on a non-synchronizing literal, and it leaves room for the operands beside the search program in one imsg. A malformed operand, such as an unterminated quoted string or a missing one, is now BAD rather than the old NO. Checked on the test machine. The build is clean and mandoc reports only its two existing STYLE notes; the suite passes, 21 scripts of 21; the parser baseline is byte-identical to the reference. A new wire test of the header keys, written before this change, makes 38 checks: before it 30 failed, each on the old NO; after it 27 fail, the same refusal, and the three that pass are the two over-cap SEARCHes answered NO [LIMIT] and the literal operand answered BAD with no continuation. Change 23 of 25: answer SEARCH SUBJECT, HEADER and SENT* from the message header RFC 9051 section 6.4.4's SUBJECT, HEADER, SENTBEFORE, SENTON and SENTSINCE are now answered, where before they were refused with NO. FROM, TO, CC, BCC, BODY and TEXT are still refused as before. The matching runs in the parser-worker, the process that already parses message content for FETCH, confined by pledge "stdio recvfd" in a chroot: the store never reads a header for SEARCH. A new request, IMSG_PARSER_SEARCH, carries the message's descriptor and the SEARCH's content keys with their strings; the reply carries one byte per key, 1 where the message matches it. The store checks that the reply is exactly that, one byte of 0 or 1 per key, before using it. The strings travel from the listener in a pool beside the search program, in IMSG_MBOX_SEARCH's fixed part, so the lock-wait record keeps them with the request as it is. A node names its string by offset and length, and the store refuses one that lies outside the pool. The store evaluates each message over three values first. A content key is unknown until the parser answers, and AND, OR and NOT carry that through, so a message its flags already decide is never sent to the parser: UNSEEN SUBJECT x asks only about unseen messages, and a SEARCH with no content key never starts a parser. The SEARCH walk can now pause, as the FETCH walk can: to wait for a parser to start, and every 64 parser requests to let the event loop serve the account's other sessions. A message the parser cannot check, because it missed its deadline, died, has struck out, or cannot read the header within "attachment max", ends the SEARCH NO. Answering as if it had not matched would look complete and not be. The matching itself, in search_match.c: A field's value is unfolded (RFC 5322 section 2.2.3) and its RFC 2047 encoded-words are decoded, B and Q, with the white space between two adjacent words dropped (section 6.2). The decoded octets are compared as they are: no charset is converted, since base has no iconv. Matching is a substring match, case folded in the ASCII range only, as RFC 9051 section 6.4.4 says it SHOULD be. HEADER matches any field of that name; an empty string tests that the field is present. SENT* take the Date: field's own calendar day, disregarding its time and zone, and accept RFC 5322 section 4.3's two- and three-digit years. A message with no Date:, or one that cannot be read, matches none of them. The header is read in blocks up to its blank line. A NUL in it does not stop the match, which goes by length. mime.c gains header_next_field(), which walks a header's fields for a caller outside the file, over the existing hdr_next_field(). parser_request() sends with imsg_composev(), as relayd's ca.c does, so a request can carry data after its fixed part; the four FETCH callers pass none. Checked on the test machine. The build is clean and mandoc reports only its two existing STYLE notes; the suite passes, 21 scripts of 21; the parser baseline is byte-identical to the reference. The header-key test, written before the grammar change, makes 38 checks; before this change 27 failed, after it 16, each a check that names FROM, TO, CC or BCC, still refused. SUBJECT "fill" matched all 10,000 messages of a bench mailbox in 2.4 s and 99,999 of 100,000 in 44 s; NOT SUBJECT "fill" matched the other one, whose Subject is a test's own. During the 100,000-message search, a second session of the same account logged in, selected and fetched in 0.36 s. ps showed a parser for the account during the search, replaced once as it retired, and maillog has no failed check, bad request or parser failure. Change 24 of 25: answer SEARCH FROM, TO, CC and BCC RFC 9051 section 6.4.4's FROM, TO, CC and BCC are now answered, where before they were refused with NO. Only BODY and TEXT are still refused. Section 6.4.4 matches these keys against "the envelope structure's" field, and an address in that structure keeps its display name, local part and domain apart (section 7.5.2). Each address is matched twice: as its display name, with its quoted-pairs undone and its RFC 2047 encoded-words decoded, and as mailbox@host rejoined, as smtpd rejoins an address as user@domain before matching a rule (ruleset.c, mailaddr_to_text()). So a whole address matches as it is written, and so does any part of it or of the name. Comments are not taken out: one after an address is matched as part of its host, and one in a display name as part of the name, as ENVELOPE shows them. What does not match: the angle brackets, the commas between addresses, and a group's name, since a group is no address. HEADER From still matches the field's text as it stands. The address parse is the one ENVELOPE already uses. It is split out of envbuf_append_one_address() as address_split(), and the top-level comma split out of envbuf_append_address_list() as address_list_next(); both formatters now call them, and what they produce is unchanged. The matching runs in the parser-worker, as SUBJECT's does, in search_match.c. Checked on the test machine. The build is clean and mandoc reports only its two existing STYLE notes; the suite passes, 21 scripts of 21; the parser baseline, which fetches ENVELOPE, is byte-identical to the reference. The header-key test makes 38 checks; before this change 16 failed, each naming FROM, TO, CC or BCC, and after it none. FROM "filler@example.com" matched all 10,000 messages of a bench mailbox in 2.4 s, as SUBJECT did, and 99,999 of 100,000 in 44 s. A FROM that matches nothing took the same 2.4 s, and SEEN FROM on a mailbox with nothing seen took 0.52 s, the time of a flag-only search, so no message went to the parser. maillog has no failed check, bad request or parser failure. Change 25 of 25: check the SEARCH matcher against references, and fix what that found search_match.c, the parser-worker's matcher for SUBJECT, HEADER, SENT*, FROM, TO, CC and BCC, now has a fuzzing harness. A sanitizer finds an overrun, not a wrong answer, so it also checks the answers against references written in the harness: ASCII-only case folding, RFC 2047 B and Q words decoding back to their octets, with the white space between two adjacent words dropped (section 6.2) and kept elsewhere, Date: days computed without timegm(), every obsolete year of RFC 5322 section 4.3, SENTBEFORE and SENTSINCE partitioning the dated messages, every mailbox@host of a generated address list, and headers read across the 64K block edge and the cap. A table carries the header-key test's messages and keys, and the address cases. Before the fixes below it failed, each time for a wrong answer: A word that fails to decode overwrote the space before it. decode_value() took the space back before decode_word(), which writes as it goes, so "=?x?Q?a?= =?x?B?QQ!A?=" decoded to "aA=?x?B?...". The word is now decoded after the space and moved back over it only if it decodes. A folded Date: was no date. RFC 5322 section 3.3 allows FWS between a date's tokens, and date_field_day() took only SP and HTAB. It now takes CR and LF too; the value's only CR and LF are folds. "attachment max" was rounded up to the next 64K block, and a message of header fields alone exactly that long was refused. read_header() now reads at most one octet past the cap and refuses a header longer than it, as read_body_from_fd() refuses a message longer than it. The header-key test joins the suite, now twenty-two scripts. Checked on the test machine. The build is clean; the suite passes, 22 scripts of 22, the header-key test among them; the parser baseline is byte-identical to the reference; the header test makes 38 checks and none fails. The matcher's harness, built there with the minimal UBSan runtime, ran 3,201,074 cases and exited 0; before the three fixes it failed on each of them, in a run elsewhere. maillog has no failed check, bad request or parser failure. commit - e059bbcc10b86ee36a3b805c784b4057c0970bee commit + 7d9334a7d40e0d8a8bbba80f1e1026a34ec35911 blob - a048b0269361225d8c0318f6c477b70062f00f9b blob + 0ecebc52b885ae539520e05c47e61857f7d8be59 --- README.md +++ README.md @@ -6,15 +6,15 @@ A from-scratch IMAP4rev2 ([RFC 9051](https://www.rfc-e ## What it is -- **Privilege-separated**, `smtpd`-style, across six processes: a root *parent* reads configuration and binds the listening sockets; unprivileged *listener* and *auth* children handle the network and credential checks; *keymgr* holds the TLS private key and performs every private-key operation on request, so the process terminating TLS never has the key in its address space; a per-connection *search-oracle* parses the `SEARCH` grammar, the largest attacker-reachable parser in the daemon — in a process with no descriptors and no filesystem; and a *store* child is forked per authenticated session, chroots into the mail spool, and drops privileges to that session's own user before ever touching a message. `pledge(2)`, `unveil(2)`, and `chroot(2)` enforce these boundaries, not just convention: *parent* is the only process that can pass a file descriptor at all, and *search-oracle* runs on bare `stdio`. +- **Privilege-separated**, `smtpd`-style, across six processes: a root *parent* reads configuration and binds the listening sockets; unprivileged *listener* and *auth* children handle the network and credential checks; *keymgr* holds the TLS private key and performs every private-key operation on request, so the process terminating TLS never has the key in its address space; a *parser* child, spun up by a *store* child on demand and retired after a spell idle, does every parse of attacker-reachable content — `SEARCH`'s grammar and `FETCH`'s `ENVELOPE`/`BODYSTRUCTURE`/header-field parsing alike — confined to `pledge(2)` `"stdio recvfd"`: it opens nothing itself, reading each message over a descriptor the store child passes it already open; and a *store* child is forked per authenticated account, shared by all of that account's sessions, chroots into the mail spool, and drops privileges to that account's own user before ever touching a message. `pledge(2)`, `unveil(2)`, and `chroot(2)` enforce these boundaries, not just convention: *parent* is the only process that can pass a file descriptor at all. - **Storage**: stock maildir format (`tmp/`/`new/`/`cur/`, atomic delivery via `rename(2)`), readable with `ls` and `grep`, and natively understood by `smtpd(8)`'s own `maildir` delivery action. IMAP's extra bookkeeping (UIDs, UIDVALIDITY, per-message mod-sequences, keywords) lives in a small, `flock(2)`-guarded, line-oriented index file per mailbox, plain colon-delimited text, not a database. - **Transport**: STARTTLS on port 143 and implicit TLS on port 993 ([RFC 8314](https://www.rfc-editor.org/rfc/rfc8314)), via `libtls`. `AUTH=PLAIN` only, refused before TLS is established. ## Protocol coverage -`CAPABILITY`, `STARTTLS`, `AUTHENTICATE`, `ID`, `ENABLE`, `SELECT`, `EXAMINE`, `CREATE`, `DELETE`, `RENAME`, `LIST`, `LSUB`, `NAMESPACE`, `STATUS`, `FETCH` (including `ENVELOPE`, `BODYSTRUCTURE`, and MIME-part-addressed `BODY[]`/`BODY.PEEK[]`), `STORE`, `SEARCH`, `APPEND`, `COPY`, `MOVE`, `EXPUNGE`, `UNSELECT`, `CLOSE`, the `UID`-prefixed form of every command that supports it, `IDLE` (with the cross-session caveat noted below), and the [RFC 7162](https://www.rfc-editor.org/rfc/rfc7162) `CONDSTORE`/`QRESYNC` extensions. +`CAPABILITY`, `STARTTLS`, `AUTHENTICATE`, `ID`, `ENABLE`, `SELECT`, `EXAMINE`, `CREATE`, `DELETE`, `RENAME`, `LIST`, `LSUB`, `NAMESPACE`, `STATUS`, `FETCH` (including `ENVELOPE`, `BODYSTRUCTURE`, and MIME-part-addressed `BODY[]`/`BODY.PEEK[]`), `STORE`, `SEARCH`, `APPEND`, `COPY`, `MOVE`, `EXPUNGE`, `UNSELECT`, `CLOSE`, `SUBSCRIBE`, `UNSUBSCRIBE` (including LIST's [RFC 9051](https://www.rfc-editor.org/rfc/rfc9051) section 6.3.9.1 `SUBSCRIBED` selection option), the `UID`-prefixed form of every command that supports it, `IDLE` (with the cross-session caveat noted below), and the [RFC 7162](https://www.rfc-editor.org/rfc/rfc7162) `CONDSTORE`/`QRESYNC` extensions. -`SUBSCRIBE`, `UNSUBSCRIBE`, and ACL/shared-mailbox support are deliberately left out. +ACL/shared-mailbox support is deliberately left out. **`IDLE` is a poll, not a kernel-driven push.** An `IDLE`ing session rechecks its selected mailbox every `idle poll` seconds (default 5, see `imapd.conf`), so new mail, whether delivered by an external MTA or by another IMAP session, is reported within one interval rather than instantly. Most polls are two `stat(2)` calls and no lock: the store child only re-reads the index and streams UIDs when the mailbox directory or `new/` has actually been touched. Setting `idle poll 0` disables polling entirely, which restores the earlier behaviour where an `IDLE`ing session saw nothing until it sent `DONE`. @@ -76,7 +76,7 @@ Beyond the deliberate protocol-scope decisions covered - If the listener or auth process exits unexpectedly after startup, it is not automatically restarted. Recovery is `rcctl restart imapd`. See `imapd(8)`. -`SIGHUP` reloads `spool`, `attachment max`, `idle poll`, `startups`, and the TLS certificate/key without dropping connected sessions, `listen on` and `credentials` changes still require a restart. See `imapd(8)`. +`SIGHUP` reloads `spool`, `append max`, `attachment max`, `account sessions`, `connections max`, `idle poll`, `lock timeout`, `login grace`, `startups`, and the TLS certificate/key without dropping connected sessions, `listen on` and `credentials` changes still require a restart. See `imapd(8)`. IPv6 is supported (`listen on ::` or `listen on *` for dual-stack) but not the default, see `imapd(8)`'s `listen on` directive. blob - df2d90ea0e7fe4d18d6356afd2387738fd7053cb blob + 275b0f615d50095ed9373998a6bc4fa669c369b6 --- contrib/imapduser.8 +++ contrib/imapduser.8 @@ -2,7 +2,7 @@ .\" .\" Written for the OpenIMAPD project. Public domain / no rights reserved. .\" -.Dd $Mdocdate: September 18 2026 $ +.Dd $Mdocdate: September 25 2026 $ .Dt IMAPDUSER 8 .Os .Sh NAME @@ -170,4 +170,4 @@ Default spool root; see .Xr imapd 8 .Sh HISTORY .Nm -was written for the OpenIMAPD project. \ No newline at end of file +was written for the OpenIMAPD project. blob - d2d44336dd52fce32311a046d5ceb0a96265e1fc blob + d5bf488c9c64067329371bc773787b88e8e5e738 --- src/Makefile +++ src/Makefile @@ -11,9 +11,9 @@ SRCS= main.c parent.c log.c imsgev.c parse.y utf8.c m search_cmd.c store_cmd.c store_ipc.c \ auth.c \ keymgr.c \ - search_oracle.c \ + parser.c \ store.c index.c mime.c envelope.c mbox_fetch.c mbox_search.c \ - mbox_store.c mbox_manage.c mbox_copy.c + mbox_store.c mbox_manage.c mbox_copy.c search_match.c BINDIR= /usr/local/sbin MANDIR= /usr/local/man/man blob - 0b06f37a593afdff9c8300f25cf94fe6ea57708b blob + b6d33d4ccde5be554ed9a3d89cfe51ff2455903e --- src/append_cmd.c +++ src/append_cmd.c @@ -16,7 +16,6 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* append_cmd.c: APPEND literal upload, async IMSG_MBOX_APPENDED completion. */ #include #include @@ -42,7 +41,7 @@ #include "listener.h" #include "mboxname.h" -/* RFC 9051 SS9 date-time via sscanf(3); timegm(3) normalizes bad dates. */ +/* RFC 9051 SS9 date-time */ int parse_date_time(const char *s, int64_t *out) { @@ -85,11 +84,7 @@ parse_date_time(const char *s, int64_t *out) if (zsign == '-') zoff = -zoff; - /* - * Reject out-of-range zone offsets -- sscanf/RFC 9051 don't bound - * them, and an extreme offset can produce a maildir basename unsafe - * for shell globs. - */ + /* refuse a zone offset that makes the time negative */ if ((int64_t)t - zoff < 0) return (-1); @@ -97,7 +92,6 @@ parse_date_time(const char *s, int64_t *out) return (0); } -/* Result struct for parse_append_args(), avoids many out-parameters. */ struct append_parsed { char mailbox[MBOX_NAME_MAX]; uint32_t sysflags; @@ -108,7 +102,7 @@ struct append_parsed { int litnonsync; }; -/* RFC 9051 SS6.3.12 append grammar; reuses parse_store_flags() for flags. */ +/* RFC 9051 SS6.3.12 APPEND */ int parse_append_args(char *args, struct append_parsed *out, const char **errmsg) { @@ -122,7 +116,6 @@ parse_append_args(char *args, struct append_parsed *ou return (-1); } - /* one parser for every mailbox argument; see mailbox_cmd.c */ if (parse_mailbox_name(&p, out->mailbox, sizeof(out->mailbox), errmsg) == -1) return (-1); @@ -215,12 +208,7 @@ parse_append_args(char *args, struct append_parsed *ou memcpy(digitsbuf, start, digits_len); digitsbuf[digits_len] = '\0'; - /* - * RFC 9051 SS9's number64 is unsigned, but strtoull(3) accepts - * a sign, so "{-1}" would arrive as ULLONG_MAX and get a - * misleading NO [LIMIT] instead of BAD -- same digit guard - * listener.c's literal pre-scan already applies. - */ + /* strtoull(3) accepts a sign */ if (digitsbuf[0] < '0' || digitsbuf[0] > '9') { *errmsg = "malformed literal octet count"; return (-1); @@ -234,10 +222,7 @@ parse_append_args(char *args, struct append_parsed *ou out->litlen = (uint64_t)litlen; if (out->litnonsync && out->litlen > 4096) { - /* - * RFC 9051 SS4.3: non-sync literals capped at 4096B, - * BAD not NO. - */ + /* RFC 9051 SS4.3 caps a non-synchronizing literal */ *errmsg = "non-synchronizing literal exceeds RFC " "9051 SS4.3's 4096-octet limit, use a " "synchronizing literal instead"; @@ -253,7 +238,6 @@ parse_append_args(char *args, struct append_parsed *ou return (0); } -/* Parses the literal announcement, starts the store's file, reads the rest. */ int cmd_append(struct session *s, const char *tag, char *args) { @@ -275,10 +259,7 @@ cmd_append(struct session *s, const char *tag, char *a if (parsed.litlen > listener_append_max) { char text[96]; - /* - * RFC 5530 LIMIT code. The text states the number, as the - * example in RFC 9051 SS7.1 does, since it reaches the client. - */ + /* RFC 5530 LIMIT, stating the number as RFC 9051 SS7.1 does */ (void)snprintf(text, sizeof(text), "[LIMIT] message exceeds " "this server's %llu octet limit", (unsigned long long)listener_append_max); @@ -287,10 +268,6 @@ cmd_append(struct session *s, const char *tag, char *a } if (s->store_iev == NULL) { - /* - * Same store_iev invariant as cmd_select()/cmd_fetch(); ST_AUTH - * needs it. - */ log_warnx("session %u: APPEND with no store channel wired", s->id); session_reply(s, tag, "NO", "[SERVERBUG] internal error"); @@ -313,17 +290,13 @@ cmd_append(struct session *s, const char *tag, char *a req.msglen = parsed.litlen; s->append_prev_state = s->state; - /* tag is IMAP_TAG_MAX-bounded by session_handle_line(); rechecked */ if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >= sizeof(s->pending_tag)) { session_reply(s, tag, "NO", "[SERVERBUG] internal error"); return (1); } - /* - * The store opens the message's tmp/ file now and is sent the literal - * in pieces as it arrives, rather than this process holding it whole. - */ + /* the literal goes to the store in pieces as it arrives */ if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_APPEND, 0, 0, -1, &req, sizeof(req)) == -1) { log_warn("session %u: imsg_compose IMSG_MBOX_APPEND", s->id); @@ -335,12 +308,7 @@ cmd_append(struct session *s, const char *tag, char *a s->literal_remaining = parsed.litlen; s->literal_pending = 1; - /* - * RFC 9051 SS4.3: "+" continuation is only for synchronizing - * literals. Uses sizeof()-1, not a hand count -- a wrong - * hand-counted length once wrote a stray NUL onto the wire, same - * idiom as listener.c's session_write() calls. - */ + /* RFC 9051 SS4.3: "+" only for synchronizing literals */ if (!parsed.litnonsync) { static const char cont[] = "+ Ready for literal data\r\n"; @@ -350,7 +318,6 @@ cmd_append(struct session *s, const char *tag, char *a return (1); } -/* Literal and its CRLF are in: tells the store to commit the message. */ int session_finish_append(struct session *s) { @@ -364,11 +331,7 @@ session_finish_append(struct session *s) s->state = SESSION_APPENDING; - /* - * Same fail-soft shape as send_mbox_request(): without it, a - * compose failure leaves s->state stuck at SESSION_APPENDING and - * session_is_busy() blocks every further command. - */ + /* a failed compose must not leave the session busy */ if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_APPEND_END, 0, 0, -1, NULL, 0) == -1) { log_warn("session %u: imsg_compose IMSG_MBOX_APPEND_END", @@ -382,7 +345,6 @@ session_finish_append(struct session *s) return (1); } -/* Terminal APPEND reply; restores s->state to s->append_prev_state. */ void session_handle_mbox_appended(struct session *s, const struct imsg_mbox_appended *res) @@ -394,10 +356,7 @@ session_handle_mbox_appended(struct session *s, if (res->error != MBOX_OP_OK) { if (res->error == MBOX_OP_ERR_NO_SUCH_MAILBOX) session_reply(s, s->pending_tag, "NO", - /* - * SS6.3.12: reports why, not a promise CREATE would - * help - */ + /* RFC 9051 SS6.3.12 */ "[TRYCREATE] no such mailbox"); else if (res->error == MBOX_OP_ERR_BUSY) session_reply(s, s->pending_tag, "NO", @@ -408,10 +367,7 @@ session_handle_mbox_appended(struct session *s, return; } - /* - * INBOX compared case-insensitively (SS5.1); other names - * case-sensitively. - */ + /* RFC 9051 SS5.1: INBOX is case-insensitive */ if (mailbox_name_is_inbox(s->append_mailbox) && mailbox_name_is_inbox(s->selected_mailbox)) appended_to_selected = 1; blob - 9e9fdeab180b7849b4578bc3b08810eb0bdc06be blob + 15fc87a7f400101a1cce03f1c440bc849107d673 --- src/auth.c +++ src/auth.c @@ -16,8 +16,6 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* auth.c: credential verification, AUTHENTICATE PLAIN vs flat cred file. */ - #include #include @@ -44,7 +42,6 @@ struct cred_entry { }; static struct imsgev iev_listener; -/* fd 3, alive for the process's lifetime */ static struct imsgev iev_parent; static char cred_file_basename[256]; @@ -55,26 +52,15 @@ static void auth_verify(struct imsg_auth_request *, static void auth_dispatch(int, short, void *); static void auth_dispatch_parent(int, short, void *); -/* - * Refuses a second IMSG_AUTH_REQUEST for an already-resolved session_id, - * defending against a compromised/buggy listener replaying a grant; tracked in - * a fixed-size ring (not a TAILQ) since auth is never notified of session end, - * and session_id is unique-per-daemon so an evicted entry is harmless. - */ -/* - * Caps bcrypt-costing auth attempts per connection (sshd's MaxAuthTries - * default) so a client retrying without limit can't convert cheap packets into - * unbounded server CPU; past the cap, requests are refused without calling - * crypt_checkpass(3), closing the cost asymmetry (though not dropping the - * connection). - */ +/* refuse a replayed request for a resolved session_id */ +/* sshd's MaxAuthTries default */ #define AUTH_MAX_TRIES 6 static unsigned int auth_failures; #define AUTH_RESOLVED_MAX 256 static uint32_t auth_resolved[AUTH_RESOLVED_MAX]; -static size_t auth_resolved_next; /* ring cursor */ -static int auth_resolved_full; /* 1 once the ring has wrapped once */ +static size_t auth_resolved_next; +static int auth_resolved_full; static int auth_session_already_resolved(uint32_t sid) @@ -112,16 +98,8 @@ auth_main(void) char chrootdir[1024]; ssize_t n; - /* - * fd-passing allowed here for IMSG_SETUP_PEER below; see - * imsgev_ibuf_init() - */ imsgev_ibuf_init(&ibuf3, 3); - /* - * IMSG_AUTH_INIT read first: cred_file needed before chroot() is - * computed - */ for (;;) { if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1) fatal("imsgbuf_get"); @@ -137,32 +115,17 @@ auth_main(void) imsg_get_type(&imsg)); if (imsg_get_data(&imsg, &init, sizeof(init)) == -1) fatalx("auth: bad IMSG_AUTH_INIT payload"); - /* - * imsg_get_data() guarantees size, not NUL termination -- force it, - * since strlcpy(3) would otherwise read unboundedly past this stack - * struct while computing the chroot(2) target. - */ + /* imsg_get_data() does not NUL-terminate */ init.cred_file[sizeof(init.cred_file) - 1] = '\0'; 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 dir holding cred file, not itself; basename kept for - * unveil() - */ if (strlcpy(chrootdir, init.cred_file, sizeof(chrootdir)) >= sizeof(chrootdir)) fatalx("cred_file too long: %s", init.cred_file); - /* - * Actually verifies what the fatalx() below claims: strrchr() finding - * '/' only proves a directory component exists, not an absolute path - * (e.g. "etc/creds" would otherwise chroot(2) relative to cwd), and - * parse.y doesn't enforce this upstream. - */ if (init.cred_file[0] != '/') fatalx("cred_file must be an absolute path: %s", init.cred_file); @@ -188,16 +151,9 @@ auth_main(void) setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1) fatal("cannot drop privileges to _imapauth"); - /* no session id yet; retitled on the first request below */ /* the imsg id cannot carry one: listener.c reads id 0 as "auth peer" */ setproctitle("auth"); - /* - * Wires auth-worker's one and only peer via parent.c's - * spawn_connection()/setup_peer_send(), with no IMSG_SETUP_DONE ack - * needed since this boot sequence already reads a fixed, - * statically-known message set before touching the event loop. - */ peer_fd = setup_recv_one_peer(&ibuf3); event_init(); @@ -205,7 +161,6 @@ auth_main(void) imsgev_init_from_ibuf(&iev_parent, &ibuf3, auth_dispatch_parent, NULL); - /* unveil() path is relative to the chroot above: "/" + basename. */ { char unveil_path[512]; @@ -217,13 +172,7 @@ auth_main(void) fatal("unveil lock"); } - /* - * No recvfd, no sendfd: this process gets its one peer fd via - * setup_recv_one_peer() before this line and never attaches a - * descriptor to an imsg itself (only parent.c does); a plain imsg with - * fd == -1 needs neither pledge promise, and a wrong guess here is an - * uncatchable SIGABRT, not silent breakage. - */ + /* no recvfd or sendfd: the one peer fd came before this */ #ifdef __OpenBSD__ if (pledge("stdio rpath", NULL) == -1) fatal("pledge"); @@ -233,7 +182,6 @@ auth_main(void) fatalx("auth: exited event loop"); } -/* EV_WRITE must be handled: imsg_compose() queues, imsgbuf_write() sends */ static void auth_dispatch(int fd, short event, void *arg) { @@ -250,13 +198,6 @@ auth_dispatch(int fd, short event, void *arg) if ((n = imsgbuf_read(&iev->ibuf)) == -1) fatal("imsgbuf_read"); if (n == 0) { - /* - * This auth-worker was spawned to serve exactly one - * connection and will never serve another, so it exits - * here on listener EOF rather than idling in - * event_dispatch() forever -- the ordinary, expected - * end of a session per parent.c's reap_child(). - */ log_debug("auth-worker: listener closed channel, " "exiting"); exit(0); @@ -278,10 +219,7 @@ auth_dispatch(int fd, short event, void *arg) log_warnx("bad IMSG_AUTH_REQUEST"); break; } - /* - * imsg_get_data() guarantees size, not NUL termination, - * force it - */ + /* imsg_get_data() does not NUL-terminate */ req.username[sizeof(req.username) - 1] = '\0'; req.password[sizeof(req.password) - 1] = '\0'; @@ -317,12 +255,6 @@ auth_dispatch(int fd, short event, void *arg) 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, so truncation is - * structurally impossible and the strlcpy() - * return value is discarded deliberately. - */ (void)strlcpy(cred.maildir, res.maildir, sizeof(cred.maildir)); if (imsg_compose(&iev_parent.ibuf, @@ -343,7 +275,6 @@ auth_dispatch(int fd, short event, void *arg) (void)fd; } -/* parent never sends auth anything post-boot; flushes CRED writes, sees EOF */ static void auth_dispatch_parent(int fd, short event, void *arg) { @@ -372,6 +303,14 @@ auth_dispatch_parent(int fd, short event, void *arg) if (n == 0) break; + if (imsg_get_type(&imsg) == IMSG_AUTH_EXIT) { + imsg_free(&imsg); + /* the listener must have its answer before we go */ + if (imsgbuf_flush(&iev_listener.ibuf) == -1) + log_warn("imsgbuf_flush IMSG_AUTH_RESULT"); + log_debug("auth-worker: login granted, exiting"); + exit(0); + } log_debug("auth_dispatch_parent: unhandled %d", imsg_get_type(&imsg)); imsg_free(&imsg); @@ -380,11 +319,7 @@ auth_dispatch_parent(int fd, short event, void *arg) (void)fd; } -/* - * Usernames come off the network and reach syslog only through this: anything - * outside printable ASCII becomes '?', since auth can't assume listener.c's - * CR/LF rejection held. - */ +/* network usernames reach syslog only through here */ static void auth_safe_name(const char *in, char *out, size_t outsize) { @@ -398,7 +333,7 @@ auth_safe_name(const char *in, char *out, size_t outsi out[i] = '\0'; } -/* calls crypt_checkpass() with hash NULL on unknown user, avoids timing leak */ +/* unknown users still pay for crypt_checkpass(), against timing */ static void auth_verify(struct imsg_auth_request *req, struct imsg_auth_result *res) { @@ -407,11 +342,6 @@ auth_verify(struct imsg_auth_request *req, struct imsg int found; char safename[AUTH_USERNAME_MAX]; - /* - * Budget spent: refuses before cred_lookup() so a client past - * AUTH_MAX_TRIES can't even trigger a re-read/re-scan of the credential - * file; reported identically to any other failure. - */ if (auth_failures >= AUTH_MAX_TRIES) { char overname[AUTH_USERNAME_MAX]; @@ -438,13 +368,7 @@ auth_verify(struct imsg_auth_request *req, struct imsg auth_failures++; } - /* - * Logs every authentication outcome (previously nothing did, leaving - * password-guessing runs untraceable for fail2ban-style tooling); - * log_info() is always emitted, and the failure line deliberately - * doesn't distinguish "no such user" from "wrong password" to avoid an - * enumeration oracle. - */ + /* every outcome is logged, for fail2ban-style tools */ auth_safe_name(req->username, safename, sizeof(safename)); if (res->ok) log_info("session %u: authentication succeeded for \"%s\" " @@ -457,14 +381,6 @@ auth_verify(struct imsg_auth_request *req, struct imsg explicit_bzero(&ce, sizeof(ce)); } -/* - * True if a privileged file at st is safe to trust here: owned by root or the - * current (post-chroot, post-setresuid) uid, and not group-writable, - * group-executable, or accessible to world at all; same policy parse.y's - * check_file_secrecy() applies to imapd.conf (parse.y:678-695), kept as its own - * function since that one runs pre-privsep against an fd the parent still owns - * and logs a different message. - */ static int cred_file_secure(const struct stat *st) { @@ -475,7 +391,6 @@ cred_file_secure(const struct stat *st) return (1); } -/* linear scan of "username:passwordhash:uid:gid:maildir" lines */ static int cred_lookup(const char *path, const char *username, struct cred_entry *out) { @@ -488,15 +403,6 @@ cred_lookup(const char *path, const char *username, st return (-1); } - /* - * Rejects a credentials file not owned by root or the current uid, or - * that is group-writable, group-executable, or accessible to world at - * all (parent.c already enforces an equivalent policy for the TLS key, - * and parse.y's check_file_secrecy() for imapd.conf itself; this file - * holding every bcrypt hash had no such check until this one); - * group-read stays permissive so the documented "root:_imapauth 0640" - * layout keeps working, and a bad mode or owner fails closed. - */ { struct stat st; @@ -525,11 +431,7 @@ cred_lookup(const char *path, const char *username, st char *ep; unsigned long ulval; - /* - * fgets(3) silently splits an over-long line, which would - * otherwise parse the tail as a phantom credential entry -- - * refuses to read the whole file rather than guess. - */ + /* fgets(3) splits an over-long line */ if (strchr(line, '\n') == NULL && strlen(line) == sizeof(line) - 1) { log_warnx("%s: over-long line, refusing to parse the " @@ -556,32 +458,15 @@ cred_lookup(const char *path, const char *username, st if (strcmp(fields[0], username) != 0) continue; - /* - * skip rather than truncate a field, same as other malformed - * lines - */ if (strlcpy(out->username, fields[0], sizeof(out->username)) >= sizeof(out->username) || strlcpy(out->passwordhash, fields[1], sizeof(out->passwordhash)) >= sizeof(out->passwordhash)) continue; - /* - * Requires a bcrypt hash ("$2" prefix): crypt_checkpass(3) - * treats an empty stored hash plus empty password as a - * successful login rather than a disabled account, so a blank - * field must be rejected here, and non-bcrypt values are - * skipped the same way as every other malformed entry to avoid - * an enumeration oracle -- use imapduser -d to disable an - * account instead. - */ + /* crypt_checkpass(3) passes an empty hash and password */ if (fields[1][0] != '$' || fields[1][1] != '2') continue; - /* - * strtoul(3) accepts a leading '-', so "-1" would parse as - * 0xffffffff (and "0" is root); the credential file shouldn't - * be able to express either uid/gid, so both are rejected here - * before the wrap case slips through unnoticed. - */ + /* strtoul(3) accepts "-1", and uid 0 is root */ if (fields[2][0] < '0' || fields[2][0] > '9' || fields[3][0] < '0' || fields[3][0] > '9') continue; @@ -604,12 +489,7 @@ cred_lookup(const char *path, const char *username, st break; } - /* - * line[] held the raw credential record (username, bcrypt hash, uid, - * gid, maildir) for every entry scanned; auth_verify() and - * auth_dispatch() scrub their own copies, so this buffer is the one - * left behind. - */ + /* line[] held credential records */ explicit_bzero(line, sizeof(line)); fclose(fp); return (found ? 0 : -1); blob - f40fc88dc9f436f73ddc512da25a07fc77ded283 blob + d12b562475f766678ff91628fbb404cb544a5d9c --- src/auth_cmd.c +++ src/auth_cmd.c @@ -16,7 +16,6 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* auth_cmd.c: CAPABILITY/NOOP/LOGOUT/ID/LOGIN/AUTH/STARTTLS/ENABLE handlers. */ #include #include @@ -51,7 +50,6 @@ int cmd_capability(struct session *s, const char *tag, char *args) { - /* RFC 9051: "Arguments: none", extra args ignored, not rejected */ (void)args; session_untagged(s, s->tls_active ? @@ -87,7 +85,7 @@ cmd_logout(struct session *s, const char *tag, char *a int cmd_id(struct session *s, const char *tag, char *args) { - /* RFC 2971 SS3.1: field/value list logged, not parsed; replies NIL */ + /* RFC 2971 SS3.1: logged, not parsed; the reply is NIL */ log_debug("session %u: ID params: %s", s->id, args != NULL ? args : "(none)"); session_untagged(s, "ID NIL"); @@ -96,7 +94,6 @@ cmd_id(struct session *s, const char *tag, char *args) } -/* LOGIN permanently disabled, matching LOGINDISABLED in CAPABILITY strings. */ int cmd_login(struct session *s, const char *tag, char *args) { @@ -112,7 +109,6 @@ 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); } @@ -122,10 +118,7 @@ cmd_starttls(struct session *s, const char *tag, char return (1); } if (listener_tls_ctx == NULL) { - /* - * RFC 9051 SS6.2.1 NO + RFC 5530 UNAVAILABLE: cert/key load - * failed at boot - */ + /* RFC 5530 UNAVAILABLE: no certificate or key at boot */ session_reply(s, tag, "NO", "[UNAVAILABLE] TLS negotiation unavailable"); return (1); @@ -134,7 +127,7 @@ cmd_starttls(struct session *s, const char *tag, char /* precedes TLS */ session_reply(s, tag, "OK", "Begin TLS negotiation now"); - /* command-injection mitigation: discard buffered plaintext */ + /* discard buffered plaintext, against command injection */ s->inbuflen = 0; session_tls_start(s); @@ -144,8 +137,7 @@ cmd_starttls(struct session *s, const char *tag, char /* RFC 4616 SS2: authzid/authcid/passwd up to 255 octets + 2 NULs = 767 */ #define SASL_PLAIN_MAX 768 -/* Stores the username for the close line; mirrors auth.c's auth_safe_name() */ -/* non-printable becomes '?', so s->user is safe wherever it is logged */ +/* as auth.c's auth_safe_name(): '?' for anything unprintable */ static void session_set_user(struct session *s, const unsigned char *in, size_t inlen) @@ -160,7 +152,6 @@ session_set_user(struct session *s, const unsigned cha s->user[i] = '\0'; } -/* decodes+verifies one SASL PLAIN msg (RFC 4616 SS2); never tears down */ int sasl_plain_finish(struct session *s, const char *tag, const char *b64, int allow_empty_equals) @@ -213,10 +204,7 @@ sasl_plain_finish(struct session *s, const char *tag, explicit_bzero(raw, sizeof(raw)); return (1); } - /* - * too big for imsg_auth_request's fixed fields; generic NO avoids an - * oracle - */ + /* a generic NO, so no oracle */ if (authcidlen >= AUTH_USERNAME_MAX || passwdlen >= AUTH_PASSWORD_MAX) { session_reply(s, tag, "NO", "[AUTHENTICATIONFAILED] authentication failed"); @@ -236,11 +224,6 @@ sasl_plain_finish(struct session *s, const char *tag, session_reply(s, tag, "NO", "[SERVERBUG] internal error"); return (1); } - /* - * The auth-worker may not exist if its fork failed (parent.c), leaving - * iev_auth.ibuf.fd at -1 -- fail gracefully rather than compose to an - * unwired imsgev. - */ if (iev_auth.ibuf.fd == -1) { session_reply(s, tag, "NO", "[UNAVAILABLE] authentication " "temporarily unavailable"); @@ -259,11 +242,9 @@ sasl_plain_finish(struct session *s, const char *tag, return (1); } -/* reply to "+ " continuation after "AUTHENTICATE PLAIN" (cmd_authenticate) */ int session_handle_auth_continuation(struct session *s, const char *line) { - /* next line back to ordinary tagged command either way */ s->auth_cont = 0; /* RFC 9051 SS6.2.2: lone "*" cancels exchange */ @@ -276,7 +257,6 @@ session_handle_auth_continuation(struct session *s, co return sasl_plain_finish(s, s->pending_tag, line, 0); } -/* reply to our "+ idling" continuation (SS6.3.13); only "DONE" ends IDLE */ int session_handle_idle_continuation(struct session *s, const char *line) { @@ -301,7 +281,6 @@ cmd_authenticate(struct session *s, const char *tag, c 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); } @@ -325,24 +304,17 @@ cmd_authenticate(struct session *s, const char *tag, c return (1); } - /* only implements PLAIN, matching CAPABILITY_POST_TLS */ if (strcasecmp(mech, "PLAIN") != 0) { session_reply(s, tag, "NO", "authentication mechanism not available"); return (1); } - /* RFC 9051 SS6.2.2 initial-resp: one round trip */ if (initial != NULL) { - /* - * `initial` is base64 cleartext password into s->inbuf; reader - * scrubs it - */ s->scrub_inbuf = 1; return sasl_plain_finish(s, tag, initial, 1); } - /* no initial response: send "+"; auth_cont routes the reply line */ if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >= sizeof(s->pending_tag)) { session_reply(s, tag, "NO", "[SERVERBUG] internal error"); @@ -353,7 +325,6 @@ cmd_authenticate(struct session *s, const char *tag, c return (1); } -/* reply for a command store.c can't run yet (no payload); NO not BAD */ int stub_not_implemented(struct session *s, const char *tag, const char *cmdname) { @@ -363,7 +334,7 @@ stub_not_implemented(struct session *s, const char *ta return (1); } -/* RFC 9051 SS6.3.1 ENABLE: unknown exts ignored; ENABLED lists only new ones */ +/* RFC 9051 SS6.3.1 ENABLE */ int cmd_enable(struct session *s, const char *tag, char *args) { @@ -390,7 +361,6 @@ cmd_enable(struct session *s, const char *tag, char *a if (!s->condstore_enabled) newly_condstore = 1; } - /* anything else: unadvertised extension, SS6.3.1 says ignore */ } if (newly_condstore || newly_qresync) blob - 37d8cbb635a00549cdc76e86d82a9256c9d165a3 blob + 724a37ec1dd67f91057de94d728c98a17534e5f5 --- src/envelope.c +++ src/envelope.c @@ -16,7 +16,6 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* envelope.c, the ENVELOPE and BODYSTRUCTURE FETCH response builders. */ #include #include @@ -57,7 +56,7 @@ envbuf_append_str(char *buf, size_t bufsize, size_t *o return (envbuf_append(buf, bufsize, outlen, s, strlen(s))); } -/* Appends one RFC 9051 nstring: NIL if val NULL, else quoted+escaped. */ +/* RFC 9051 nstring */ int envbuf_append_nstring(char *buf, size_t bufsize, size_t *outlen, const char *val, size_t vallen) @@ -73,10 +72,7 @@ envbuf_append_nstring(char *buf, size_t bufsize, size_ for (i = 0; i < vallen; i++) { char c = val[i]; - /* - * RFC 9051 SS4.3 quoted strings exclude NUL/CR/LF; substitute - * rather than reject so one bad byte doesn't drop the field. - */ + /* RFC 9051 SS4.3: substituted, not dropped */ if (c == '\0' || c == '\r' || c == '\n') c = ' '; if ((c == '"' || c == '\\') && @@ -90,21 +86,16 @@ envbuf_append_nstring(char *buf, size_t bufsize, size_ return (0); fail: - /* - * All-or-nothing: a partial append leaves an unterminated quoted - * string. - */ + /* all or nothing */ *outlen = save; return (-1); } -/* Formats an RFC 5322 mailbox as an address tuple (RFC 9051 SS9); no groups. */ int -envbuf_append_one_address(char *buf, size_t bufsize, size_t *outlen, - const char *tok, size_t toklen) +address_split(const char *tok, size_t toklen, const char **name_out, + size_t *namelen_out, const char **mailbox_out, size_t *mailboxlen_out, + const char **host_out, size_t *hostlen_out) { - char addrbuf[1024]; - size_t addrlen = 0; const char *name = NULL; size_t namelen = 0; const char *spec; @@ -172,10 +163,6 @@ envbuf_append_one_address(char *buf, size_t bufsize, s if (displen >= 2 && disp[0] == '"' && disp[displen - 1] == '"') { - /* - * emission loop below re-escapes for the wire; - * no unescape pass needed - */ disp++; displen -= 2; } @@ -223,16 +210,36 @@ envbuf_append_one_address(char *buf, size_t bufsize, s host = spec + at + 1; hostlen = speclen - at - 1; - /* - * strip quotes from a quoted local-part (unescaped "@" inside not - * handled) - */ + /* unquote a quoted local-part */ if (mailboxlen >= 2 && mailbox[0] == '"' && mailbox[mailboxlen - 1] == '"') { mailbox++; mailboxlen -= 2; } + *name_out = name; + *namelen_out = namelen; + *mailbox_out = mailbox; + *mailboxlen_out = mailboxlen; + *host_out = host; + *hostlen_out = hostlen; + return (0); +} + +/* RFC 9051 SS9 address; no groups */ +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, *mailbox, *host; + size_t namelen, mailboxlen, hostlen; + + if (address_split(tok, toklen, &name, &namelen, &mailbox, &mailboxlen, + &host, &hostlen) == -1) + return (-1); + if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "(", 1) == -1) return (-1); @@ -249,6 +256,9 @@ envbuf_append_one_address(char *buf, size_t bufsize, s j++; c = name[j]; } + /* substituted as in envbuf_append_nstring() */ + if (c == '\0' || c == '\r' || c == '\n') + c = ' '; if ((c == '"' || c == '\\') && envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "\\", 1) == -1) @@ -280,39 +290,15 @@ envbuf_append_one_address(char *buf, size_t bufsize, s if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, ")", 1) == -1) return (-1); - /* - * One atomic append: the whole "(...)" tuple lands or none of it - * does, since envbuf_append() leaves *outlen untouched on failure. - */ return (envbuf_append(buf, bufsize, outlen, addrbuf, addrlen)); } -/* - * 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) +address_list_next(const char *val, size_t vallen, size_t *pos, + const char **tok_out, size_t *toklen_out) { - size_t save = *outlen; - size_t i = 0; - int any = 0; + size_t i = *pos; - 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; @@ -341,18 +327,46 @@ envbuf_append_address_list(char *buf, size_t bufsize, tok_len--; if (tok_len > 0) { - /* - * Safe to skip a malformed or non-fitting address and - * continue: envbuf_append_one_address() builds the - * tuple locally before one atomic append, leaving - * buf/outlen untouched on failure. - */ - if (envbuf_append_one_address(buf, bufsize, outlen, - val + tok_start, tok_len) == 0) - any = 1; + *tok_out = val + tok_start; + *toklen_out = tok_len; + *pos = i; + return (1); } } + *pos = i; + return (0); +} +/* RFC 9051 SS7.5.2 address list, or NIL */ +int +envbuf_append_address_list(char *buf, size_t bufsize, size_t *outlen, + const char *val, size_t vallen) +{ + const char *tok; + size_t save = *outlen; + size_t i = 0, toklen; + 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 (address_list_next(val, vallen, &i, &tok, &toklen) == 1) { + if (envbuf_append_one_address(buf, bufsize, outlen, tok, + toklen) == 0) + any = 1; + } + if (!any) { *outlen = save; return (envbuf_append_str(buf, bufsize, outlen, "NIL")); @@ -360,7 +374,6 @@ envbuf_append_address_list(char *buf, size_t bufsize, return (envbuf_append(buf, bufsize, outlen, ")", 1)); } -/* Looks up header `name`, appends nstring (NIL if absent); for ENVELOPE. */ int append_field_nstring(char *out, size_t outsize, size_t *outlen, const char *hdrbuf, uint32_t hdrlen, const char *name) @@ -378,12 +391,9 @@ append_field_nstring(char *out, size_t outsize, size_t 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. - */ +/* RFC 9051 SS7.5.2 ENVELOPE; Sender and Reply-To default to From */ int -build_envelope(int dfd, const char *basename, char **buf_out, +build_envelope(int fd, const char *basename, char **buf_out, uint32_t *len_out) { char *hdrbuf = NULL; @@ -396,7 +406,7 @@ build_envelope(int dfd, const char *basename, char **b *buf_out = NULL; *len_out = 0; - if (read_message_header(dfd, basename, &hdrbuf, &hdrlen) == -1) + if (read_header_from_fd(fd, basename, &hdrbuf, &hdrlen) == -1) return (-1); if (envbuf_append(out, sizeof(out), &outlen, "(", 1) == -1) @@ -528,11 +538,7 @@ fail: 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. - */ +/* RFC 9051 SS7.5.2 BODYSTRUCTURE; no extension data */ int build_body_structure(int depth, int *nparts_used, const char *hdr, size_t hdrlen, const char *body, size_t bodylen, char *out, @@ -572,10 +578,7 @@ build_body_structure(int depth, int *nparts_used, cons 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 0/0 - */ + /* RFC 2046 SS5.1.1: an empty part is legal */ if (plen == 0) phdrend = 0; else if (find_header_body_split(pbuf, plen, @@ -597,7 +600,7 @@ build_body_structure(int depth, int *nparts_used, cons if (strcasecmp(type, "MESSAGE") == 0 && (strcasecmp(subtype, "RFC822") == 0 || strcasecmp(subtype, "GLOBAL") == 0)) - /* scoped out, see BODYSTRUCTURE comment above */ + /* MESSAGE/RFC822 and MESSAGE/GLOBAL are not supported */ return (-1); { @@ -708,12 +711,8 @@ build_body_structure(int depth, int *nparts_used, cons } } -/* - * 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(int dfd, const char *basename, char **buf_out, +build_bodystructure(int fd, const char *basename, char **buf_out, uint32_t *len_out) { char *wholebuf = NULL; @@ -726,7 +725,7 @@ build_bodystructure(int dfd, const char *basename, cha *buf_out = NULL; *len_out = 0; - if (read_message_body(dfd, basename, 0, bodystructure_read_max, + if (read_body_from_fd(fd, basename, 0, bodystructure_read_max, "BODYSTRUCTURE", &wholebuf, &wholelen) == -1) return (-1); if (wholelen == 0 || blob - b3a8877617850b49d567d824e44dfc14c5f6f6ca blob + 024c85828cc7b50984c280f0be02c9abad1a68af --- src/fetch_cmd.c +++ src/fetch_cmd.c @@ -16,8 +16,6 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* FETCH: attribute/section-spec parsing and response building. */ - #include #include #include @@ -59,11 +57,6 @@ parse_nz_number(const char *str, uint32_t *out) return (0); } -/* - * Parses one range token (single optional colon, no comma) into r; factored out - * of the old parse_seq_range() so parse_sequence_set() can reuse it per - * comma-separated segment. - */ static int parse_one_seq_range(const char *tok, struct seq_range *r) { @@ -108,13 +101,7 @@ parse_one_seq_range(const char *tok, struct seq_range return (0); } -/* - * Parses an RFC 9051 SS9 sequence-set (comma-separated seq-number/seq-range) by - * splitting on top-level commas and parsing each with parse_one_seq_range(), - * writing up to SEQSET_MAX_RANGES entries to ranges[] and the count to - * *nranges, or returning -1 with *errmsg set on a malformed, empty, or excess - * segment. - */ +/* RFC 9051 SS9 sequence-set */ int parse_sequence_set(const char *text, struct seq_range ranges[SEQSET_MAX_RANGES], uint32_t *nranges, const char **errmsg) @@ -162,7 +149,7 @@ parse_sequence_set(const char *text, struct seq_range return (0); } -/* like strtok_r(); space isn't a delim inside an unclosed '[' or '(' (SS9) */ +/* a space inside an unclosed "[" or "(" is not a delimiter */ static char * fetch_att_tok(char *str, char **savep) { @@ -196,7 +183,7 @@ fetch_att_tok(char *str, char **savep) return (start); } -/* parses HEADER.FIELDS[.NOT] body (SS9); -1 is BAD, not a silent drop */ +/* RFC 9051 SS9 HEADER.FIELDS[.NOT]; -1 is BAD */ int parse_header_fields_att(const char *inner, int *not_out, char *fields_out, size_t fields_outsize) @@ -253,7 +240,7 @@ parse_header_fields_att(const char *inner, int *not_ou return (0); } -/* SS6.4.5.1 section-part check; string still reaches parse_section_part() */ +/* RFC 9051 SS6.4.5.1 section-part */ int section_part_valid(const char *s) { @@ -281,7 +268,7 @@ section_part_valid(const char *s) return (1); } -/* SS6.4.5 ""; count 0 handled by partial_range() */ +/* RFC 9051 SS6.4.5 */ int parse_partial_suffix(const char *s, int *has_partial_out, uint32_t *start_out, uint32_t *count_out) @@ -322,13 +309,6 @@ parse_partial_suffix(const char *s, int *has_partial_o return (0); } -/* - * generic BODY.PEEK[...] tok (already known not to be HEADER.FIELDS): [], - * [TEXT], or [], optional <> (SS6.4.5); updates - * *attrs_inout/section_part_out/partial-range out-params. Returns 1 on success, - * 0 if silently degraded (*degraded_out set, same lenient skip as other - * unsupported forms), -1 on a hard parse error (*errmsg set). - */ static int parse_body_peek_section_tok(const char *tok, uint32_t *attrs_inout, char *section_part_out, size_t section_part_outsize, @@ -381,13 +361,6 @@ parse_body_peek_section_tok(const char *tok, uint32_t return (1); } -/* - * BODY.PEEK[HEADER.FIELDS...] tok: extracts the bracket body, dedupes a second - * HEADER.FIELDS item, delegates to parse_header_fields_att(). Returns 1 on - * success (*attrs_inout and the header_fields_*_out params updated), 0 if this - * token should be silently ignored (a duplicate), -1 on a hard parse error - * (*errmsg set). - */ static int parse_body_peek_header_fields_tok(const char *tok, uint32_t *attrs_inout, int *header_fields_not_out, char *header_fields_out, @@ -427,7 +400,7 @@ parse_body_peek_header_fields_tok(const char *tok, uin return (1); } -/* SS6.4.5: ALL/FULL/FAST; unsupported skipped (*degraded_out=1), else -2/NO */ +/* RFC 9051 SS6.4.5 fetch-att; unsupported items are skipped */ int parse_fetch_atts(char *spec, uint32_t *attrs_out, int *degraded_out, int *header_fields_not_out, char *header_fields_out, @@ -475,18 +448,15 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 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. */ + /* RFC 9051 SS6.4.5 FAST macro */ attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE | MBOX_FETCH_RFC822_SIZE; } else if (strcasecmp(tok, "ALL") == 0) { - /* SS6.4.5 macro: FAST + ENVELOPE. */ + /* 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) - */ + /* ALL + bare BODY */ attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE | MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_ENVELOPE | MBOX_FETCH_BODYSTRUCTURE; @@ -499,7 +469,7 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int } else if (strcasecmp(tok, "RFC822.SIZE") == 0) { attrs |= MBOX_FETCH_RFC822_SIZE; } else if (strcasecmp(tok, "MODSEQ") == 0) { - /* SS3.1.4.2, CONDSTORE; cmd_fetch() checks */ + /* RFC 7162 SS3.1.4.2 */ attrs |= MBOX_FETCH_MODSEQ; } else if (strcasecmp(tok, "BODY.PEEK[HEADER]") == 0) { /* exact BODY[...]; \Seen unimplemented */ @@ -525,10 +495,6 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int attrs |= MBOX_FETCH_ENVELOPE; } else if (strcasecmp(tok, "BODY") == 0 || strcasecmp(tok, "BODYSTRUCTURE") == 0) { - /* - * both produce identical output, exact-matched ahead of - * "BODY" catch-all - */ attrs |= MBOX_FETCH_BODYSTRUCTURE; *bodystructure_full_out = (strcasecmp(tok, "BODYSTRUCTURE") == 0); @@ -536,10 +502,7 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int strcasecmp(tok, "RFC822") == 0 || strcasecmp(tok, "RFC822.HEADER") == 0 || strcasecmp(tok, "RFC822.TEXT") == 0) { - /* - * MIME part BODY[...] and RFC822(.HEADER/.TEXT) - * shorthands unimplemented - */ + /* other BODY forms and RFC822 items: unimplemented */ degraded = 1; } else { *errmsg = "unknown message data item"; @@ -548,30 +511,19 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int } if (attrs == 0) { - /* - * every item unsupported, e.g. BODY[] 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/HEADER.FIELDS requested (legal, SS6.4.5): HEADER wins - * here - */ + /* RFC 9051 SS6.4.5: HEADER wins over HEADER.FIELDS */ if ((attrs & MBOX_FETCH_BODY_HEADER) && (attrs & MBOX_FETCH_HEADER_FIELDS)) attrs &= ~MBOX_FETCH_HEADER_FIELDS; *attrs_out = attrs; *degraded_out = degraded; - /* - * accumulated in attrs, copied out so HEADER-wins is the one adjust - * point - */ *has_partial_out = has_partial; *partial_start_out = partial_start; *partial_count_out = partial_count; @@ -583,7 +535,7 @@ const char *fetch_month_names[12] = { "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; -/* SS9 date-time; always UTC "+0000": ts has no tz, chroot child lacks tzdata */ +/* RFC 9051 SS9 date-time, always "+0000": no tzdata in the chroot */ void format_internaldate(int64_t ts, char *out, size_t outsize) { @@ -603,7 +555,6 @@ format_internaldate(int64_t ts, char *out, size_t outs tm.tm_hour, tm.tm_min, tm.tm_sec); } -/* growing-buf helper; clamps *len, truncation can't underflow bufsize */ static void fetch_append(char *buf, size_t bufsize, size_t *len, const char *fmt, ...) { @@ -625,11 +576,7 @@ fetch_append(char *buf, size_t bufsize, size_t *len, c *len = bufsize; } -/* - * Writes len octets of fd, starting at off, to the client. The literal's - * length is already on the wire, so a short read cannot be repaired and - * the connection is shut down instead of desynchronized. - */ +/* a short read cannot be repaired: the length is on the wire */ static void session_write_file_range(struct session *s, int fd, uint64_t off, uint64_t len) @@ -660,7 +607,7 @@ session_write_file_range(struct session *s, int fd, ui } } -/* sends untagged FETCH response (SS7.5.2); literals flush buf, write raw */ +/* RFC 9051 SS7.5.2 */ void session_send_fetch_response(struct session *s, struct imsg_mbox_fetch_meta *meta) @@ -745,10 +692,7 @@ session_send_fetch_response(struct session *s, need_sp = 1; } if (have_body) { - /* - * SS6.4.5: echo origin octet only if client sent one, - * not store.c's count - */ + /* echo the origin only if the client sent one */ len = 0; if (s->pending_body_has_partial) fetch_append(buf, sizeof(buf), &len, @@ -777,12 +721,6 @@ session_send_fetch_response(struct session *s, session_untagged(s, buf); } - /* - * Reset pending_*_found when have_* is false, so it can't leak to - * the next reply. Requested but not found also means the store - * could not produce the item, which RFC 9051 SS6.4.5 answers with a - * tagged NO once the command finishes. - */ if (have_header) { free(s->pending_header_buf); s->pending_header_buf = NULL; @@ -832,7 +770,7 @@ session_send_fetch_response(struct session *s, } } -/* STORE's FETCH (SS6.4.6) shows FLAGS; MODSEQ if CONDSTORE-aware (SS3.1.3) */ +/* RFC 9051 SS6.4.6 STORE echo */ void session_send_store_fetch_response(struct session *s, const struct imsg_mbox_fetch_meta *meta) @@ -840,10 +778,7 @@ session_send_store_fetch_response(struct session *s, 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 - */ + /* RFC 9051 SS6.4.9: UID STORE echoes UID 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)) @@ -858,7 +793,7 @@ session_send_store_fetch_response(struct session *s, session_untagged(s, buf); } -/* splits trailing RFC4466 modifiers off spec; NUL-terminates spec in place */ +/* RFC 4466 modifiers */ char * split_trailing_modifiers(char *spec) { @@ -877,10 +812,6 @@ split_trailing_modifiers(char *spec) break; } } else if (*p == '\0') - /* - * unterminated; caller's parser produces the - * BAD for this - */ return (NULL); p++; } @@ -899,10 +830,7 @@ split_trailing_modifiers(char *spec) 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. - */ +/* RFC 4466, RFC 7162 SS3.1.4.1 and SS3.2.6 modifiers */ int parse_fetch_modifiers(char *modspec, struct imsg_mbox_fetch *req, const struct session *s, int by_uid, int *want_vanished, @@ -932,13 +860,7 @@ parse_fetch_modifiers(char *modspec, struct imsg_mbox_ "mod-sequence value"; return (-1); } - /* - * RFC 7162 SS7 mod-sequence-values are unsigned only, - * but strtoull(3) accepts a leading sign, so a guard - * rejects non-digit-leading input to stop "-1" silently - * becoming ULLONG_MAX (same check as - * auth.c/index.c/listener.c's literal parser). - */ + /* strtoull(3) accepts a sign */ if (*valtok < '0' || *valtok > '9') { *errmsg = "invalid CHANGEDSINCE mod-sequence"; return (-1); @@ -955,10 +877,7 @@ parse_fetch_modifiers(char *modspec, struct imsg_mbox_ req->attrs |= MBOX_FETCH_MODSEQ; } else if (strcasecmp(tok, "VANISHED") == 0) { if (!by_uid) { - /* - * RFC 7162 SS3.2.6: VANISHED with plain FETCH - * MUST return tagged BAD - */ + /* RFC 7162 SS3.2.6: BAD */ *errmsg = "VANISHED is only valid as a UID " "FETCH modifier (RFC 7162 SS3.2.6)"; return (-1); @@ -978,14 +897,13 @@ parse_fetch_modifiers(char *modspec, struct imsg_mbox_ return (0); } -/* SS6.4.5 fetch+RFC4466/7162 modifiers; BODY[...]/BODY[] a scope cut */ int cmd_fetch(struct session *s, const char *tag, char *args) { return fetch_dispatch(s, tag, args, 0); } -/* shared cmd_fetch/cmd_uid FETCH (uid 0/1); forces MBOX_FETCH_UID (SS6.4.9) */ +/* RFC 9051 SS6.4.9: UID FETCH forces UID */ int fetch_dispatch(struct session *s, const char *tag, char *args, int by_uid) { @@ -1056,7 +974,6 @@ fetch_dispatch(struct session *s, const char *tag, cha req.by_uid = by_uid; req.header_fields_not = header_fields_not; - /* bounds-checked above; applies per WHOLE/TEXT/PART winner */ 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)) @@ -1068,7 +985,6 @@ fetch_dispatch(struct session *s, const char *tag, cha req.partial_start = partial_start; req.partial_count = partial_count; - /* client label never reaches store.c; stashed to echo in the reply */ if (attrs & MBOX_FETCH_BODY_HEADER) { if (strlcpy(s->pending_header_label, "HEADER", sizeof(s->pending_header_label)) >= @@ -1087,10 +1003,6 @@ fetch_dispatch(struct session *s, const char *tag, cha } } - /* - * same, for BODY.PEEK[]/[TEXT]/[]; matches handle_mbox_fetch() - * order - */ if (attrs & MBOX_FETCH_BODY_WHOLE) { s->pending_body_label[0] = '\0'; } else if (attrs & MBOX_FETCH_BODY_TEXT) { @@ -1113,10 +1025,6 @@ fetch_dispatch(struct session *s, const char *tag, cha s->pending_body_has_partial = has_partial; s->pending_body_partial_origin = partial_start; - /* - * same, BODYSTRUCTURE: echoes "BODY"/"BODYSTRUCTURE" bare token client - * used - */ if (attrs & MBOX_FETCH_BODYSTRUCTURE) { if (strlcpy(s->pending_bodystructure_label, bodystructure_full ? "BODYSTRUCTURE" : "BODY", @@ -1137,10 +1045,7 @@ fetch_dispatch(struct session *s, const char *tag, cha } if (want_vanished && !req.has_changedsince) { - /* - * RFC 7162 SS3.2.6: VANISHED MUST pair with CHANGEDSINCE, else - * tagged BAD - */ + /* RFC 7162 SS3.2.6: VANISHED needs CHANGEDSINCE */ session_reply(s, tag, "BAD", "VANISHED requires CHANGEDSINCE also be specified " "(RFC 7162 SS3.2.6)"); @@ -1152,10 +1057,6 @@ fetch_dispatch(struct session *s, const char *tag, cha req.attrs |= MBOX_FETCH_UID; if (s->store_iev == NULL) { - /* - * same invariant as cmd_select(): ST_SELECTED requires - * store_iev wired - */ log_warnx("session %u: %s with no store channel wired", s->id, cmdname); session_reply(s, tag, "NO", "[SERVERBUG] internal error"); @@ -1164,10 +1065,7 @@ fetch_dispatch(struct session *s, const char *tag, cha req.nranges = nranges; - /* - * RFC 7162 SS3.1: MODSEQ fetch-att and CHANGEDSINCE both - * CONDSTORE-enabling - */ + /* RFC 7162 SS3.1: both enable CONDSTORE */ if (req.attrs & MBOX_FETCH_MODSEQ) session_condstore_enable(s); blob - f3d26fd552c675eab5d75707a86c008bcd83c659 blob + fe08b5b977abd9e830855e87f2de1c8f2f51072f --- src/imapd.8 +++ src/imapd.8 @@ -3,7 +3,7 @@ .\" Written for the OpenIMAPD project. Public domain / no rights reserved, .\" matching the project's ports-oriented, OpenBSD-base-inclusion goal. .\" -.Dd $Mdocdate: September 18 2026 $ +.Dd $Mdocdate: September 25 2026 $ .Dt IMAPD 8 .Os .Sh NAME @@ -32,11 +32,12 @@ and .Em auth children over .Xr imsg_init 3 -control channels; a +control channels. +One .Em store -child is forked per authenticated session, chroots into the mail -spool, and drops privileges to that session's own user before ever -touching mailbox data. +child serves each logged-in account, shared by all of that account's +sessions; it chroots into the mail spool and drops privileges to the +account's own user before ever touching mailbox data. The .Fl x flag referenced internally by these re-executed children is not @@ -219,6 +220,8 @@ and reloads the .Ic spool , .Ic append max , .Ic attachment max , +.Ic account sessions , +.Ic connections max , .Ic idle poll , .Ic lock timeout , .Ic login grace , @@ -437,6 +440,9 @@ At or above every new connection is refused. A connection stops counting the moment it authenticates, so the limit bounds unfinished logins rather than established sessions. +A refused connection is answered as one past +.Ic connections max +is. .Pp Both counts must be between 0 and 1000000 inclusive, .Ar percent @@ -462,14 +468,43 @@ Defaults to 100, matching .Xr sshd_config 5 Ns 's own default of 10:30:100. +.It Ic startups per-source Ar count | Ic none +The most connections that have not yet authenticated +.Nm +accepts from any one address, as +.Xr sshd_config 5 Ns 's +PerSourceMaxStartups does. +It stops one address holding every connection the +.Ic startups +limit allows, which would refuse everyone else. +A connection stops counting the moment it authenticates, and a refused +one is answered as one past +.Ic connections max +is. +.Pp +Each address counts on its own, IPv6 addresses included, so a host able +to use many IPv6 addresses can open this many from each of them; only +.Ic startups +and +.Ic connections max +bound that. +Users behind one NAT address share it, and after a restart may briefly +exceed it and have to retry. +Raise it for them, or set +.Ic none +to count no address separately. +.Pp +Must be between 1 and 1000000 inclusive, or +.Ic none . +Defaults to 5. .It Ic login grace Ar seconds How long a connection may go without authenticating before .Nm closes it. .Pp -Every accepted connection costs three processes, a listener-worker, an -auth-worker and a search-oracle, and a connection that completes the TCP -handshake and then sends nothing would otherwise hold all three +Every accepted connection costs two processes until it logs in, a +listener-worker and an auth-worker, and a connection that completes the +TCP handshake and then sends nothing would otherwise hold both indefinitely. Enough such connections reach the .Ic startups full @@ -493,18 +528,25 @@ Must be between 1 and 3600 seconds inclusive, or 0 to reopens the denial of service described above. Defaults to 60. .It Ic lock timeout Ar seconds -How long a command waits for another session of the same user to release a -mailbox's index lock before giving up and answering +How long a command waits for a mailbox's index lock held by another +process before giving up and answering .Li NO with the RFC 9051, section 7.1 .Li INUSE response code. .Pp -Two connections for one account are ordinary, and a command that changes a -mailbox holds its index lock for the whole of the change. -A client marking a large mailbox read can therefore hold the lock for the -better part of a minute, and another of that user's clients waits behind it. -This directive bounds that wait. +All of one account's sessions are served by its one +.Em store +child, one command at a time, so they never wait for each other's lock. +A command that changes a mailbox holds the lock for the whole of the +change, and marking a large mailbox read can take the better part of a +minute; the account's other sessions are answered only once it is done, +and this directive does not bound that. +Another process holds the lock when a +.Em store +child whose sessions have all ended is still finishing a command as a new +login for the same account starts another, and this directive bounds how +long the new one waits. .Pp It is deliberately generous, because it is a safety net for a holder that is stuck rather than a cure for one that is merely slow. @@ -518,6 +560,71 @@ a single command can legitimately run longer than the Must be between 1 and 3600 seconds inclusive, or 0 to disable the bound, which restores an unbounded wait. Defaults to 120. +.It Ic account sessions Ar count +The most sessions one account may have open at once. +An account is one uid, gid and maildir from the credentials file, +so entries that share all three are one account. +Every session counts, whichever client opened it, and a mail client +that opens several connections for one account uses several. +A client that vanishes without closing its connection keeps its session +until TCP keepalive finds it gone: after the +.Va net.inet.tcp.keepidle +.Xr sysctl 8 +plus eight +.Va net.inet.tcp.keepintvl +intervals, 2 hours 10 minutes by default. +.Pp +A login past the limit is answered +.Li NO +with the RFC 9051, section 7.1 +.Li LIMIT +response code, and the connection is closed. +The password was accepted, so the client may log in again on a new +connection once one of the account's sessions has ended. +.Pp +Must be between 1 and 4096 inclusive. +Defaults to 8. +.It Ic connections max Ar count +The most connections +.Nm +holds open at once, logged in or not, from all clients together. +Past it, a new connection on the cleartext port is answered with the +RFC 9051, section 7.1.5 rejected-connection greeting, a +.Li BYE +with the +.Li UNAVAILABLE +response code, and closed. +On the implicit TLS port that greeting would have to travel inside TLS, +so the connection is closed with nothing sent. +Connections already open are not affected. +.Pp +Each connection costs one process, and a second until it logs in. +Each account with a session logged in costs one more, and a second +while a message is being parsed for it. +So C connections, L of them not yet logged in, for A accounts need up +to C + L + 2A processes, plus two for +.Nm +itself, and the parent keeps a descriptor to each of them. +The processes count against the +.Va kern.maxproc +.Xr sysctl 8 , +shared with the rest of the system, and the descriptors against the +.Cm openfiles +limit of the +.Xr login.conf 5 +class +.Nm +runs in; raise those before raising this. +The default fits +.Ox Ns 's +default +.Va kern.maxproc +and +.Cm daemon +class even if every connection is a different account. +.Pp +Must be between 1 and 4096 inclusive. +Defaults to 256. .It Ic append max Ar bytes Largest message a client may upload with .Li APPEND . @@ -707,11 +814,13 @@ directive under FILES above. .Xr imsg_init 3 , .Xr tls_init 3 , .Xr httpd.conf 5 , +.Xr login.conf 5 , .Xr sshd_config 5 , .Xr syslog.conf 5 , .Xr httpd 8 , .Xr imapduser 8 , .Xr smtpd 8 , +.Xr sysctl 8 , .Xr syslogd 8 .Sh STANDARDS .Rs blob - 5b86996154c9b3069bea23f9c2a7cf037c026690 blob + ac8f63423c2f281213f976240a94b8fa2e18ebd3 --- src/imapd.conf.example +++ src/imapd.conf.example @@ -77,8 +77,8 @@ attachment max 41943040 #idle poll 5 # How long a connection may go without authenticating before imapd -# closes it. Every accepted connection costs three processes (a -# listener-worker, an auth-worker and a search-oracle), and a connection +# closes it. Every accepted connection costs two processes (a +# listener-worker and an auth-worker) until it logs in, and a connection # that completes TCP and then says nothing would otherwise hold them for # ever, so a few dozen silent connections can reach the "startups full" # limit below and lock everyone else out. RFC 9051 section 5.4 permits @@ -105,6 +105,23 @@ attachment max 41943040 # disable the bound (an unbounded wait). Defaults to 120. #lock timeout 120 +# The most sessions one account (one uid, gid and maildir in the +# credentials file) may have open at once, from all its clients +# together. A login past it is answered NO [LIMIT] (RFC 9051 section +# 7.1) and the connection is closed; the client may log in again on a +# new connection once one of the account's sessions has ended. Must be +# 1-4096. Defaults to 8. +#account sessions 8 + +# The most connections open at once, logged in or not. A new connection +# past it gets a BYE [UNAVAILABLE] greeting on port 143 and is closed; +# on port 993 it is closed with nothing sent. C connections, L of them +# not yet logged in, for A accounts need up to C + L + 2A processes and +# as many descriptors in the parent, so raise kern.maxproc and the +# login class's openfiles before raising this. The default fits +# OpenBSD's defaults. Must be 1-4096. Defaults to 256. +#connections max 256 + # Admission-control throttle on concurrent, not-yet-authenticated # connections, modeled on sshd_config(5)'s MaxStartups (see that # man page for the canonical description of this algorithm). @@ -112,6 +129,15 @@ attachment max 41943040 # accepted normally. Between "begin" and "full", new connections # are refused with linearly increasing probability, starting at # "rate" percent at "begin" and reaching 100% at "full". At or -# above "full", every new connection is refused outright. +# above "full", every new connection is refused outright, and answered +# as for "connections max". # Defaults to sshd_config(5)'s own default, 10:30:100. #startups begin 10 rate 30 full 100 + +# The most not-yet-authenticated connections from any one address, as +# sshd_config(5)'s PerSourceMaxStartups, so one address cannot hold +# every "startups" slot. Each IPv6 address counts on its own. Users +# behind one NAT address share it; raise it for them, or set "none" +# to turn it off. A refused connection is answered as for +# "connections max". Must be 1-1000000 or none. Defaults to 5. +#startups per-source 5 blob - 4b279ff164f1c69ee2048b601d4709ae2c096844 blob + 36a55e455f441bce94458123f671b274b6e98577 --- src/imapd.h +++ src/imapd.h @@ -16,34 +16,29 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* Shared definitions for every imapd(8) process role. */ - #ifndef IMAPD_H #define IMAPD_H #include -#include /* __dead */ +#include #include -#include /* sockaddr_storage, socklen_t */ +#include #include #include #include -#define IMAPD_VERSION "0.1.5" +#define IMAPD_VERSION "0.1.6" -/* Process roles, selected at exec time via "-x ". See main.c. */ enum openimap_proc_type { PROC_PARENT, PROC_LISTENER, PROC_AUTH, PROC_STORE, PROC_KEYMGR, - PROC_SEARCH /* per-connection SEARCH-grammar - * parsing oracle */ + PROC_PARSER }; -/* imsg message catalog. */ enum imsg_type { IMSG_NONE, @@ -51,69 +46,61 @@ enum imsg_type { IMSG_SETUP_PEER, IMSG_SETUP_DONE, - /* parent -> listener-worker at fork, and -> keymgr at boot and on */ - /* every SIGHUP. The certificate is public; each gets its own copy. */ IMSG_TLS_CERT, - /* parent -> listener-worker at fork: one already-accepted */ - /* connection, the client fd riding as this imsg's fd-pass. */ + /* parent -> listener-worker: an accepted connection, fd-passed */ IMSG_LISTENER_SESSION_INIT, - /* parent -> auth, at boot */ IMSG_AUTH_INIT, /* listener <-> auth */ IMSG_AUTH_REQUEST, IMSG_AUTH_RESULT, - /* parent -> listener-worker and -> search-oracle at fork, wiring the */ - /* per-connection SEARCH-parsing oracle. Its own type rather than */ - /* IMSG_SETUP_PEER, whose id field is already a peer discriminator. */ - IMSG_SETUP_SEARCH_PEER, - - /* listener -> search-oracle: SEARCH argument text as raw trailing */ - /* bytes, no fixed struct. Oracle -> listener: struct */ - /* imsg_search_parse_result. One round trip per session at most, so */ - /* no correlation id. */ - IMSG_SEARCH_PARSE_REQUEST, - IMSG_SEARCH_PARSE_RESULT, - - /* parent -> keymgr, at boot and on SIGHUP: the real TLS private key */ + /* parent -> keymgr, at boot and on SIGHUP: the TLS private key */ IMSG_KEYMGR_INIT, - /* listener <-> keymgr: one private-key operation, forwarded from */ - /* listener's OpenSSL engine override. Each reply reuses its */ - /* request's type, correlated by the imsg id field. */ + /* listener <-> keymgr: the reply reuses the request's type and id */ IMSG_KEYMGR_RSA_PRIVENC, IMSG_KEYMGR_RSA_PRIVDEC, IMSG_KEYMGR_ECDSA_SIGN, - /* parent -> keymgr, on shutdown: exit, this was not a crash */ IMSG_KEYMGR_SHUTDOWN, - /* auth -> parent, per successful login */ IMSG_AUTH_CRED, + IMSG_AUTH_EXIT, - /* per-session store spawn */ IMSG_STORE_FORK, IMSG_STORE_INIT, IMSG_STORE_SHUTDOWN, + IMSG_STORE_EXIT, - /* listener <-> store, once a session's store child is wired up. */ - /* SELECT carries EXAMINE too, as a request with "readonly" set. */ + /* parent -> parser: the uid to drop to; the parser opens nothing */ + IMSG_PARSER_INIT, + /* store -> parent: start a parser; it arrives as IMSG_SETUP_PEER */ + IMSG_PARSER_WANT, + IMSG_PARSER_NONE, + + /* store <-> parser: the reply reuses the request's type and id */ + IMSG_PARSER_ENVELOPE, + IMSG_PARSER_BODYSTRUCTURE, + IMSG_PARSER_HEADER_FIELDS, + IMSG_PARSER_PART, + IMSG_PARSER_SEARCH, + + /* listener <-> store; SELECT carries EXAMINE as readonly */ IMSG_MBOX_SELECT, IMSG_MBOX_SELECTED, IMSG_MBOX_FETCH, IMSG_MBOX_FETCH_META, - /* the four below are all store -> listener, one per message */ - IMSG_MBOX_FETCH_HEADER, /* raw BODY.PEEK[HEADER] bytes */ - IMSG_MBOX_FETCH_BODY, /* body descriptor and octet range */ - IMSG_MBOX_FETCH_ENVELOPE, /* formatted ENVELOPE text */ - IMSG_MBOX_FETCH_BODYSTRUCTURE, /* formatted BODYSTRUCTURE text */ + IMSG_MBOX_FETCH_HEADER, + IMSG_MBOX_FETCH_BODY, + IMSG_MBOX_FETCH_ENVELOPE, + IMSG_MBOX_FETCH_BODYSTRUCTURE, IMSG_MBOX_STORE, - IMSG_MBOX_APPEND, /* opens the message's tmp/ file */ - IMSG_MBOX_APPEND_DATA, /* one piece of the literal */ - IMSG_MBOX_APPEND_END, /* literal complete, commit it */ + IMSG_MBOX_APPEND, + IMSG_MBOX_APPEND_DATA, + IMSG_MBOX_APPEND_END, IMSG_MBOX_APPENDED, IMSG_MBOX_COPY, IMSG_MBOX_MOVE, @@ -130,28 +117,24 @@ enum imsg_type { IMSG_MBOX_RENAME, IMSG_MBOX_RESULT, - /* RFC 7162 CONDSTORE/QRESYNC. Both store -> listener, streamed */ - /* before the terminal reply. */ - IMSG_MBOX_SELECT_VANISHED, /* one vanished UID, QRESYNC resync */ - IMSG_MBOX_STORE_MODIFIED, /* one UNCHANGEDSINCE failure */ + /* RFC 7162 CONDSTORE/QRESYNC */ + IMSG_MBOX_SELECT_VANISHED, + IMSG_MBOX_STORE_MODIFIED, /* RFC 9051 SS6.3.13 (IDLE) */ - IMSG_MBOX_IDLE_REFRESH, /* listener -> store, seed or diff */ - IMSG_MBOX_IDLE_EXPUNGE, /* one untagged EXPUNGE to print */ - IMSG_MBOX_IDLE_FETCH, /* one flag change, as fetch_meta */ - IMSG_MBOX_IDLE_REFRESHED, /* terminal reply */ + IMSG_MBOX_IDLE_REFRESH, + IMSG_MBOX_IDLE_EXPUNGE, + IMSG_MBOX_IDLE_FETCH, + IMSG_MBOX_IDLE_REFRESHED, - /* RFC 9051 SS6.3.9 (LIST): one mailbox name, store -> listener, */ - /* before the terminal IMSG_MBOX_RESULT. */ + /* RFC 9051 SS6.3.9 (LIST) */ IMSG_MBOX_LIST_ITEM, - /* RFC 9051 SS6.3.7/SS6.3.8: both carry struct imsg_mbox_subscribe */ - /* and reply with IMSG_MBOX_RESULT. */ + /* RFC 9051 SS6.3.7/SS6.3.8 (SUBSCRIBE/UNSUBSCRIBE) */ IMSG_MBOX_SUBSCRIBE, IMSG_MBOX_UNSUBSCRIBE }; -/* privsep imsg-over-event(3) wrapper. */ struct imsgev { struct imsgbuf ibuf; void (*handler)(int, short, void *); @@ -163,61 +146,37 @@ struct imsgev { #define LISTENER_MAX_ADDRS 2 struct openimap_config { - char listen_addr[64]; /* "0.0.0.0" (default), "::", a literal - * address, or "*" for both */ - /* A port of 0 means this listener is not configured. parse.y clears */ - /* the one a config did not name, but only if it named either. */ + char listen_addr[64]; uint16_t port_cleartext; /* 143, STARTTLS; 0 = not configured */ uint16_t port_implicit_tls; /* 993, RFC 8314; 0 = not configured */ - char spool_root[1024]; /* mail spool root, store's chroot */ - char cred_file[1024]; /* auth's credential file, one - * line per user, format - * username:passwordhash:uid:gid: - * maildir */ + char spool_root[1024]; + char cred_file[1024]; char tls_cert_file[1024]; char tls_key_file[1024]; - uint32_t bodystructure_read_max; /* "attachment max" directive */ - uint64_t append_max; /* "append max" directive */ + uint32_t bodystructure_read_max; + uint64_t append_max; - /* the "startups begin/rate/full" directive. config_load() defaults */ - /* to 10/30/100, matching sshd_config(5)'s own "10:30:100". */ uint32_t max_startups_begin; uint32_t max_startups_rate; /* percent, 0-100 */ uint32_t max_startups_full; + uint32_t max_startups_per_source; - /* "idle poll": how often an IDLEing session asks its store child */ - /* whether the mailbox changed. 0 disables polling, and an IDLEing */ - /* session then sees nothing until it sends DONE. */ uint32_t idle_poll_secs; - - /* "lock timeout": seconds a command waits for another session's */ - /* index lock before answering NO [INUSE]. 0 disables the bound, */ - /* restoring the unbounded wait it replaced. */ uint32_t lock_timeout_secs; - - /* "login grace": seconds a connection may go without authenticating */ - /* before it is closed. 0 disables the timer, which reopens the */ - /* denial of service it exists to stop. */ uint32_t login_grace_secs; + uint32_t account_sessions; + uint32_t connections_max; }; -/* imsg payload wire structs. */ #define AUTH_USERNAME_MAX 64 #define AUTH_PASSWORD_MAX 128 #define AUTH_MAILDIR_MAX 256 -/* Boot-time config-delivery payloads. */ - -/* IMSG_LISTENER_SESSION_INIT's payload. The client fd rides as the imsg's */ -/* fd-pass, not a field here. remote_ss/remote_sslen are the raw sockaddr */ -/* from accept(2), so the worker does its own getnameinfo() formatting. */ struct imsg_listener_session_init { uint32_t session_id; int implicit_tls; struct sockaddr_storage remote_ss; socklen_t remote_sslen; - /* carried per connection, since this process is spawned fresh per */ - /* connection and so needs no reload path of its own */ uint32_t idle_poll_secs; uint32_t login_grace_secs; uint64_t append_max; @@ -227,39 +186,20 @@ struct imsg_auth_init { char cred_file[1024]; }; -/* - * IMSG_KEYMGR_RSA_PRIVENC / _RSA_PRIVDEC / _ECDSA_SIGN (listener to keymgr - * and back, same imsg type each way, correlated by the imsg id field). - * Fixed header plus trailing raw bytes on one imsg, as imsg_mbox_append - * below does. - * - * hash is libtls's tls_cert_pubkey_hash() format ("SHA256:" plus lowercase - * hex of the certificate's DER SubjectPublicKeyInfo digest); keymgr.c - * recomputes it from the certificate it holds and refuses a mismatch. - * padding is an OpenSSL RSA_PKCS1_PADDING-style constant, ignored for - * ECDSA_SIGN. KEYMGR_DATA_MAX (1024) covers an RSA buffer up to an - * 8192-bit key and any ECDSA digest or signature. - */ +/* keymgr request: fixed header, then fromlen bytes */ #define KEYMGR_HASH_MAX 80 /* "SHA256:" + 64 hex chars + NUL, generous */ #define KEYMGR_DATA_MAX 1024 struct imsg_keymgr_sign_request { char hash[KEYMGR_HASH_MAX]; - /* RSA padding mode; ignored for ECDSA */ + /* ignored for ECDSA */ uint32_t padding; - /* trailing input bytes, <= KEYMGR_DATA_MAX */ uint32_t fromlen; }; struct imsg_keymgr_sign_reply { - /* - * 0 = refused/failed; listener's engine callback returns this straight - * to OpenSSL, which fails that one RSA/EC operation, same as any other - * engine failure -- see keymgr.c's header comment on why this is a - * reply, not a fatalx() - */ + /* a failure fails one TLS operation, not the listener */ int ok; - /* trailing output bytes, meaningful only if ok */ uint32_t tolen; }; @@ -288,48 +228,37 @@ struct imsg_auth_cred { struct imsg_store_fork { uint32_t session_id; + int limit; /* refused by "account sessions" */ }; +struct imsg_parser_init { + uint32_t session_id; + uid_t uid; + gid_t gid; + uint32_t bodystructure_read_max; +}; + struct imsg_store_init { uint32_t session_id; uid_t uid; gid_t gid; - /* store needs this to chroot() */ char spool_root[1024]; - /* - * THIS session's own mailbox subdirectory, relative to spool_root above - */ + /* relative to spool_root */ char maildir[STORE_MAILDIR_MAX]; - /* - * copied from struct openimap_config's field of the same name - */ uint32_t bodystructure_read_max; uint64_t append_max; uint32_t lock_timeout_secs; }; -/* - * IMSG_MBOX_SELECT (listener -> store) / IMSG_MBOX_SELECTED (store -> - * listener). - */ #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). - * - * 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, and MBOX_OP_ERR_BUSY via RFC 9051 SS7.1's INUSE. - */ +/* RFC 5530 NONEXISTENT/ALREADYEXISTS, RFC 9051 SS7.1 INUSE */ 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, - /* gave up waiting for another session's index lock */ MBOX_OP_ERR_BUSY, }; @@ -338,49 +267,24 @@ struct imsg_mbox_select { char mailbox[MBOX_NAME_MAX]; int readonly; /* 1 = EXAMINE, 0 = SELECT */ - int qresync; /* 1 if QRESYNC was requested and - * enabled (RFC 7162 SS3.2.5) */ - uint32_t qresync_uidvalidity; /* client's last-known - * UIDVALIDITY; a mismatch means the - * rest of qresync_* is ignored */ - uint64_t qresync_modseq; /* client's last-known mailbox - * mod-sequence */ - int qresync_has_uids; /* 0 = client omitted known-uids; - * store.c then defaults to the full - * UID range (SS3.2.5.1) */ - uint32_t qresync_nranges; /* count of the trailing struct - * seq_range array (RFC 9051 SS9 - * sequence-set; "*" is forbidden in - * known-uids per SS3.2.5.1, already - * rejected by parse_qresync_group()); - * ignored (and 0) if - * !qresync_has_uids */ + int qresync; + uint32_t qresync_uidvalidity; + uint64_t qresync_modseq; + int qresync_has_uids; + uint32_t qresync_nranges; }; struct imsg_mbox_selected { - enum mbox_op_error error; /* MBOX_OP_ERR_GENERIC covers "no - * such mailbox" and I/O failure alike - *, always reported as - * [NONEXISTENT] */ - 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 */ - - /* RFC 7162 SS3.1.2.1: mailbox's highest mod-sequence, always - * populated; listener.c decides whether to surface it via the - * HIGHESTMODSEQ response code. */ + enum mbox_op_error error; + uint32_t exists; + uint32_t uidvalidity; + uint32_t uidnext; uint64_t highestmodseq; - /* store.c streams, before this struct: zero or more - * IMSG_MBOX_SELECT_VANISHED, then zero or more IMSG_MBOX_FETCH_META - * (modseq set), per RFC 7162 SS3.2.6's VANISHED-before-FETCH - * ordering, only when qresync was requested and UIDVALIDITY - * matched. */ + /* preceded by VANISHED, then FETCH_META (RFC 7162 SS3.2.6) */ }; -/* RFC 9051 SS6.3.11 status-att-val bits, plus RFC 7162 SS3.1.7's - * HIGHESTMODSEQ. Request-parsing order only, listener.c's response uses - * its own fixed order, not this bitmask's. */ +/* RFC 9051 SS6.3.11, plus RFC 7162 SS3.1.7 HIGHESTMODSEQ */ #define STATUS_ATT_MESSAGES (1U << 0) #define STATUS_ATT_UIDNEXT (1U << 1) #define STATUS_ATT_UIDVALIDITY (1U << 2) @@ -388,264 +292,163 @@ struct imsg_mbox_selected { #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 dropped - * \Recent/RECENT, but - * some clients still - * ask, always - * answered "0" rather - * than BAD */ +#define STATUS_ATT_RECENT (1U << 7) /* answered "0" */ -/* - * IMSG_MBOX_STATUS (listener -> store) / IMSG_MBOX_STATUS_RESULT (store -> - * listener): RFC 9051 SS6.3.11 STATUS, one combined reply, no per-message - * streaming. - * - * mailbox targets any named mailbox independent of what's selected - * (SS6.3.11); store.c opens it with mailbox_open_dir() and closes it - * afterward, leaving the selection alone. - */ +/* RFC 9051 SS6.3.11 STATUS; the selection is left alone */ struct imsg_mbox_status { char mailbox[MBOX_NAME_MAX]; - uint32_t attrs; /* STATUS_ATT_* bitmask. MESSAGES/UIDNEXT/ - * UIDVALIDITY/HIGHESTMODSEQ are always - * computed (free reads); UNSEEN/DELETED/SIZE - * only trigger a per-message scan if - * requested (RFC 9051 SS6.3.11 warns SIZE can - * be slow) */ + uint32_t attrs; }; struct imsg_mbox_status_result { - enum mbox_op_error error; /* MBOX_OP_ERR_GENERIC only -- - * always reported as - * [NONEXISTENT] */ - uint32_t messages; /* STATUS_ATT_MESSAGES */ - uint32_t uidnext; /* STATUS_ATT_UIDNEXT */ - uint32_t uidvalidity; /* STATUS_ATT_UIDVALIDITY */ - uint64_t highestmodseq; /* STATUS_ATT_HIGHESTMODSEQ, RFC 7162 - * SS3.1.7 */ - uint32_t unseen; /* STATUS_ATT_UNSEEN, 0 if not - * requested, see imsg_mbox_status.attrs - * comment */ - uint32_t deleted; /* STATUS_ATT_DELETED, same as above */ - uint64_t size; /* STATUS_ATT_SIZE, same as above */ + enum mbox_op_error error; + uint32_t messages; + uint32_t uidnext; + uint32_t uidvalidity; + uint64_t highestmodseq; + uint32_t unseen; + uint32_t deleted; + uint64_t size; }; -/* - * IMSG_MBOX_SELECT_VANISHED (store -> listener, zero or more, before the - * terminal IMSG_MBOX_SELECTED or IMSG_MBOX_RESULT): one range [uid_lo, - * uid_hi] of UIDs no longer present. Used both for QRESYNC SELECT resync - * and RFC 7162 SS3.2.6's VANISHED UID FETCH modifier; listener.c tells - * them apart by s->state. - * - * Computed in O(mailbox size) by walking the present-message list once - * and reporting gaps, not by iterating the (client-controlled) requested - * range. Ignores qresync_modseq entirely. This implementation uses RFC - * 7162 SS5.1's minimal-state model rather than persisting expunge - * history (SS5.3). - */ +/* one gap [uid_lo, uid_hi]; RFC 7162 SS5.1 minimal state */ struct imsg_mbox_select_vanished { uint32_t uid_lo; uint32_t uid_hi; }; -/* - * IMSG_MBOX_FETCH (listener -> store) / IMSG_MBOX_FETCH_META (store -> - * listener, one per matching message, ascending sequence order) / - * IMSG_MBOX_RESULT (store -> listener, once, after the last META). - */ -#define MBOX_FLAGS_MAX 256 /* generous; truncated (not rejected) if - * exceeded */ +#define MBOX_FLAGS_MAX 256 /* truncated if exceeded */ #define MBOX_FETCH_FLAGS (1U << 0) #define MBOX_FETCH_UID (1U << 1) #define MBOX_FETCH_INTERNALDATE (1U << 2) #define MBOX_FETCH_RFC822_SIZE (1U << 3) -#define MBOX_FETCH_MODSEQ (1U << 4) /* RFC 7162 SS3.1.4.2 -- - * set if MODSEQ was named, - * CHANGEDSINCE was used, or - * CONDSTORE is already enabled */ -#define MBOX_FETCH_BODY_HEADER (1U << 5) /* BODY.PEEK[HEADER] only - * (SS6.4.5): raw header block, not - * ENVELOPE. Plain BODY[HEADER] is - * unimplemented (\Seen side effect). */ -#define MBOX_FETCH_BODY_WHOLE (1U << 6) /* BODY.PEEK[] only - * (SS6.4.5): raw header+body, no MIME - * parsing. */ -#define MBOX_FETCH_BODY_TEXT (1U << 7) /* BODY.PEEK[TEXT] only - * (SS6.4.5.1): body after the - * header/body blank line. If both - * WHOLE and TEXT are requested, - * store.c answers WHOLE only. */ -#define MBOX_FETCH_HEADER_FIELDS (1U << 8) /* BODY.PEEK[HEADER.FIELDS - * [.NOT] (names)] (SS6.4.5.1); reuses - * IMSG_MBOX_FETCH_HEADER. req-> - * header_fields_not/header_fields - * carry the NOT flag and field list; - * listener.c echoes the client's own - * label text back verbatim. Plain - * BODY.PEEK[HEADER] wins if both are - * requested. */ -#define MBOX_FETCH_ENVELOPE (1U << 9) /* RFC 9051 SS7.5.2 -- - * parsed response via store.c's - * build_envelope(). No .PEEK - * variant, no \Seen side - * effect. */ -#define MBOX_FETCH_BODYSTRUCTURE (1U << 10) /* RFC 9051 SS7.5.2 - * BODYSTRUCTURE and its synonym - * bare "BODY", extension data - * (MD5/disposition/language/ - * location) is never emitted, - * so both produce identical - * output. Recursive MIME - * parsing via store.c's - * build_bodystructure(). */ -#define MBOX_FETCH_BODY_PART (1U << 11) /* BODY.PEEK[] - * only (SS6.4.5.1, numeric - * section-part). Leaf parts - * only, a MULTIPART container - * or nested MESSAGE/RFC822| - * GLOBAL numbering is "not - * found", matching - * BODYSTRUCTURE's own scope - * cut. Needs req->section_part, - * hence a separate bit from - * WHOLE/TEXT. */ +#define MBOX_FETCH_MODSEQ (1U << 4) /* RFC 7162 SS3.1.4.2 */ +#define MBOX_FETCH_BODY_HEADER (1U << 5) +#define MBOX_FETCH_BODY_WHOLE (1U << 6) +#define MBOX_FETCH_BODY_TEXT (1U << 7) +#define MBOX_FETCH_HEADER_FIELDS (1U << 8) +#define MBOX_FETCH_ENVELOPE (1U << 9) +#define MBOX_FETCH_BODYSTRUCTURE (1U << 10) /* and bare BODY */ +#define MBOX_FETCH_BODY_PART (1U << 11) -/* Cap on the dotted-numeric section-part string. An oversized one is */ -/* dropped as an unsupported fetch-att, not truncated and not an error. */ #define SECTION_PART_MAX 40 -/* Cap on raw header bytes one IMSG_MBOX_FETCH_HEADER carries. An */ -/* oversized header is "not found", not truncated. */ #define FETCH_HEADER_MAX 8192 -/* "append max" default and ceiling. The default matches smtpd.conf(5)'s */ -/* max-message-size default of 35M. */ +/* smtpd.conf(5)'s max-message-size default */ #define APPEND_MAX_DEFAULT (35 * 1024 * 1024) #define APPEND_MAX_MAX 1073741824 -/* Bytes a FETCH walk composes before it pauses to let them drain, so */ -/* the store holds this much of a reply rather than all of it. */ #define FETCH_BATCH_MAX (1024 * 1024) -/* Descriptors a FETCH walk passes before it pauses. Each holds a slot in */ -/* the system-wide file table, kern.maxfiles, until it has been sent. */ +/* each fd holds a kern.maxfiles slot until sent */ #define FETCH_FD_MAX 16 -/* Cap on the space-joined header-field-name list; an oversized list is */ -/* rejected, not truncated. */ #define HEADER_FIELDS_MAX 256 -/* Cap on formatted ENVELOPE text; an oversized one is "not found". */ #define ENVELOPE_MAX 8192 -/* Ceilings on the recursive BODYSTRUCTURE builder: a message's own */ -/* headers claim its part count and depth, so neither may be trusted. */ -/* Exceeding either is "not found" rather than a truncated part tree. */ +/* relayd's RELAY_TLS_PRIV_TIMEOUT bounds a similar wait */ +#define PARSER_REPLY_TIMEOUT_SEC 10 + +/* SIGXCPU at soft, SIGKILL at hard (sys/kern/kern_resource.c) */ +#define PARSER_CPU_SOFT_SEC 8 +#define PARSER_CPU_HARD_SEC 10 + +/* half the soft limit, so honest work retires before SIGXCPU */ +#define PARSER_CPU_RETIRE_SEC (PARSER_CPU_SOFT_SEC / 2) + +#define PARSER_IDLE_SEC 60 + +#define PARSER_STRIKES 3 + +/* a message's own headers claim these, so neither is trusted */ #define MIME_MAX_DEPTH 10 #define MIME_MAX_PARTS 64 -/* Cap on formatted BODYSTRUCTURE text; MIME_MAX_PARTS and */ -/* MIME_MAX_DEPTH above are the limits that bite first in practice. */ #define BODYSTRUCTURE_MAX 12000 -/* "idle poll" bounds. The default is short because an unchanged mailbox */ -/* costs two stat(2) calls and no lock. IDLE_POLL_MAX is a sanity bound. */ -#define IDLE_POLL_DEFAULT 5 /* seconds */ +#define IDLE_POLL_DEFAULT 5 #define IDLE_POLL_MAX 300 /* seconds; 0 disables polling */ -/* "login grace" bounds. RFC 9051 SS5.4 permits a shortened */ -/* pre-authentication timer specifically against denial of service; the */ -/* 30 minute floor in that section governs a POST-authentication */ -/* autologout, which this server does not have. */ -#define LOGIN_GRACE_DEFAULT 60 /* seconds */ +/* RFC 9051 SS5.4 permits a short pre-authentication timer */ +#define LOGIN_GRACE_DEFAULT 60 #define LOGIN_GRACE_MAX 3600 /* seconds; 0 disables the timer */ -/* "lock timeout" bounds. A command that cannot take a mailbox's index */ -/* lock waits this long before answering NO [INUSE] (RFC 9051 SS7.1). It */ -/* is a safety net for a holder that is stuck, not a cure for one that is */ -/* merely slow: an ordinary STORE over a large mailbox holds the lock for */ -/* the better part of a minute on modest hardware, and a deadline under */ -/* that would refuse ordinary concurrent use. */ -#define LOCK_TIMEOUT_DEFAULT 120 /* seconds */ +/* lock wait, then NO [INUSE] (RFC 9051 SS7.1) */ +#define LOCK_TIMEOUT_DEFAULT 120 #define LOCK_TIMEOUT_MAX 3600 /* seconds; 0 disables the bound */ -/* Cap on on-disk bytes read while deriving a message's MIME structure. */ -/* Deliberately above APPEND_MAX_DEFAULT: mail from an external MTA is */ -/* not bounded by "append max", and only the derived summary */ -/* goes back over the wire. Exceeding it is "not found", not truncated. */ -/* config_load()'s default for imapd.conf's "attachment max". */ +/* "account sessions" bounds; the maximum is parent.c's OPEN_SESSION_MAX */ +#define ACCOUNT_SESSIONS_DEFAULT 8 +#define ACCOUNT_SESSIONS_MAX 4096 + +/* "connections max" bounds; the maximum is parent.c's OPEN_SESSION_MAX */ +#define CONNECTIONS_MAX_DEFAULT 256 +#define CONNECTIONS_MAX_MAX 4096 + +#define STARTUPS_PER_SOURCE_DEFAULT 5 + +/* "attachment max"; mail from an MTA is not bound by "append max" */ #define BODYSTRUCTURE_READ_DEFAULT 41943040 -/* - * One comma-separated range of an RFC 9051 SS9 sequence-set. "*" travels - * unresolved via lo_is_star/hi_is_star, since only the store knows the live - * value to resolve it against. A whole sequence-set rides as an imsg - * request's trailing seq_range[nranges] array. - */ +/* each request carries an O_RDONLY descriptor on the message */ +struct imsg_parser_req { + char basename[512]; + char fields[HEADER_FIELDS_MAX]; + int fields_not; + int path[MIME_MAX_DEPTH]; + int pathlen; + uint32_t nleaves; + uint32_t poollen; +}; + +/* RFC 9051 SS6.4.4 content key; strings are offsets into the pool */ +struct imsg_parser_leaf { + int32_t op; + uint32_t str_off; + uint32_t str_len; + uint32_t name_off; + uint32_t name_len; + int64_t num; /* SENT*: that day's UTC midnight */ +}; + +struct imsg_parser_rep { + int found; + uint32_t len; + uint64_t part_off; + uint64_t part_len; + uint32_t retiring; +}; + +/* RFC 9051 SS9 sequence-set range; the store resolves "*" */ struct seq_range { - uint32_t lo; /* 1-based, inclusive; ignored if lo_is_star */ - uint32_t hi; /* 1-based, inclusive; ignored if hi_is_star */ + uint32_t lo; + uint32_t hi; int lo_is_star; int hi_is_star; }; -/* Bounds a sequence-set's range count, so the trailing seq_range array */ -/* cannot grow an imsg past MAX_IMSGSIZE. */ +/* keeps the trailing array under MAX_IMSGSIZE */ #define SEQSET_MAX_RANGES 500 struct imsg_mbox_fetch { - uint32_t nranges; /* count of the trailing struct - * seq_range array (RFC 9051 SS9 - * sequence-set); "*" resolves - * against store's live message - * count, not listener's possibly- - * stale SELECT-time count */ - uint32_t attrs; /* bitmask of MBOX_FETCH_* above */ + uint32_t nranges; + uint32_t attrs; - /* RFC 7162 SS3.1.4.1 CHANGEDSINCE; has_changedsince distinguishes - * "not specified" from a legal value of 0. */ + /* RFC 7162 SS3.1.4.1 CHANGEDSINCE */ int has_changedsince; uint64_t changedsince; - /* by_uid: RFC 9051 SS6.4.9 UID FETCH, resolve the trailing - * sequence-set ranges against UID space rather than sequence- - * number space. listener.c separately forces MBOX_FETCH_UID - * into attrs whenever this is set. - * - * want_vanished: RFC 7162 SS3.2.6 VANISHED modifier (only legal - * with CHANGEDSINCE, only on UID FETCH; listener.c enforces both). - * Per this implementation's minimal-state QRESYNC model, store.c - * reports every UID in range that isn't present, unconditionally - *, explicitly sanctioned by SS3.2.6. - */ + /* RFC 9051 SS6.4.9 UID FETCH; RFC 7162 SS3.2.6 VANISHED */ int by_uid; int want_vanished; - /* BODY.PEEK[HEADER.FIELDS[.NOT] (...)] (MBOX_FETCH_HEADER_FIELDS): - * header_fields_not is 0 for FIELDS (include), 1 for FIELDS.NOT - * (exclude). header_fields is the space-joined field-name list, - * exactly as typed (matching is ASCII case-insensitive, done by - * store.c). listener.c has already validated the grammar before - * either field is populated. - */ int header_fields_not; char header_fields[HEADER_FIELDS_MAX]; - /* BODY[]/BODY.PEEK[] - * (MBOX_FETCH_BODY_PART): section_part is the client-typed - * dotted-numeric path (e.g. "3.1"), pre-validated by listener.c's - * tokenizer. Single shared field, a client requesting two - * section-parts in one FETCH only gets the first honored. - * - * has_partial/partial_start/partial_count carry a <> - * range (SS6.4.5), applying uniformly to whole/TEXT/section_part. - * store.c slices the extracted content to - * [partial_start, partial_start + partial_count), clamped to - * the content's actual length (SS6.4.5's truncate-past-end-of-text - * rule) and to nothing else. has_partial distinguishes - * "no range" from a legal partial_start of 0. - */ + /* a second section-part in one FETCH is ignored */ char section_part[SECTION_PART_MAX]; int has_partial; uint32_t partial_start; @@ -653,368 +456,137 @@ struct imsg_mbox_fetch { }; struct imsg_mbox_fetch_meta { - uint32_t seqno; /* 1-based */ + uint32_t seqno; uint32_t uid; - uint64_t size; /* on-disk file size, octets -- - * RFC822.SIZE (RFC 9051 SS2.3.4). - * 64-bit so a message over 4 GiB - * reports correctly (F13 fix). */ - int64_t internaldate; /* Unix timestamp, parsed from the - * maildir basename's delivery-time - * field, not the file's mtime */ - char flags[MBOX_FLAGS_MAX]; /* space-separated IMAP flag - * names, e.g. "\Seen \Flagged foo", - * pre-formatted by store.c */ - uint64_t modseq; /* RFC 7162 per-message mod-sequence, - * always populated; shared by FETCH, - * STORE's FETCH echo, and QRESYNC - * resync's FETCH-with-UID responses */ + uint64_t size; + int64_t internaldate; /* from the basename, not mtime */ + char flags[MBOX_FLAGS_MAX]; + uint64_t modseq; }; -/* - * IMSG_MBOX_FETCH_HEADER: sent by store.c immediately before the - * IMSG_MBOX_FETCH_META for the same message, iff req->attrs & - * MBOX_FETCH_BODY_HEADER, listener.c relies on this exact ordering to - * fold the header bytes into the same "* N FETCH (...)" response line. - * Same "fixed struct + trailing variable-length bytes on one imsg" shape - * as imsg_mbox_append. - */ +/* sent just before the same message's IMSG_MBOX_FETCH_META */ struct imsg_mbox_fetch_header { - uint32_t seqno; /* 1-based, matches the following - * IMSG_MBOX_FETCH_META */ + uint32_t seqno; uint32_t uid; - int found; /* 0 if the message file couldn't be - * found or its header exceeded - * FETCH_HEADER_MAX; listener.c omits - * BODY[HEADER] from this message's - * response rather than failing the - * whole FETCH. hdrlen and the trailing - * bytes are only meaningful if 1. */ - uint32_t hdrlen; /* length of the trailing raw header - * bytes on this imsg, capped at - * FETCH_HEADER_MAX */ + int found; + uint32_t hdrlen; }; -/* - * IMSG_MBOX_FETCH_BODY: sent by store.c immediately before the - * IMSG_MBOX_FETCH_META for the same message, iff req->attrs & - * (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT | MBOX_FETCH_BODY_PART). - * The octets do not ride on the imsg. A found, non-empty body carries a - * read-only descriptor on the message file, and offset and length say - * which octets the literal is; the store has already done any parsing, - * so the listener only reads and writes. - */ +/* the octets travel as a passed descriptor, not in the imsg */ struct imsg_mbox_fetch_body { - uint32_t seqno; /* 1-based, matches the following - * IMSG_MBOX_FETCH_META */ + uint32_t seqno; uint32_t uid; - int found; /* 0 if the message file couldn't be - * found, it contained a NUL byte, or - * (TEXT only) no header/body - * separator was found */ - uint64_t offset; /* first octet, from the file's start */ + int found; + uint64_t offset; uint64_t length; /* octets; no descriptor when 0 */ }; -/* - * IMSG_MBOX_FETCH_ENVELOPE: sent by store.c immediately before the - * IMSG_MBOX_FETCH_META for the same message, iff req->attrs & - * MBOX_FETCH_ENVELOPE. Unlike fetch_header/fetch_body, the trailing bytes - * are the complete, already-formatted RFC 9051 SS7.5.2 envelope - * parenthesized-list text, ready to splice verbatim after "ENVELOPE " in - * the FETCH response. store.c's build_envelope() does all RFC 5322 - * parsing and address-list decomposition; listener.c does pure wire - * framing. No literal-block wrapping needed, envelope fields are short - * quoted strings, never raw message bytes, so never CRLF-bearing. - */ +/* CRLF-free is enforced by parser_reply_safe() in store.c */ struct imsg_mbox_fetch_envelope { - uint32_t seqno; /* 1-based, matches the following - * IMSG_MBOX_FETCH_META */ + uint32_t seqno; uint32_t uid; - int found; /* 0 if the message's header couldn't - * be found/read, or the formatted - * envelope exceeded ENVELOPE_MAX. - * envlen and the trailing bytes are - * only meaningful if 1. */ - uint32_t envlen; /* length of the trailing formatted - * envelope text, capped at - * ENVELOPE_MAX */ + int found; + uint32_t envlen; }; -/* - * IMSG_MBOX_FETCH_BODYSTRUCTURE: sent by store.c immediately before the - * IMSG_MBOX_FETCH_META for the same message, iff req->attrs & - * MBOX_FETCH_BODYSTRUCTURE. Same "already-formatted response text" - * shape as imsg_mbox_fetch_envelope. The trailing bytes are the complete - * RFC 9051 SS7.5.2 BODYSTRUCTURE parenthesized-list text, built by - * store.c's build_bodystructure()/build_body_structure() (Content-Type/ - * CTE/Content-ID/Content-Description extraction, multipart boundary - * splitting, recursion bounded by MIME_MAX_DEPTH/MIME_MAX_PARTS). - * Extension data (MD5/disposition/language/location) is never emitted, - * so BODYSTRUCTURE and bare BODY produce identical text. No - * literal-block wrapping needed, same reasoning as envelope. - */ struct imsg_mbox_fetch_bodystructure { - uint32_t seqno; /* 1-based, matches the following - * IMSG_MBOX_FETCH_META */ + uint32_t seqno; uint32_t uid; - int found; /* 0 if the message's raw bytes - * couldn't be read, MIME_MAX_DEPTH/ - * MIME_MAX_PARTS was exceeded, the - * message contains a message/rfc822 - * or message/global part, or the - * formatted text exceeded - * BODYSTRUCTURE_MAX. bslen and the - * trailing bytes are only meaningful - * if 1. */ - uint32_t bslen; /* length of the trailing formatted - * BODYSTRUCTURE text, capped at - * BODYSTRUCTURE_MAX */ + int found; + uint32_t bslen; }; -/* enum mbox_op_error is defined earlier in this file, above - * imsg_mbox_select, since several structs before this point need the - * complete type in scope. */ - -/* Generic per-operation completion signal; FETCH is the first user, but - * intended for STORE/APPEND/etc. too. */ struct imsg_mbox_result { 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 */ - - /* RFC 7162: mailbox HIGHESTMODSEQ after this operation. Always - * populated for STORE/EXPUNGE; 0 for plain FETCH. listener.c - * decides whether to surface it: EXPUNGE's tagged OK includes it - * whenever CONDSTORE is enabled (SS3.2.7); STORE's tagged OK only - * on the first CONDSTORE-enabling command (SS3.1.3); CLOSE's - * tagged OK MUST NOT include it (SS3.2.8). - */ + uint32_t count; uint64_t highestmodseq; - /* RFC 9051 SS7.1 COPYUID response code: destination mailbox's - * UIDVALIDITY. Populated only for COPY/MOVE; 0 for - * FETCH/STORE/EXPUNGE/SEARCH. - */ + /* RFC 9051 SS7.1 COPYUID; COPY and MOVE only */ uint32_t uidvalidity; }; -/* - * IMSG_MBOX_STORE (listener -> store): RFC 9051 SS6.4.6 STORE. Replies - * reuse IMSG_MBOX_FETCH_META (one per modified message, only if - * !silent) and the terminal IMSG_MBOX_RESULT, SS6.4.6's only response - * is an untagged FETCH, the same shape FETCH itself produces. - * - * System flags are the fixed 5-bit RFC 9051 SS2.3.2 set (\Answered - * \Flagged \Deleted \Seen \Draft; \Recent and any flag-extension are - * rejected by listener.c before this struct is built). Keywords are - * carried as a comma-separated list matching the index's own - * delimiter; listener.c rejects a keyword containing ':' or ',' (legal - * IMAP atoms, but the index format has no escaping for its own - * delimiters). - */ +/* keywords may not hold ':' or ','; the index cannot escape them */ #define MBOX_FLAG_ANSWERED (1U << 0) #define MBOX_FLAG_FLAGGED (1U << 1) #define MBOX_FLAG_DELETED (1U << 2) #define MBOX_FLAG_SEEN (1U << 3) #define MBOX_FLAG_DRAFT (1U << 4) -#define MBOX_STORE_SET 0 /* FLAGS, replace outright */ -#define MBOX_STORE_ADD 1 /* +FLAGS, union in */ -#define MBOX_STORE_REMOVE 2 /* -FLAGS, subtract out */ +#define MBOX_STORE_SET 0 /* FLAGS */ +#define MBOX_STORE_ADD 1 /* +FLAGS */ +#define MBOX_STORE_REMOVE 2 /* -FLAGS */ struct imsg_mbox_store { - uint32_t nranges; /* count of the trailing struct - * seq_range array (RFC 9051 SS9 - * sequence-set), same header-plus- - * trailing-array shape as - * imsg_mbox_fetch above; always >= 1, - * STORE always requires a - * sequence-set */ - int mode; /* MBOX_STORE_* above */ - int silent; /* 1 = ".SILENT", suppress the - * untagged FETCH per message */ - uint32_t sysflags; /* MBOX_FLAG_* bitmask named in this - * STORE (flags being set/added/ - * removed, not the resulting flags) */ - char keywords[MBOX_FLAGS_MAX]; /* comma-separated keyword - * atoms named in this STORE, "" if - * none */ + uint32_t nranges; + int mode; + int silent; + uint32_t sysflags; + char keywords[MBOX_FLAGS_MAX]; - /* RFC 7162 SS3.1.3 UNCHANGEDSINCE; has_unchangedsince distinguishes - * "not specified" from the legal (always-fails) value 0. - */ + /* RFC 7162 SS3.1.3 UNCHANGEDSINCE */ int has_unchangedsince; uint64_t unchangedsince; - /* RFC 9051 SS6.4.9: UID-vs-sequence-number switch for UID STORE, - * same as imsg_mbox_fetch's by_uid. - */ int by_uid; }; -/* - * IMSG_MBOX_STORE_MODIFIED (store -> listener, zero or more, only when - * req->has_unchangedsince, before the terminal IMSG_MBOX_RESULT): one - * message whose mod-sequence exceeded UNCHANGEDSINCE, so the STORE was - * not performed for it (RFC 7162 SS3.1.3). listener.c range-compacts - * these into the tagged response's MODIFIED response code. - */ +/* RFC 7162 SS3.1.3 MODIFIED */ struct imsg_mbox_store_modified { uint32_t seqno; uint32_t uid; }; -/* - * IMSG_MBOX_EXPUNGE (listener -> store) / IMSG_MBOX_EXPUNGED (store -> - * listener, one per removed message, in removal order) / IMSG_MBOX_RESULT - * (store -> listener, terminal). - * - * RFC 9051 SS6.4.3 EXPUNGE removes all \Deleted messages, one untagged - * EXPUNGE per removal before the tagged OK. Per SS7.5.1's "sequence - * number immediately decremented by 1" rule, this server removes - * lowest-numbered first (a "lower to higher" server), matching SS6.4.3's - * own worked example; see store.c's handle_mbox_expunge() for the - * compaction algorithm. - * - * silent lets CLOSE (SS6.4.1: no untagged EXPUNGE responses) reuse this - * request/reply pair, same ".SILENT" pattern as STORE. - */ +/* RFC 9051 SS6.4.3; lowest-numbered first (SS7.5.1) */ struct imsg_mbox_expunge { int silent; /* 1 for CLOSE, 0 for a real EXPUNGE command */ - /* RFC 9051 SS6.4.9's UID EXPUNGE form: only \Deleted messages in - * the trailing struct seq_range array (nranges entries, same - * header-plus-trailing-array shape as imsg_mbox_fetch above) are - * removed. Never set together with silent=1 (no "UID CLOSE"). - * Meaningless when !by_uid (plain EXPUNGE/CLOSE take no - * arguments) -- nranges is 0 and there's no trailing data in - * that case, unlike every other sequence-set-bearing imsg here, - * which always require nranges >= 1. - */ + /* UID EXPUNGE; nranges is 0 unless by_uid */ int by_uid; uint32_t nranges; }; struct imsg_mbox_expunged { - uint32_t seqno; /* sequence number at the moment of removal - * (SS7.5.1's "immediately decremented" - * rule), not the UID, not the - * pre-EXPUNGE sequence number */ - uint32_t uid; /* RFC 7162: same message's UID, needed once - * QRESYNC is enabled (SS3.2.10.2 replaces - * EXPUNGE with VANISHED in that case); the - * index line is already gone by the time - * this is sent, so there's no other way to - * learn it */ + uint32_t seqno; /* after earlier EXPUNGEs (RFC 9051 SS7.5.1) */ + uint32_t uid; /* for VANISHED (RFC 7162 SS3.2.10.2) */ }; -/* - * IMSG_MBOX_COPY (RFC 9051 SS6.4.7) / IMSG_MBOX_MOVE (SS6.4.8) share this - * request shape, distinguished by which IMSG_MBOX_* type arrived (same - * pattern as EXPUNGE/CLOSE's .silent). - * - * destname is validated by listener.c's copy_move_dispatch() the same - * way cmd_rename()'s oldname/newname are. store.c's handle_mbox_copy()/ - * handle_mbox_move() fast-path destname == the already-selected mailbox - * as a single-index operation, and only take the cross-mailbox two-index - * path (see those functions' own comments, including lock ordering) when - * it genuinely differs. - */ +/* RFC 9051 SS6.4.7 COPY and SS6.4.8 MOVE */ struct imsg_mbox_copy { int by_uid; - uint32_t nranges; /* count of the trailing struct - * seq_range array, same header-plus- - * trailing-array shape as - * imsg_mbox_fetch above; always >= 1, - * COPY/MOVE always require a - * sequence-set */ + uint32_t nranges; char destname[MBOX_NAME_MAX]; }; -/* - * IMSG_MBOX_COPY_MAPPING (store -> listener, zero or more, before the - * terminal IMSG_MBOX_RESULT): one message's COPYUID mapping (RFC 9051 - * SS7.1). Streamed one pair per message, ascending; listener.c - * accumulates src_uid/dest_uid into parallel arrays and range-compacts - * each once the terminal reply arrives, same "store streams raw values, - * listener compacts" split as ESEARCH/MODIFIED. - * - * Used for both COPY and MOVE. For MOVE, listener.c also buffers each - * IMSG_MBOX_EXPUNGED from the same round trip and flushes both buffers - * in order. COPYUID first, then EXPUNGE/VANISHED, per SS6.4.8's - * requirement to send COPYUID before EXPUNGE. Mirrors session_handle_ - * mbox_selected()'s dual-buffer-then-flush pattern for QRESYNC resync. - */ +/* COPYUID pairs, sent before EXPUNGE for MOVE (RFC 9051 SS6.4.8) */ struct imsg_mbox_copy_mapping { uint32_t src_uid; uint32_t dest_uid; }; -/* - * IMSG_MBOX_APPEND (listener -> store) / IMSG_MBOX_APPENDED (store -> - * listener, exactly once). - * - * The RFC 9051 SS6.3.12 literal does not ride on this imsg. This struct - * goes alone when the literal is announced, the octets follow as - * IMSG_MBOX_APPEND_DATA pieces as they are read, and IMSG_MBOX_APPEND_END - * follows the command's closing CRLF. The store answers only after END. - * Each piece is at most SESSION_INBUF_MAX, under MAX_IMSGSIZE. - */ +/* the literal follows as APPEND_DATA pieces, then APPEND_END */ struct imsg_mbox_append { char mailbox[MBOX_NAME_MAX]; - uint32_t sysflags; /* MBOX_FLAG_* bitmask; an omitted or - * empty flag-list means 0 (SS6.3.12) */ - char keywords[MBOX_FLAGS_MAX]; /* comma-separated, same - * convention as imsg_mbox_store's */ - int has_date; /* 0 = use current time at delivery - * (SS6.3.12) */ - int64_t date; /* Unix timestamp; meaningful only if - * has_date */ - uint64_t msglen; /* announced literal length; the - * DATA pieces must add up to it */ + uint32_t sysflags; + char keywords[MBOX_FLAGS_MAX]; + int has_date; + int64_t date; + uint64_t msglen; }; struct imsg_mbox_appended { - enum mbox_op_error error; /* MBOX_OP_ERR_NO_SUCH_MAILBOX, "not - * INBOX", tagged NO gets [TRYCREATE] - * per SS6.3.12; MBOX_OP_ERR_GENERIC, - * plain NO */ + enum mbox_op_error error; /* NO_SUCH_MAILBOX gets [TRYCREATE] */ uint32_t uidvalidity; - uint32_t uid; /* appended message's UID; with - * uidvalidity, this is SS7.1's - * APPENDUID response code */ - uint32_t exists; /* mailbox's new total message count, - * for SS6.3.12's untagged EXISTS - * notification, sent only if this - * session has the mailbox selected */ + uint32_t uid; /* APPENDUID, with uidvalidity */ + uint32_t exists; }; -/* - * IMSG_MBOX_SEARCH (listener -> store) / IMSG_MBOX_SEARCH_MATCH (store -> - * listener, one per match, ascending sequence order) / IMSG_MBOX_RESULT - * (store -> listener, terminal; count is the number of matches, used - * directly as COUNT if requested). - * - * RFC 9051 SS6.4.4 SEARCH's search-key grammar nests arbitrarily, so it - * can't be a single fixed-size struct. search_cmd.c's parse_search_key()/ - * parse_search_key_list() compile the whole search-program into a flat - * postfix array of struct search_node, sent as variable-length trailing - * data after a small fixed header, same imsg_get_buf()/imsg_get_len() - * technique as IMSG_MBOX_APPEND. SEARCH_PROGRAM_MAX_NODES bounds both - * wire size and evaluation stack depth; an oversized query gets a plain - * BAD. - */ +/* RFC 9051 SS6.4.4 search-program, as a postfix array of nodes */ #define SEARCH_PROGRAM_MAX_NODES 100 -#define SEARCH_KEYWORD_MAX 64 /* one flag-keyword atom, not - * a list, MBOX_FLAGS_MAX is - * sized for STORE's comma- - * joined keyword *list* and - * would be the wrong constant - * to reuse here */ +#define SEARCH_KEYWORD_MAX 64 /* one keyword, not a list */ +/* operand pool; RFC 9051 SS4.3's non-synchronizing literal cap */ +#define SEARCH_OPERANDS_MAX 4096 -#define SEARCH_OP_ALL 0 /* leaf: matches every message */ +#define SEARCH_OP_ALL 0 #define SEARCH_OP_ANSWERED 1 #define SEARCH_OP_UNANSWERED 2 #define SEARCH_OP_DELETED 3 @@ -1025,170 +597,89 @@ struct imsg_mbox_appended { #define SEARCH_OP_UNFLAGGED 8 #define SEARCH_OP_SEEN 9 #define SEARCH_OP_UNSEEN 10 -#define SEARCH_OP_KEYWORD 11 /* operand: keyword */ -#define SEARCH_OP_UNKEYWORD 12 /* operand: keyword */ -#define SEARCH_OP_BEFORE 13 /* operand: num (UTC day-start epoch) */ -#define SEARCH_OP_ON 14 /* operand: num (UTC day-start epoch) */ -#define SEARCH_OP_SINCE 15 /* operand: num (UTC day-start epoch) */ -#define SEARCH_OP_LARGER 16 /* operand: num (octets) */ -#define SEARCH_OP_SMALLER 17 /* operand: num (octets) */ -#define SEARCH_OP_SEQSET 18 /* operand: seq_lo/seq_hi/lo_is_star/ - * hi_is_star, matched against sequence - * number */ -#define SEARCH_OP_UIDSET 19 /* operand: seq_lo/seq_hi/lo_is_star/ - * hi_is_star, matched against UID */ -#define SEARCH_OP_AND 20 /* postfix binary combinator */ -#define SEARCH_OP_OR 21 /* postfix binary combinator */ -#define SEARCH_OP_NOT 22 /* postfix unary combinator */ -#define SEARCH_OP_MODSEQ 23 /* RFC 7162 SS3.1.5, operand: num, - * matches if the message's own - * mod-sequence is >= this. The - * optional / prefix is parsed by - * listener.c for syntax only and - * never reaches this struct */ +#define SEARCH_OP_KEYWORD 11 +#define SEARCH_OP_UNKEYWORD 12 +#define SEARCH_OP_BEFORE 13 +#define SEARCH_OP_ON 14 +#define SEARCH_OP_SINCE 15 +#define SEARCH_OP_LARGER 16 +#define SEARCH_OP_SMALLER 17 +#define SEARCH_OP_SEQSET 18 +#define SEARCH_OP_UIDSET 19 +#define SEARCH_OP_AND 20 +#define SEARCH_OP_OR 21 +#define SEARCH_OP_NOT 22 +#define SEARCH_OP_MODSEQ 23 /* RFC 7162 SS3.1.5 */ +/* RFC 9051 SS6.4.4 content keys, answered by the parser-worker */ +#define SEARCH_OP_SUBJECT 24 +#define SEARCH_OP_HEADER 25 +#define SEARCH_OP_SENTBEFORE 26 +#define SEARCH_OP_SENTON 27 +#define SEARCH_OP_SENTSINCE 28 +#define SEARCH_OP_FROM 29 +#define SEARCH_OP_TO 30 +#define SEARCH_OP_CC 31 +#define SEARCH_OP_BCC 32 struct search_node { - int op; /* SEARCH_OP_* above */ - int64_t num; /* BEFORE/ON/SINCE/LARGER/SMALLER/MODSEQ - * operand */ - uint32_t seq_lo; /* SEQSET/UIDSET operand, same "*" - * convention as imsg_mbox_fetch; - * store.c resolves it against live - * idx.nlines (SEQSET) or highest - * in-use UID (UIDSET) up front */ + int op; + int64_t num; + uint32_t seq_lo; uint32_t seq_hi; int lo_is_star; int hi_is_star; - char keyword[SEARCH_KEYWORD_MAX]; /* KEYWORD/UNKEYWORD - * operand */ + char keyword[SEARCH_KEYWORD_MAX]; + uint32_t str_off; /* into the pool */ + uint32_t str_len; + uint32_t name_off; + uint32_t name_len; }; struct imsg_mbox_search { - uint32_t nnodes; /* number of struct search_node entries - * in this imsg's trailing data */ + uint32_t nnodes; + uint32_t poollen; + char pool[SEARCH_OPERANDS_MAX]; }; -/* - * IMSG_SEARCH_PARSE_REQUEST's raw trailing bytes (already- - * buffered SEARCH argument text, post RETURN/CHARSET) are sized - * against this rather than left unbounded: generously bigger - * than any single argument list could legitimately be, since - * the whole command line it was sliced from is already capped - * at listener.h's 8192-byte SESSION_INBUF_MAX. - */ -#define SEARCH_ORACLE_ARGS_MAX 8192 +#define SEARCH_ARGS_MAX 8192 -/* - * IMSG_SEARCH_PARSE_RESULT (search-oracle -> listener): reply to - * IMSG_SEARCH_PARSE_REQUEST, echoing parse_search_key_list()'s own - * (rc, errmsg) contract (search_cmd.c) -- rc == 0: nnodes valid, - * struct search_node[nnodes] trails, same wire shape - * imsg_mbox_search above already uses; errmsg unused. rc == -1: - * BAD, errmsg set, nnodes/trailing data unused. rc == -2: NO, - * errmsg set, same. Every errmsg parse_search_key_list() and its - * helpers produce is a static string literal (search_cmd.c has no - * runtime-formatted SEARCH parse error), so - * SEARCH_ORACLE_ERRMSG_MAX only needs to cover the longest one, - * with headroom. uses_modseq mirrors struct search_parse_ctx's - * own field of the same name (search_cmd.c, opaque to every - * caller outside that file) -- RFC 7162 SS3.1: a SEARCH - * including the MODSEQ data item is a CONDSTORE-enabling - * command. Valid only when rc == 0, same as nnodes; a - * rejected parse never reaches search_dispatch()'s CONDSTORE - * check. - */ -#define SEARCH_ORACLE_ERRMSG_MAX 128 -struct imsg_search_parse_result { - int rc; /* 0 ok, -1 BAD, -2 NO */ - /* - * valid when rc == 0; struct search_node[nnodes] trails, same technique - * as imsg_mbox_search above - */ +/* rc: 0 ok, -1 BAD, -2 NO; errmsg is a static string */ +#define SEARCH_ERRMSG_MAX 128 +struct search_parse_result { + int rc; uint32_t nnodes; - int uses_modseq; /* valid when rc == 0, see this - * struct's own comment */ - char errmsg[SEARCH_ORACLE_ERRMSG_MAX]; /* valid when - * rc != 0 */ + int uses_modseq; + char errmsg[SEARCH_ERRMSG_MAX]; + uint32_t poollen; + char pool[SEARCH_OPERANDS_MAX]; }; struct imsg_mbox_search_match { uint32_t seqno; uint32_t uid; - uint64_t modseq; /* RFC 7162 SS3.1.6: highest - * mod-sequence among returned - * matches, required whenever the - * client used a MODSEQ criterion. - * Always populated (cheap); - * listener.c tracks the running max - * only when SEARCH_OP_MODSEQ was - * actually used. */ + uint64_t modseq; /* RFC 7162 SS3.1.6 */ }; -/* - * RFC 9051 SS6.3.13 (IDLE). IMSG_MBOX_IDLE_REFRESH (listener -> store), - * IMSG_MBOX_IDLE_EXPUNGE (store -> listener, one per untagged EXPUNGE to - * print, in order), IMSG_MBOX_IDLE_FETCH (store -> listener, one per - * message whose flags changed, carrying imsg_mbox_fetch_meta as FETCH and - * QRESYNC resync do), IMSG_MBOX_IDLE_REFRESHED (terminal). - * - * SS6.3.13 names flag changes among what IDLE exists to report, and - * requires an unsolicited FETCH to carry a UID item (SS7.5.2 repeats it). - * Messages that arrived since the last refresh are reported by EXISTS - * alone: their flags are news to nobody, and a client that wants them - * asks. - * - * Sent once after "+ idling" with seed set, then once per "idle poll" - * interval without it. store.c answers from refresh_index(), so a refresh - * sees what a fresh SELECT would, new mail from an external MTA included. - * Most polls cost two stat(2) calls and no lock; see index.c's - * idle_probe_unchanged(). - * - * The store child holds the UID list it last reported and compares in one - * walk, so what crosses the socket is what to print rather than the whole - * mailbox: a change to one message costs one imsg, not one per message. - */ +/* RFC 9051 SS6.3.13 IDLE: seeded once, then a diff per poll */ struct imsg_mbox_idle_refresh { - /* - * Adopt the current state as the baseline and report nothing. Set - * whenever the listener cannot vouch for what the client has already - * been told: at "+ idling", since the mailbox may have changed while - * the session was not idling. - */ + /* adopt the current state as baseline, report nothing */ int seed; }; struct imsg_mbox_idle_expunge { - uint32_t seqno; /* 1-based, already decremented for - * the EXPUNGEs sent before it - * (RFC 9051 SS7.5.1) */ + uint32_t seqno; }; struct imsg_mbox_idle_refreshed { int ok; - /* - * Set when the store child's cheap probe found neither the mailbox - * directory nor new/ touched since the last look, in which case no - * IMSG_MBOX_IDLE_EXPUNGE messages preceded this one and every field - * below is left zero and is meaningless. - */ + /* the probe saw no change; the fields below are zero */ int unchanged; - int exists_changed; /* print exists as "* n EXISTS" */ + int exists_changed; uint32_t exists; int busy; /* lock held elsewhere; ok says if it seeded */ }; -/* - * RFC 9051 SS6.3.4/SS6.3.5 (CREATE/DELETE) and SS6.3.9 (LIST), this pass, - * 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", - * no hierarchy-delimiter character, within MBOX_NAME_MAX) before either of - * these is ever sent, store.c re-validates independently rather than - * trusting that, the same defense-in-depth every other mailbox-name- - * carrying imsg in this file already gets across the privsep boundary. - */ +/* RFC 9051 SS6.3.4/SS6.3.5; store.c re-validates the name */ struct imsg_mbox_create { char mailbox[MBOX_NAME_MAX]; }; @@ -1197,50 +688,24 @@ struct imsg_mbox_delete { char mailbox[MBOX_NAME_MAX]; }; -/* - * RFC 9051 SS6.3.6 (RENAME). oldname/newname, not "mailbox"/"destination", - * to avoid confusion with imsg_mbox_copy's destination field, RENAME's - * two names are peers in the same flat namespace, not a source-range- - * plus-destination pairing like COPY/MOVE. - */ +/* RFC 9051 SS6.3.6 RENAME */ struct imsg_mbox_rename { char oldname[MBOX_NAME_MAX]; char newname[MBOX_NAME_MAX]; }; -/* - * RFC 9051 SS6.3.9 (LIST). One IMSG_MBOX_LIST_ITEM per mailbox, unordered, - * INBOX excluded; listener.c does its own wildcard matching. Terminal reply - * reuses imsg_mbox_result ("ok" = 0 only on a real I/O error, not on finding - * zero mailboxes). - * - * subscribed_only picks WHICH set of names is streamed: 0 is every mailbox - * on disk, 1 is every subscribed name, which SS6.3.9.1 says may include names - * with no mailbox behind them. The store reads the subscription file only - * when this is 1, so a plain LIST -- what a client sends on every connection - * -- costs exactly what it did before subscriptions existed. - */ +/* RFC 9051 SS6.3.9 LIST; unordered, INBOX excluded */ struct imsg_mbox_list { int subscribed_only; }; -/* - * One mailbox name for the LIST above. `exists` is 0 only in a - * subscribed_only stream, naming something subscribed with no mailbox on - * disk: SS6.3.8 forbids dropping such a name from the list, and SS6.3.9.6's - * Table 3 has the server report it rather than stay silent. Every item in a - * subscribed_only stream is subscribed by construction, so no field says so. - */ +/* exists 0: subscribed, no mailbox (RFC 9051 SS6.3.9.6) */ struct imsg_mbox_list_item { char mailbox[MBOX_NAME_MAX]; int exists; }; -/* - * RFC 9051 SS6.3.7 (SUBSCRIBE) / SS6.3.8 (UNSUBSCRIBE). One struct for - * both, as IMSG_MBOX_COPY and IMSG_MBOX_MOVE already share imsg_mbox_copy. - * Terminal reply is imsg_mbox_result, only "error" meaningful. - */ +/* RFC 9051 SS6.3.7 SUBSCRIBE and SS6.3.8 UNSUBSCRIBE */ struct imsg_mbox_subscribe { char mailbox[MBOX_NAME_MAX]; }; @@ -1253,66 +718,29 @@ int config_load(const char *, struct openimap_config int cmdline_symset(char *); /* parent.c */ -/* Config file path, threaded from main.c's conffile local, so - * sighup_handler() can re-run config_load() against the same path. - */ __dead void parent_main(const char *, int, char *[], struct openimap_config *); -/* listener.c / auth.c / store.c take no struct openimap_config *, each - * gets exactly the config it needs over its fd-3 channel instead: - * IMSG_LISTENER_SESSION_INIT, IMSG_AUTH_INIT, IMSG_STORE_INIT respectively. - * search_oracle.c needs no config at all -- see its own comment. - */ +/* each role has its own file; its config arrives over fd 3 */ __dead void listener_main(void); __dead void auth_main(void); __dead void store_main(void); __dead void keymgr_main(void); -__dead void search_oracle_main(void); +__dead void parser_main(void); -/* - * search_oracle.c's one entry point into search_cmd.c's otherwise- - * private struct search_parse_ctx, see search_oracle_parse()'s own - * comment (search_cmd.c). Declared here, not in listener.h, on - * purpose: search_oracle.c is a separate role, not part of listener.h's - * listener.c/auth_cmd.c/mailbox_cmd.c/append_cmd.c/fetch_cmd.c/ - * search_cmd.c/store_cmd.c/store_ipc.c family, and pulling that whole - * header in for one prototype is what caused search_oracle.c's own - * file-scope `static struct imsgev iev_parent` to collide with - * listener.h's unrelated `extern struct imsgev iev_parent` (listener's - * own long-lived fd-3 channel) -- same identifier, incompatible - * linkage, a real build failure on premio. search_cmd.c already - * includes this header too, so its own view of the prototype is - * unchanged by the move. - */ -int search_oracle_parse(char *, struct search_node *, uint32_t *, - int *, char *, size_t); - -/* imsg helpers shared by all roles. The "handler" passed to - * imsgev_init() is the libevent callback, expected to run its own - * imsgbuf_read()/imsgbuf_get() loop (parent_dispatch_child() is the - * reference shape), and to end with imsgev_rearm_read(). - */ +/* imsgev.c */ void imsgev_ibuf_init(struct imsgbuf *, int); void imsgev_init(struct imsgev *, int, void (*)(int, short, void *), void *); void imsgev_init_from_ibuf(struct imsgev *, const struct imsgbuf *, void (*)(int, short, void *), void *); -/* You do NOT need to call this after an imsg_compose(). imsgev_init() installs - * imsgev_on_compose() as the channel's imsg close callback, so libutil arms - * EV_WRITE from inside imsg_close() on every queueing path. This is now only - * for the rare caller that must arm a channel it did not just compose on. */ void imsgev_add(struct imsgev *); -/* Same work as imsgev_add(), deliberately under a different name: this is - * the one a dispatch handler MUST call before returning. See its definition - * in imsgev.c for why the two are spelled apart. */ +/* a dispatch handler must call this before returning */ void imsgev_rearm_read(struct imsgev *); -/* Boot-time setup-loop helpers: a freshly exec'd child blocks reading - * fd 3 for zero or more IMSG_SETUP_PEER messages, then IMSG_SETUP_DONE, - * and acks. */ int setup_recv_one_peer(struct imsgbuf *); +int setup_recv_one_peer_id(struct imsgbuf *, uint32_t *); void setup_recv_done_and_ack(struct imsgbuf *); #endif /* IMAPD_H */ blob - 52f95b54c764213581617143650d8138ecb2e94b blob + 4abc10cf7e54c09814b8651cf7a107c6d0ec0c43 --- src/imsgev.c +++ src/imsgev.c @@ -37,12 +37,6 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* - * Shared imsgbuf+event(3) wrapper for parent/listener/auth/store, built on the - * current imsgbuf_*() API (imsg_get() and friends were removed upstream); - * imsgbuf_get()'s 1/0/-1 return is handled exactly as before. - */ - #include #include @@ -52,12 +46,6 @@ #include "imapd.h" #include "log.h" -/* - * Sets imapd-wide imsgbuf settings for every channel: imsgbuf_set_maxsize() - * raises the whole-message limit by IMSG_HEADER_SIZE since its argument is - * payload-only, and imsgbuf_allow_fdpass() is needed since the parent fd-passes - * on these channels at spawn time. - */ void imsgev_ibuf_init(struct imsgbuf *ibuf, int fd) { @@ -68,11 +56,6 @@ imsgev_ibuf_init(struct imsgbuf *ibuf, int fd) imsgbuf_allow_fdpass(ibuf); } -/* - * Arms EV_WRITE via libutil's imsg_close() callback so every queued message - * gets it exactly once; the early return skips re-arming once EV_WRITE is - * already pending mid-batch. - */ static void imsgev_on_compose(struct imsgbuf *ibuf, void *arg) { @@ -98,17 +81,11 @@ imsgev_init(struct imsgev *iev, int fd, void (*handler event_set(&iev->ev, fd, iev->events, iev->handler, iev->data); event_add(&iev->ev, NULL); - /* - * Must follow event_set()/event_add() (callback touches iev->ev) and - * imsgev_ibuf_init() (imsgbuf_init() memset()s the struct); not done - * inside imsgev_ibuf_init() itself since roles call it pre-event-loop - * on fd 3. - */ + /* after event_set() and imsgbuf_init() */ imsgbuf_set_userdata(&iev->ibuf, iev); imsgbuf_set_close_callback(&iev->ibuf, imsgev_on_compose); } -/* like imsgev_init(), but copies an already-init'd *ibuf (no re-init) */ void imsgev_init_from_ibuf(struct imsgev *iev, const struct imsgbuf *ibuf, void (*handler)(int, short, void *), void *data) @@ -128,7 +105,6 @@ imsgev_init_from_ibuf(struct imsgev *iev, const struct imsgbuf_set_close_callback(&iev->ibuf, imsgev_on_compose); } -/* re-arm after imsg_compose(); adds EV_WRITE if output is queued */ void imsgev_add(struct imsgev *iev) { @@ -142,23 +118,16 @@ imsgev_add(struct imsgev *iev) event_add(&iev->ev, NULL); } -/* - * Re-arms EV_READ (which imsgev_init() sets without EV_PERSIST, so it drops - * after firing) at the end of every dispatch handler, delegating to - * imsgev_add() -- kept as a separate name for clarity, not different behavior. - */ +/* EV_READ lacks EV_PERSIST: every handler must re-arm it */ void imsgev_rearm_read(struct imsgev *iev) { imsgev_add(iev); } -/* - * blocks for one IMSG_SETUP_PEER, returns its fd-passed fd; imsgbuf_get() - * checked before imsgbuf_read() to avoid coalesced-message stalls - */ +/* imsgbuf_get() before imsgbuf_read(): replies may coalesce */ int -setup_recv_one_peer(struct imsgbuf *ibuf3) +setup_recv_one_peer_id(struct imsgbuf *ibuf3, uint32_t *id_out) { struct imsg imsg; ssize_t n; @@ -182,14 +151,19 @@ setup_recv_one_peer(struct imsgbuf *ibuf3) if ((fd = imsg_get_fd(&imsg)) == -1) fatalx("setup_recv_one_peer: IMSG_SETUP_PEER carried no fd"); + if (id_out != NULL) + *id_out = imsg_get_id(&imsg); + imsg_free(&imsg); return (fd); } -/* - * blocks for IMSG_SETUP_DONE, then sends one back as an ack (see - * setup_recv_one_peer() re: imsgbuf_get() ordering) - */ +int +setup_recv_one_peer(struct imsgbuf *ibuf3) +{ + return (setup_recv_one_peer_id(ibuf3, NULL)); +} + void setup_recv_done_and_ack(struct imsgbuf *ibuf3) { blob - 294bf25f0d4dfad1fb3c5ff997f48046af9e8abe blob + cf71d95cf2cc0880c154fda71df483e955db9cae --- src/index.c +++ src/index.c @@ -16,7 +16,6 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* index.c: maildir index format -- load/save/append, QRESYNC, vanished-UID. */ #include #include @@ -38,16 +37,8 @@ #include "log.h" #include "store_internal.h" -/* - * Index lines are colon-delimited text, so no field may contain ':', CR, or LF - * -- centralized here since keywords-field callers bypass index_append() and - * hand-build lines. - */ -/* - * RFC 7162 SS7 bounds a mod-sequence to a positive 63-bit integer; values read - * back from the index are bounded the same way the wire-facing parsers already - * are. - */ +/* no ':', CR or LF in any index field */ +/* RFC 7162 SS7 */ #define INDEX_MODSEQ_MAX INT64_MAX int @@ -56,20 +47,12 @@ index_field_valid(const char *field) return (field != NULL && strpbrk(field, ":\r\n") == NULL); } -/* - * A basename read from the index is pasted into paths for - * open(2)/stat(2)/rename(2); unveil(2) only stops it leaving the maildir, so - * load-time enforces the same format rules as the write side. - */ +/* unveil(2) only keeps a path inside the maildir */ int index_basename_valid(const char *basename) { const unsigned char *p; - /* - * Strictly stronger than index_field_valid(): whatever is unsafe to - * write into a line is also unsafe to paste into a path. - */ if (!index_field_valid(basename)) return (0); /* excludes "", ".", "..", and dotfiles in one test */ @@ -82,12 +65,6 @@ index_basename_valid(const char *basename) return (1); } -/* - * Grows idx->lines by doubling (from 16) when it is full; index_load() and - * index_append() carried byte-identical copies of this block, so the growth - * policy and its failure log now live in one place. Returns 0 when there is - * room for one more line, -1 on allocation failure (already logged). - */ static int index_lines_grow(struct mbox_index *idx) { @@ -133,11 +110,7 @@ index_load(int fd, struct mbox_index *idx) } while (fgets(line, sizeof(line), fp) != NULL) { - /* - * fgets(3) silently splits an over-long line; peek at the next - * byte to distinguish a legal max-length line (next byte is - * '\n' or EOF) from an actual split record. - */ + /* fgets(3) splits an over-long line */ if (strchr(line, '\n') == NULL && strlen(line) == sizeof(line) - 1) { int c = fgetc(fp); @@ -164,18 +137,7 @@ index_load(int fd, struct mbox_index *idx) goto fail; } *colon = '\0'; - /* - * Each header field is digits-or-nothing: - * strtoul(3)/strtoull(3) accept leading whitespace and - * a sign, so unguarded input like "-1:1:1" would - * silently parse into a bogus value; same guard used - * elsewhere. The two uint32 fields are range-checked - * before narrowing as well: RFC 9051 SS2.3.1.1 makes - * UIDVALIDITY and UIDNEXT non-zero 32-bit values, and - * "4294967296" would otherwise truncate to 0 and - * "4294967297" to 1, the second handing out UIDs that - * are already in use. - */ + /* strtoul(3) accepts leading space and a sign */ if (line[0] < '0' || line[0] > '9') { log_warnx("session %u: malformed " "UIDVALIDITY: %s", session_id, line); @@ -191,10 +153,7 @@ index_load(int fd, struct mbox_index *idx) } idx->uidvalidity = (uint32_t)parsed; - /* - * RFC 7162: optional third field HIGHESTMODSEQ; NULL - * means older two-field header, defaults to 1 - */ + /* RFC 7162: optional HIGHESTMODSEQ; absent means 1 */ if ((colon2 = strchr(colon + 1, ':')) != NULL) *colon2 = '\0'; @@ -259,17 +218,11 @@ index_load(int fd, struct mbox_index *idx) return (0); fail: - /* - * idx may hold partially-allocated lines here; index_free() is a safe - * no-op, making "-1 means idx is already freed" true for every caller - * including refresh_index(). - */ index_free(idx); fclose(fp); return (-1); } -/* Parses "UID:basename:keywords[:MODSEQ]"; -1 (logged) on corrupt line. */ int index_parse_line(const char *line, struct index_rec *rec) { @@ -279,21 +232,11 @@ index_parse_line(const char *line, struct index_rec *r memset(rec, 0, sizeof(*rec)); - /* - * strtoul(3) accepts leading whitespace and a sign, so an unguarded UID - * field could parse ":x:y:1" as 0 or "-1:x:y:1" as 4294967295; the - * field must be digits-or-nothing. - */ if (line[0] < '0' || line[0] > '9') { log_warnx("session %u: corrupt index line (UID field is not " "a decimal number)", session_id); return (-1); } - /* - * Narrowed only after the range test, the same four-part form the - * UIDVALIDITY floor read uses: a value strtoul(3) accepts but a - * uint32_t cannot hold was truncating, so "4294967296" became UID 0. - */ errno = 0; parsed = strtoul(line, &ep, 10); if (*ep != ':' || errno != 0 || parsed > UINT32_MAX) { @@ -315,12 +258,6 @@ index_parse_line(const char *line, struct index_rec *r } memcpy(rec->basename, p, (size_t)(q - p)); rec->basename[q - p] = '\0'; - /* - * Refuses traversal, hidden names, and control bytes before this - * basename is pasted into open(2)/stat(2)/rename(2) paths, since - * unveil(2) only stops paths leaving the maildir; logged by UID, never - * by the untrusted basename itself. - */ if (!index_basename_valid(rec->basename)) { log_warnx("session %u: refusing index line with unsafe " "basename (UID %u)", session_id, rec->uid); @@ -338,13 +275,6 @@ index_parse_line(const char *line, struct index_rec *r memcpy(rec->keywords, p, (size_t)(r - p)); rec->keywords[r - p] = '\0'; - /* - * Same digit-or-nothing guard as the UID field, now applied to - * MODSEQ: RFC 7162 SS7 bounds it at 9,223,372,036,854,775,807, - * but strtoull(3)'s sign handling would otherwise turn "-1" - * into 18446744073709551615 and leak into - * CHANGEDSINCE/UNCHANGEDSINCE and client-visible MODSEQ. - */ if (r[1] < '0' || r[1] > '9') { log_warnx("session %u: malformed per-message MODSEQ " "in index line: %s", session_id, line); @@ -359,10 +289,7 @@ index_parse_line(const char *line, struct index_rec *r return (-1); } } else { - /* - * no MODSEQ field: pre-CONDSTORE line (index_rec - * backward-compat) - */ + /* a pre-CONDSTORE line */ if (strlcpy(rec->keywords, p, sizeof(rec->keywords)) >= sizeof(rec->keywords)) { log_warnx("session %u: keywords too long in index " @@ -375,10 +302,7 @@ index_parse_line(const char *line, struct index_rec *r 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. - */ +/* "*" is the highest present UID, not uidnext-1 */ uint32_t index_max_uid(struct mbox_index *idx) { @@ -387,26 +311,11 @@ index_max_uid(struct mbox_index *idx) if (idx->nlines == 0) return (0); - /* - * One parser for the UID field, not two: the hand-rolled strtoul(3) - * that used to live here had neither guard and read "-1:name::1" as - * 4294967295. A line index_parse_line() rejects is skipped as a - * message by every walker of idx->lines, so it has no present UID - * for this to return; 0 means "no UIDs in use", as before. - */ if (index_parse_line(idx->lines[idx->nlines - 1], &rec) == -1) return (0); return (rec.uid); } -/* - * Resolves "*" entries in a parsed sequence-set against max (index_max_uid() - * for UID requests, idx->nlines for sequence-number requests); swaps any - * backwards "*"-involving range per RFC 9051 SS9 (since parse_one_seq_range() - * can't), then clamps lo up to 1 and, when clamp_hi is set, hi down to max, - * keeping every range including degenerate ones that seqset_contains() - * correctly treats as unmatchable. - */ uint32_t seqset_resolve(const struct seq_range *ranges, uint32_t nranges, uint32_t max, int clamp_hi, struct seq_range resolved[SEQSET_MAX_RANGES]) @@ -434,7 +343,6 @@ seqset_resolve(const struct seq_range *ranges, uint32_ return (n); } -/* True if val is in any nresolved [lo, hi] pair from seqset_resolve() above. */ int seqset_contains(const struct seq_range *resolved, uint32_t nresolved, uint32_t val) @@ -448,11 +356,6 @@ seqset_contains(const struct seq_range *resolved, uint return (0); } -/* - * Highest hi across all resolved ranges (0 if none), letting an ascending scan - * of idx->lines break early once past it, same as a single-range scan already - * did. - */ uint32_t seqset_max_hi(const struct seq_range *resolved, uint32_t nresolved) { @@ -465,16 +368,7 @@ seqset_max_hi(const struct seq_range *resolved, uint32 return (max); } -/* - * The "does this command apply to this message?" rule for FETCH/STORE/COPY, in - * one place: RFC 9051 SS6.4.9 makes a UID command's sequence-set UID-space and - * a bare one position-space, so the caller passes both and by_uid picks. - * PAST_END is a stop signal, valid only because those three walk idx->lines in - * ascending order -- the compaction loops in - * move_same_mailbox()/handle_mbox_expunge() deliberately don't use this, since - * breaking early would leave the surviving lines they still have to copy down - * unwritten. - */ +/* RFC 9051 SS6.4.9: a UID command's set is in UID space */ enum seqset_pos seqset_position(const struct seq_range *resolved, uint32_t nresolved, uint32_t max_hi, int by_uid, uint32_t uid, uint32_t seqno) @@ -488,7 +382,7 @@ seqset_position(const struct seq_range *resolved, uint return (SEQSET_MATCH); } -/* Reports UID in [lo, hi] absent from idx as VANISHED; RFC 7162 SS3.2.6. */ +/* RFC 7162 SS3.2.6 */ void send_vanished_range(const struct mbox_index *idx, uint32_t lo, uint32_t hi, struct imsgev *iev) @@ -521,11 +415,7 @@ send_vanished_range(const struct mbox_index *idx, uint "IMSG_MBOX_SELECT_VANISHED", session_id); } - /* - * Stop here: a UID of UINT32_MAX would wrap to 0 and make the - * tail check trivially true, emitting a VANISHED range that - * wrongly claims every message in the mailbox is gone. - */ + /* UINT32_MAX would wrap to 0 */ if (rec.uid == UINT32_MAX) return; want = rec.uid + 1; @@ -544,7 +434,6 @@ send_vanished_range(const struct mbox_index *idx, uint } } -/* Linear scan for a basename in the index; O(n), fine at modest size. */ int index_has_basename(struct mbox_index *idx, const char *basename) { @@ -568,31 +457,19 @@ index_has_basename(struct mbox_index *idx, const char return (0); } -/* Appends "UID:basename::MODSEQ"; caller owns uidnext, bumps modseq (SS3.1). */ int index_append(struct mbox_index *idx, uint32_t uid, const char *basename) { char line[STORE_INDEX_LINE_MAX]; int len; - /* - * RFC 9051 SS9 forbids UID 0; with no ceiling on uidnext, exhaustion - * would wrap it to 0 and silently reuse in-use UIDs (forbidden by - * SS2.3.1.1, and breaking index_max_uid()'s ascending assumption), so - * refuse here instead -- the RFC's real fix, changing UIDVALIDITY, - * needs persistent state not yet kept (see index.c review's finding - * #1). - */ + /* RFC 9051 SS9 forbids UID 0; refuse rather than wrap */ if (uid == 0) { log_warnx("session %u: refusing index entry with UID 0 " "(uidnext exhausted or index header corrupt)", session_id); return (-1); } - /* - * 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); @@ -619,10 +496,7 @@ index_append(struct mbox_index *idx, uint32_t uid, con return (0); } -/* - * Rewrites index to STORE_INDEX_TMP_NAME, rename(2)s over STORE_INDEX_NAME so a - * reader never sees a torn file. - */ +/* rename(2), so a reader never sees a torn file */ int index_save(int dfd, const struct mbox_index *idx) { @@ -630,10 +504,7 @@ index_save(int dfd, const struct mbox_index *idx) 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 - */ + /* O_EXCL: never follow a planted symlink */ if (unlinkat(dfd, STORE_INDEX_TMP_NAME, 0) == -1 && errno != ENOENT) { log_warn("session %u: unlink %s", session_id, @@ -712,33 +583,21 @@ index_free(struct mbox_index *idx) 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. - */ +/* RFC 7162 SS3.2.5.1 QRESYNC resync */ void qresync_send_resync(const struct imsg_mbox_select *req, const struct seq_range *ranges, uint32_t nranges, - struct mbox_index *idx, struct imsgev *iev) + struct mbox_index *idx, struct store_session *ss) { + struct imsgev *iev = &ss->iev; struct seq_range resolved[SEQSET_MAX_RANGES]; uint32_t nresolved, max_hi, i; if (req->qresync_has_uids) { - /* - * known-uids is a full RFC 9051 SS9 sequence-set resolved the - * same way as any UID-space consumer; max is unused since "*" - * is already rejected upstream, and clamp_hi is 0 because a - * known UID above the current highest is exactly what RFC 7162 - * SS3.2.5.1 wants reported VANISHED, not dropped. - */ nresolved = seqset_resolve(ranges, nranges, index_max_uid(idx), 0, resolved); } else { - /* - * SS3.2.5.1: no known-uids means "1:", empty if - * uidnext == 1 - */ + /* RFC 7162 SS3.2.5.1: no known-uids means 1:uidnext-1 */ if (idx->uidnext <= 1) return; resolved[0].lo = 1; @@ -747,12 +606,6 @@ qresync_send_resync(const struct imsg_mbox_select *req nresolved = 1; } - /* - * RFC 7162 SS3.2.6 requires VANISHED (EARLIER) precede FETCH; ordering - * is guaranteed by store_ipc.c's session_handle_mbox_selected(), which - * buffers and flushes VANISHED before FETCH, the same two-pass split - * and helper handle_mbox_fetch() uses. - */ for (i = 0; i < nresolved; i++) send_vanished_range(idx, resolved[i].lo, resolved[i].hi, iev); @@ -761,9 +614,7 @@ qresync_send_resync(const struct imsg_mbox_select *req struct index_rec rec; if (index_parse_line(idx->lines[i], &rec) == -1) - /* - * corrupt line, already logged, not reported either way - */ + /* corrupt line, already logged */ continue; if (rec.uid > max_hi) break; @@ -779,8 +630,9 @@ qresync_send_resync(const struct imsg_mbox_select *req meta.seqno = i + 1; meta.uid = rec.uid; meta.modseq = rec.modseq; - if (locate_message_file(mailbox_dir_fd, rec.basename, - &size, suffix, sizeof(suffix)) == 0) { + if (locate_message_file(&ss->cur_snap, + ss->mailbox_dir_fd, rec.basename, &size, suffix, + sizeof(suffix)) == 0) { build_flags_string(suffix, rec.keywords, meta.flags, sizeof(meta.flags)); } @@ -793,18 +645,7 @@ qresync_send_resync(const struct imsg_mbox_select *req } } -/* - * Takes the index lock (LOCK_EX/LOCK_SH) on STORE_INDEX_LOCK_NAME's stable - * inode before opening the index, so the descriptor can't refer to an inode a - * concurrent index_save() already renamed away; release with the idempotent - * index_lock_release(). - * - * Returns 0 holding the lock, or -1 having taken nothing. A caller that - * added LOCK_NB can also get 1, meaning another process holds it: an - * ordinary answer rather than a failure, so it is not logged, and every - * blocking caller is unaffected because flock(2) cannot report EWOULDBLOCK - * without LOCK_NB (flock(2), sys/kern/kern_descrip.c). - */ +/* lock first, then open, so the fd names the current inode */ int index_lock_acquire(int dfd, struct index_lock *il, int op) { @@ -839,10 +680,6 @@ index_lock_acquire(int dfd, struct index_lock *il, int return (0); } -/* - * Drops whatever index_lock_acquire() took; safe to call twice, and safe on an - * INDEX_LOCK_INIT struct that was never acquired. - */ void index_lock_release(struct index_lock *il) { @@ -858,15 +695,7 @@ index_lock_release(struct index_lock *il) } } -/* - * Issues and records a new UIDVALIDITY: RFC 9051 SS2.3.1.1 requires it strictly - * increase, so the timestamp is only a floor -- the value returned is - * max(clock, last-issued+1), the high-water mark is persisted at the maildir - * root (STORE_UIDVALIDITY_NAME, since per-mailbox state is gone after DELETE), - * and every failure degrades to a bare timestamp except an - * unparseable-but-present file, which is left untouched rather than overwritten - * with a lower floor. - */ +/* RFC 9051 SS2.3.1.1: it must increase, so time is only a floor */ uint32_t uidvalidity_next(void) { @@ -880,7 +709,6 @@ uidvalidity_next(void) ssize_t n; int fd, writeback = 1; - /* one file per account, at the maildir root */ path = STORE_UIDVALIDITY_NAME; now = time(NULL); @@ -899,11 +727,6 @@ uidvalidity_next(void) return (val != 0 ? val : 1); } - /* - * A zero-length file is the ordinary just-created case (floor 0 is - * correct); anything present but unreadable is damage and is left - * alone. - */ if (fstat(fd, &st) == 0 && st.st_size > 0) { if ((n = read(fd, buf, sizeof(buf) - 1)) <= 0) { log_warn("session %u: read %s (UIDVALIDITY floor)", @@ -912,11 +735,6 @@ uidvalidity_next(void) } else { buf[n] = '\0'; buf[strcspn(buf, "\r\n")] = '\0'; - /* - * same digit guard as the index header: strtoul(3) - * accepts a leading sign, so "-1" would read as - * 4294967295 and pin the floor at its ceiling - */ errno = 0; parsed = strtoul(buf, &ep, 10); if (buf[0] < '0' || buf[0] > '9' || *ep != '\0' || @@ -934,10 +752,6 @@ uidvalidity_next(void) if (writeback) { if (val <= floor) { if (floor == UINT32_MAX) { - /* - * 4 billion issued, or clock past 2106: nothing - * greater representable. - */ log_warnx("session %u: UIDVALIDITY floor is " "exhausted (%u); reusing it", session_id, floor); @@ -960,11 +774,6 @@ uidvalidity_next(void) "may be issued again", session_id, path); } - /* - * A successful floor consultation is otherwise silent, and a - * quietly-wrong UIDVALIDITY looks identical to a correct one to the - * client; -v logs what was read and what was issued. - */ log_debug("session %u: UIDVALIDITY: floor %u in %s -> issued %u%s", session_id, floor, path, val, writeback ? "" : " (floor NOT updated)"); @@ -973,14 +782,6 @@ uidvalidity_next(void) return (val); } -/* - * Walks new/ for undiscovered maildir deliveries: mutate==0 only answers "is - * there at least one?" without touching idx or the filesystem (safe under a - * shared lock, used by the frequent IDLE poll); mutate==1 indexes everything - * found and needs the exclusive lock. Returns 1 (found/added), 0, or -1 on - * error -- on error with mutate set, idx is already index_free()'d, per - * refresh_index()'s contract. - */ static int index_scan_new(int dfd, struct mbox_index *idx, int mutate) { @@ -1007,22 +808,10 @@ index_scan_new(int dfd, struct mbox_index *idx, int mu } while ((de = readdir(dp)) != NULL) { if (de->d_name[0] == '.') - /* - * ".", "..", and dotfiles -- maildir delivery never - * creates the latter - */ + /* ".", "..", and dotfiles */ continue; - /* - * never index a filename with ':' or newline, corrupts index - * line format - */ + /* ':' or a newline would corrupt the index line */ if (strpbrk(de->d_name, ":\r\n") != NULL) { - /* - * Logged only on the mutating pass -- the read-only - * pass runs every poll interval for an IDLE's whole - * life, and a badly-named file would otherwise fill the - * log forever. - */ if (mutate) log_warnx("session %u: skipping new/ file " "with unsafe name (contains ':' or " @@ -1033,7 +822,6 @@ index_scan_new(int dfd, struct mbox_index *idx, int mu continue; if (!mutate) { closedir(dp); - /* one is enough to answer the question */ return (1); } if (index_append(idx, idx->uidnext, de->d_name) == -1) { @@ -1048,10 +836,6 @@ index_scan_new(int dfd, struct mbox_index *idx, int mu return (added); } -/* - * 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(int dfd, struct mbox_index *idx, int fd) { @@ -1063,11 +847,7 @@ refresh_index(int dfd, struct mbox_index *idx, int fd) if ((added = index_scan_new(dfd, idx, 1)) == -1) return (-1); /* index_scan_new() has already freed idx */ - /* - * Skip index_save()'s cost on a no-op refresh, unless the header itself - * is new -- a freshly invented UIDVALIDITY that's never written down - * would just be invented again, differently, next call. - */ + /* a fresh UIDVALIDITY must still be written down */ if (!added && !idx->fresh) return (0); @@ -1078,60 +858,25 @@ refresh_index(int dfd, struct mbox_index *idx, int fd) return (0); } -/* - * Cheap change probe: two stat(2) calls (no lock, no read) on "." (moved by - * every index_save()-based mutation: APPEND/STORE/EXPUNGE/COPY/MOVE) and "new" - * (touched by an external MTA delivery before anything indexes it); sampled - * before the caller's work so a change is never missed, at the cost of one - * harmless extra refresh, modulo theoretical same-nanosecond races. - */ -static struct { - int valid; - ino_t dir_ino; - ino_t new_ino; - struct timespec dir_mtim; - struct timespec new_mtim; -} idle_probe; - -/* Forces next probe to report a change; call whenever cwd changes mailbox. */ void -idle_probe_reset(void) +idle_probe_reset(struct store_session *ss) { - idle_probe.valid = 0; + ss->idle_probe.valid = 0; } -/* - * The UID list this session was last told about, ascending, and what an IDLE - * refresh diffs against. It lives here rather than in the listener so that a - * change to one message costs one imsg instead of one per message in the - * mailbox, and one walk instead of a rescan per message. About 4 bytes per - * message. - */ -static struct { - uint32_t *uids; - size_t n; - uint64_t modseq; /* highest reported; above it is news */ - int valid; -} idle_baseline; - -/* Forces the next refresh to seed rather than diff; pairs with the above. */ void -idle_baseline_reset(void) +idle_baseline_reset(struct store_session *ss) { - free(idle_baseline.uids); - idle_baseline.uids = NULL; - idle_baseline.n = 0; - idle_baseline.modseq = 0; - idle_baseline.valid = 0; + struct store_idle_baseline *base = &ss->idle_baseline; + + free(base->uids); + base->uids = NULL; + base->n = 0; + base->modseq = 0; + base->valid = 0; } -/* - * One untagged EXPUNGE per UID that went away, in order; returns how many. - * - * RFC 9051 SS7.5.1: each EXPUNGE decrements the sequence numbers above it, so - * seqno counts only messages still present. Both lists are UID-ascending (see - * index_max_uid()), so one walk does it. - */ +/* RFC 9051 SS7.5.1: each EXPUNGE renumbers those above it */ static size_t idle_send_expunges(const uint32_t *old, size_t oldn, const uint32_t *cur, size_t curn, struct imsgev *iev) @@ -1159,20 +904,12 @@ idle_send_expunges(const uint32_t *old, size_t oldn, c return (gone); } -/* - * One untagged FETCH per message whose mod-sequence passed what was last - * reported; returns how many. RFC 9051 SS6.3.13 lists flag changes among - * what IDLE reports and requires an unsolicited FETCH to carry a UID item. - * - * Only messages the client already knows about: one that arrived since the - * last refresh is not in old, and EXISTS is all it gets. Flags live in the - * message file's name, so each one reported costs a lookup, which is why - * this walks the changed messages and not the mailbox. - */ +/* RFC 9051 SS6.3.13: flag changes, as FETCH with UID */ static size_t idle_send_flag_fetches(const struct mbox_index *idx, const uint32_t *old, - size_t oldn, uint64_t since, struct imsgev *iev) + size_t oldn, uint64_t since, struct store_session *ss) { + struct imsgev *iev = &ss->iev; struct imsg_mbox_fetch_meta meta; struct index_rec rec; char suffix[64]; @@ -1189,8 +926,8 @@ idle_send_flag_fetches(const struct mbox_index *idx, c j++; if (j == oldn || old[j] != rec.uid) continue; /* arrived since; EXISTS covers it */ - if (locate_message_file(mailbox_dir_fd, rec.basename, &size, - suffix, sizeof(suffix)) == -1) { + if (locate_message_file(&ss->cur_snap, ss->mailbox_dir_fd, + rec.basename, &size, suffix, sizeof(suffix)) == -1) { log_warnx("session %u: message %s (uid %u) indexed " "but missing on disk, no IDLE flag push", session_id, rec.basename, rec.uid); @@ -1217,44 +954,36 @@ tspec_eq(const struct timespec *a, const struct timesp return (a->tv_sec == b->tv_sec && a->tv_nsec == b->tv_nsec); } -/* 1 = nothing can have changed since the last call; 0 = look properly. */ static int -idle_probe_unchanged(void) +idle_probe_unchanged(struct store_session *ss) { - struct stat dst, nst; - int same; + struct store_idle_probe *probe = &ss->idle_probe; + struct stat dst, nst; + int same; - if (fstatat(mailbox_dir_fd, ".", &dst, 0) == -1) { - /* - * Cannot tell, so do not claim to know: fall through to the - * full refresh, which will report the failure properly. - */ - idle_probe.valid = 0; + if (fstatat(ss->mailbox_dir_fd, ".", &dst, 0) == -1) { + probe->valid = 0; return (0); } - if (fstatat(mailbox_dir_fd, "new", &nst, 0) == -1) + if (fstatat(ss->mailbox_dir_fd, "new", &nst, 0) == -1) /* absent new/ is a stable state */ memset(&nst, 0, sizeof(nst)); - same = idle_probe.valid && - dst.st_ino == idle_probe.dir_ino && - nst.st_ino == idle_probe.new_ino && - tspec_eq(&dst.st_mtim, &idle_probe.dir_mtim) && - tspec_eq(&nst.st_mtim, &idle_probe.new_mtim); + same = probe->valid && + dst.st_ino == probe->dir_ino && + nst.st_ino == probe->new_ino && + tspec_eq(&dst.st_mtim, &probe->dir_mtim) && + tspec_eq(&nst.st_mtim, &probe->new_mtim); - idle_probe.valid = 1; - idle_probe.dir_ino = dst.st_ino; - idle_probe.new_ino = nst.st_ino; - idle_probe.dir_mtim = dst.st_mtim; - idle_probe.new_mtim = nst.st_mtim; + probe->valid = 1; + probe->dir_ino = dst.st_ino; + probe->new_ino = nst.st_ino; + probe->dir_mtim = dst.st_mtim; + probe->new_mtim = nst.st_mtim; return (same); } -/* - * The UIDs an IDLE baseline records, in index order, and the mod-sequence - * to diff from next time. Returns -1, having allocated nothing, on failure. - */ static int idle_uid_list(const struct mbox_index *idx, uint32_t **listp, size_t *np, uint64_t *modseqp) @@ -1264,11 +993,6 @@ idle_uid_list(const struct mbox_index *idx, uint32_t * uint64_t modseq = 0; size_t i, n = 0; - /* - * nlines + 1 so that an empty mailbox still asks for a nonzero - * allocation, which keeps a NULL return meaning failure and nothing - * else. - */ if ((list = reallocarray(NULL, idx->nlines + 1, sizeof(*list))) == NULL) { log_warn("session %u: idle refresh: reallocarray", session_id); @@ -1276,21 +1000,12 @@ idle_uid_list(const struct mbox_index *idx, uint32_t * } for (i = 0; i < idx->nlines; i++) { if (index_parse_line(idx->lines[i], &rec) == -1) - /* skip malformed line, don't fail the whole request */ continue; list[n++] = rec.uid; if (rec.modseq > modseq) modseq = rec.modseq; } - /* - * The header's HIGHESTMODSEQ is what a client is told, but a - * per-message value above it would then never be reported again, so - * take whichever is greater as the mark for next time. A - * pre-CONDSTORE index line parses with modseq defaulted to 1, as - * index_parse_line does, which a header of 0 would otherwise make - * look like a change on every refresh. - */ if (idx->highestmodseq > modseq) modseq = idx->highestmodseq; @@ -1300,23 +1015,20 @@ idle_uid_list(const struct mbox_index *idx, uint32_t * return (0); } -/* - * Seeds the IDLE baseline from the committed index without its lock, for - * an IDLE that found the lock busy. index_save() only ever replaces the - * index whole, by rename(2), so this reads one committed version, never a - * torn one. Deliveries still in new/ are left for a later refresh to - * index and report. Returns 0 seeded, with the count in reply, or -1. - */ +/* lockless: index_save() only ever replaces the index by rename(2) */ static int -idle_seed_unlocked(struct imsg_mbox_idle_refreshed *reply) +idle_seed_unlocked(struct store_session *ss, + struct imsg_mbox_idle_refreshed *reply) { - struct mbox_index idx; - uint32_t *list; - uint64_t modseq; - size_t n; - int fd; + struct store_idle_baseline *base = &ss->idle_baseline; + struct mbox_index idx; + uint32_t *list; + uint64_t modseq; + size_t n; + int fd; - if ((fd = openat(mailbox_dir_fd, STORE_INDEX_NAME, O_RDONLY)) == -1) { + if ((fd = openat(ss->mailbox_dir_fd, STORE_INDEX_NAME, + O_RDONLY)) == -1) { if (errno != ENOENT) log_warn("session %u: open %s", session_id, STORE_INDEX_NAME); @@ -1333,20 +1045,21 @@ idle_seed_unlocked(struct imsg_mbox_idle_refreshed *re } index_free(&idx); - free(idle_baseline.uids); - idle_baseline.uids = list; - idle_baseline.n = n; - idle_baseline.modseq = modseq; - idle_baseline.valid = 1; + free(base->uids); + base->uids = list; + base->n = n; + base->modseq = modseq; + base->valid = 1; reply->exists = (uint32_t)n; return (0); } -/* RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9; re-checked vs listener.c (privsep). */ void handle_mbox_idle_refresh(const struct imsg_mbox_idle_refresh *req, - struct imsgev *iev) + struct store_session *ss) { + struct store_idle_baseline *base = &ss->idle_baseline; + struct imsgev *iev = &ss->iev; struct mbox_index idx; struct imsg_mbox_idle_refreshed reply; struct index_lock il = INDEX_LOCK_INIT; @@ -1357,33 +1070,17 @@ handle_mbox_idle_refresh(const struct imsg_mbox_idle_r memset(&reply, 0, sizeof(reply)); /* a seed adopts what it finds rather than reporting it */ - seeded = req->seed || !idle_baseline.valid; + seeded = req->seed || !base->valid; - /* - * Cheapest question first: on an untouched mailbox this is the whole - * job, with no lock and no index read, which matters since the poll - * runs every few seconds. - */ - if (idle_probe_unchanged()) { + if (idle_probe_unchanged(ss)) { reply.ok = 1; reply.unchanged = 1; - /* - * The poll mechanism is otherwise silent and a dead one is - * indistinguishable from a healthy one (as the cross-session - * push bug showed); at -v these lines make each poll and any - * real work observable. - */ log_debug("session %u: idle refresh: unchanged (probe: no " "change to . or new/)", session_id); goto send; } - /* - * Something moved, so the index must be read; take the shared lock - * since reading alone covers the common case, and escalate only when - * new/ actually holds a delivery to index. - */ - locked = index_lock_acquire(mailbox_dir_fd, &il, LOCK_SH | LOCK_NB); + locked = index_lock_acquire(ss->mailbox_dir_fd, &il, LOCK_SH | LOCK_NB); if (locked == 1) goto busy; if (locked == -1) @@ -1392,60 +1089,45 @@ handle_mbox_idle_refresh(const struct imsg_mbox_idle_r index_lock_release(&il); goto send; } - if ((pending = index_scan_new(mailbox_dir_fd, &idx, 0)) == -1) { + if ((pending = index_scan_new(ss->mailbox_dir_fd, &idx, 0)) == -1) { index_free(&idx); index_lock_release(&il); goto send; } - /* - * idx.fresh joins pending here: index_load() just invented a - * UIDVALIDITY for a header-less mailbox, and persisting it needs the - * exclusive lock just as indexing a delivery does. - */ if (pending || idx.fresh) { - /* - * Deliberately drop the shared lock and redo everything under - * LOCK_EX rather than upgrading in place -- flock(2) has no - * atomic upgrade, so another process could slip in between - * states and invalidate what was read under the shared lock. - */ + /* relock LOCK_EX from scratch rather than upgrade */ index_free(&idx); index_lock_release(&il); - locked = index_lock_acquire(mailbox_dir_fd, &il, + locked = index_lock_acquire(ss->mailbox_dir_fd, &il, LOCK_EX | LOCK_NB); if (locked == 1) goto busy; if (locked == -1) goto send; - if (refresh_index(mailbox_dir_fd, &idx, il.fd) == -1) { + if (refresh_index(ss->mailbox_dir_fd, &idx, il.fd) == -1) { index_lock_release(&il); goto send; } } if (idle_uid_list(&idx, &newlist, &newn, &seen_modseq) == -1) { - /* - * reply.ok stays 0, so the listener keeps what it last told - * the client and this poll simply reports nothing; the - * baseline here is untouched for the same reason. - */ index_free(&idx); index_lock_release(&il); goto send; } if (!seeded) { - gone = idle_send_expunges(idle_baseline.uids, idle_baseline.n, - newlist, newn, iev); - changed = idle_send_flag_fetches(&idx, idle_baseline.uids, - idle_baseline.n, idle_baseline.modseq, iev); - reply.exists_changed = newn != idle_baseline.n; + gone = idle_send_expunges(base->uids, base->n, newlist, newn, + iev); + changed = idle_send_flag_fetches(&idx, base->uids, base->n, + base->modseq, ss); + reply.exists_changed = newn != base->n; } - free(idle_baseline.uids); - idle_baseline.uids = newlist; - idle_baseline.n = newn; - idle_baseline.modseq = seen_modseq; - idle_baseline.valid = 1; + free(base->uids); + base->uids = newlist; + base->n = newn; + base->modseq = seen_modseq; + base->valid = 1; reply.ok = 1; reply.exists = (uint32_t)newn; @@ -1461,35 +1143,23 @@ handle_mbox_idle_refresh(const struct imsg_mbox_idle_r goto send; busy: - /* - * Another session holds the lock. A poll skips, and send: below makes - * the next one look again. A seed cannot skip: the baseline left from - * the last IDLE predates the client's own commands since, and diffing - * against it would resend EXPUNGEs the client has already had (RFC - * 9051 SS7.5.1). So a seed reads the committed index instead, and if - * it cannot, drops the baseline so that the next refresh seeds. - */ + /* RFC 9051 SS7.5.1: a seed cannot skip, or EXPUNGEs repeat */ reply.busy = 1; - if (seeded && idle_seed_unlocked(&reply) == 0) { + if (seeded && idle_seed_unlocked(ss, &reply) == 0) { reply.ok = 1; /* so the next poll reads the index, not the probe */ - idle_probe_reset(); + idle_probe_reset(ss); } else if (seeded) { - idle_baseline_reset(); + idle_baseline_reset(ss); } log_debug("session %u: idle refresh: index lock busy, %s", session_id, !seeded ? "skipped this poll" : reply.ok ? "seeded without it" : "next refresh seeds"); send: - /* - * A refresh that gives up has already consumed the change: - * idle_probe_unchanged() records the new mtimes whatever its caller - * does next, so without this the next poll reports "unchanged" for - * something the client was never told. - */ + /* the probe has already consumed this change */ if (!reply.ok) - idle_probe_reset(); + idle_probe_reset(ss); if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_REFRESHED, 0, 0, -1, &reply, sizeof(reply)) == -1) blob - e97c389fb7ab36d090a992f9435cd362e94a1bd5 blob + 2c1312d3accc2cbb04903f56af503731f6550e94 --- src/keymgr.c +++ src/keymgr.c @@ -42,13 +42,6 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* - * keymgr.c: holds the real TLS private key for listener.c's fake-key/imsg - * forwarding; boot-time plumbing failures are fatal, but content failures - * (bad cert/key) and per-request failures degrade gracefully, keeping the - * process alive with no usable key rather than crashing. - */ - #include #include @@ -72,19 +65,9 @@ #include "imapd.h" #include "log.h" -/* - * Matches parent.c's read buffer size (8192), kept as a separate local constant - * rather than a shared imapd.h macro since nothing else needs to agree on the - * exact value. - */ #define KEYMGR_CERT_MAX 8192 #define KEYMGR_KEY_MAX 8192 -/* - * keymgr serves every live connection's listener-worker via its own peer - * entry, wired in by IMSG_SETUP_PEER and torn down on channel close; named - * so keymgr_dispatch_parent() doesn't need a forward declaration. - */ struct keymgr_peer { uint32_t session_id; struct imsgev iev; @@ -94,34 +77,17 @@ TAILQ_HEAD(keymgr_peer_list, keymgr_peer); static struct keymgr_peer_list keymgr_peers = TAILQ_HEAD_INITIALIZER(keymgr_peers); -/* fd 3, alive for the process's lifetime */ static struct imsgev iev_parent; -/* - * SIGHUP reload staging; keymgr_dispatch_parent() fires once both flags are set - * -- same pattern listener.c used for its own now-removed cert/key reload - * gating. - */ static char reload_cert_buf[KEYMGR_CERT_MAX]; static char reload_key_buf[KEYMGR_KEY_MAX]; static size_t reload_cert_len, reload_key_len; static int reload_got_cert, reload_got_key; -/* - * The currently-loaded real key and its libtls-compatible pubkey hash; - * NULL/empty iff no usable key has ever loaded successfully. - */ static EVP_PKEY *keymgr_pkey; static char keymgr_hash[KEYMGR_HASH_MAX]; -/* - * The explicit permission gate: true once the boot-time cert+key pair has - * been processed at all (even if it was rejected as unusable) -- distinct from - * keymgr_pkey being non-NULL, which tracks whether a *usable* key is currently - * loaded. A signing request arriving before this is set is refused outright, - * not merely "refused because no key is loaded yet", so the gate is checkable - * on its own rather than an incidental side effect of message ordering. - */ +/* set once the boot pair was processed, even if rejected */ static int keymgr_got_init; static void keymgr_key_free(void); @@ -151,19 +117,9 @@ keymgr_main(void) size_t cert_len = 0, key_len = 0; int got_cert = 0, got_key = 0; - /* - * fd-passing is allowed on this channel for the fd-passed - * IMSG_SETUP_PEER peer fds; see imsgev_ibuf_init()'s own comment - */ imsgev_ibuf_init(&ibuf3, 3); - /* - * Both IMSG_TLS_CERT and IMSG_KEYMGR_INIT must be read before the peer - * handshake so keymgr_got_init/keymgr_pkey are final before any signing - * request can arrive; sent as two imsgs (mirroring parent.c's - * send_tls_cert()/send_keymgr_key() split) rather than one, to stay - * comfortably under MAX_IMSGSIZE. - */ + /* the key is final before any peer can ask for a signature */ while (!got_cert || !got_key) { if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1) fatal("imsgbuf_get"); @@ -211,7 +167,7 @@ keymgr_main(void) imsg_free(&imsg); } - /* Content failure here is not fatal, see this file's header comment. */ + /* a bad key is not fatal: its operations fail instead */ if (cert_len == 0 || key_len == 0) log_warnx("no usable TLS cert/key at boot, TLS " "private-key operations will fail until the next " @@ -223,19 +179,11 @@ keymgr_main(void) explicit_bzero(key_buf, sizeof(key_buf)); keymgr_got_init = 1; - /* - * keymgr's own daemon-user identity: a dedicated account, following - * imapd's per-role convention (_imapd for listener, _imapauth for auth) - * over smtpd's literal SMTPD_USER reuse. - */ if ((pw = getpwnam("_imapkey")) == NULL) fatalx("getpwnam _imapkey: no such user " "(expected, not yet provisioned by an install script)"); - /* - * No filesystem access needed at all -- the key arrives over imsg from - * parent, never touches disk in this process. - */ + /* the key arrives over imsg; nothing is read from disk */ if (chroot("/var/empty") == -1) fatal("chroot /var/empty"); if (chdir("/") == -1) @@ -248,12 +196,6 @@ keymgr_main(void) setproctitle("keymgr"); - /* - * keymgr stays the one boot-time, daemon-lifetime child, but no peer is - * wired to it at boot -- parent.c sends only IMSG_SETUP_DONE; every - * listener-worker peer arrives later over this same channel via - * IMSG_SETUP_PEER, handled below. - */ setup_recv_done_and_ack(&ibuf3); event_init(); @@ -262,12 +204,7 @@ keymgr_main(void) NULL); #ifdef __OpenBSD__ - /* - * recvfd only: keymgr keeps receiving peer fds via IMSG_SETUP_PEER for - * its whole life but never sends one (only parent attaches descriptors - * to imsgs, and keymgr_reply() composes with fd == -1); no rpath either - * since keymgr touches no filesystem. - */ + /* recvfd only: peers arrive, nothing is sent */ if (pledge("stdio recvfd", NULL) == -1) fatal("pledge"); #endif @@ -276,21 +213,11 @@ keymgr_main(void) fatalx("exited event loop"); } -/* - * Replicates smtpd's ssl.c hash_x509() byte-for-byte: SHA256 of the cert's DER - * SubjectPublicKeyInfo, formatted "SHA256:" plus lowercase hex -- the exact - * format is load-bearing (see file header), not cosmetic. - */ +/* as smtpd's ssl.c hash_x509() */ static int keymgr_pubkey_hash(X509 *cert, char *hash, size_t hashlen) { static const char hex[] = "0123456789abcdef"; - /* - * Uses unsigned char/unsigned int, not smtpd hash_x509()'s signed - * types, to match X509_pubkey_digest(3)'s prototype and avoid - * -Wpointer-sign warnings; the emitted string is unchanged since - * digest[i] is already unsigned. - */ unsigned char digest[EVP_MAX_MD_SIZE]; size_t off; unsigned int dlen, i; @@ -309,8 +236,6 @@ keymgr_pubkey_hash(X509 *cert, char *hash, size_t hash return (0); } -/* Frees the loaded key; EVP_PKEY_free wipes it via BN_free */ -/* unnecessary -- freed pages are zeroed -- but right on every exit path */ static void keymgr_key_free(void) { @@ -320,12 +245,7 @@ keymgr_key_free(void) } } -/* - * Parses a new cert+key pair fully before replacing the live one, so a - * malformed SIGHUP reload leaves the last known-good key in place instead of - * none; not sourced from smtpd's ca.c reload logic. cert_buf/key_buf aren't - * retained past this call -- callers must scrub key_buf themselves. - */ +/* a bad reload leaves the last good key in place */ static int keymgr_load(const char *cert_buf, size_t cert_len, const char *key_buf, size_t key_len) @@ -363,12 +283,7 @@ keymgr_load(const char *cert_buf, size_t cert_len, con goto fail; } - /* - * A cert and key can each parse fine yet not correspond to each other - * (e.g. a rotation that replaced only one) -- checked here via - * X509_check_private_key() before swapping, since the per-request hash - * check elsewhere can't catch a cert/key mismatch. - */ + /* a cert and key may each parse yet not match */ if (X509_check_private_key(cert, pkey) != 1) { log_warnx("certificate and private key do not match, not " "(re)loading"); @@ -379,11 +294,7 @@ keymgr_load(const char *cert_buf, size_t cert_len, con keymgr_key_free(); keymgr_pkey = pkey; pkey = NULL; - /* - * strlcpy, not memcpy of sizeof(): keymgr_pubkey_hash() only writes 72 - * of KEYMGR_HASH_MAX's 80 bytes, and memcpy would drag uninitialised - * stack bytes into a static. - */ + /* strlcpy: the hash does not fill the buffer */ (void)strlcpy(keymgr_hash, hash, sizeof(keymgr_hash)); log_info("TLS key loaded (%s)", keymgr_hash); @@ -405,13 +316,6 @@ fail: return (-1); } -/* - * Commits a SIGHUP reload once BOTH halves have arrived: IMSG_TLS_CERT and - * IMSG_KEYMGR_INIT can land in either order, so both cases call this and only - * the second one finds the pair complete. The key buffer is scrubbed whether or - * not the load succeeded, and both flags clear so the next reload starts from a - * clean pair rather than half of this one. - */ static void keymgr_try_reload(void) { @@ -425,14 +329,6 @@ keymgr_try_reload(void) reload_got_cert = reload_got_key = 0; } -/* - * PARENT channel (fd 3): SIGHUP reload's IMSG_TLS_CERT/IMSG_KEYMGR_INIT pair - * (same paired-flags shape listener.c used for its own now-removed cert/key - * reload gating), plus IMSG_SETUP_PEER wiring in a fresh listener-worker's peer - * (one per parent.c's spawn_connection() call, imsg_get_id() carries - * session_id -- see listener.c's own IMSG_SETUP_PEER (store) case for the same - * pattern). - */ static void keymgr_dispatch_parent(int fd, short event, void *arg) { @@ -448,15 +344,6 @@ keymgr_dispatch_parent(int fd, short event, void *arg) if ((n = imsgbuf_read(&iev->ibuf)) == -1) fatal("imsgbuf_read"); if (n == 0) { - /* - * Parent gone means this process is done: it used to - * linger serving existing peers, but keymgr is only - * ever consulted during a TLS handshake, so that only - * kept a key-holding process alive for as long as any - * client held a connection open; log_warnx (an operator - * should notice) and exit(0) (not a failure, just - * following the parent's death) rather than fatalx(). - */ log_warnx("parent closed channel, exiting"); keymgr_key_free(); exit(0); @@ -546,11 +433,6 @@ keymgr_dispatch_parent(int fd, short event, void *arg) (void)fd; } -/* - * LISTENER channel: handles the three signing/decrypt request types; - * arg is the owning struct keymgr_peer (not a bare imsgev) so the EOF - * path knows which of possibly many live peers just went away. - */ static void keymgr_dispatch_listener(int fd, short event, void *arg) { @@ -559,14 +441,7 @@ keymgr_dispatch_listener(int fd, short event, void *ar struct imsg imsg; ssize_t n; - /* - * A transport failure on one listener-worker's channel drops only that - * peer, not the whole process -- fatal()ing here would turn one - * connection's worker dying into a daemon-wide TLS outage, since keymgr - * is never restarted by parent.c's reap_child(); - * keymgr_dispatch_parent() (the fd-3 channel) stays strict since losing - * the parent leaves keymgr with no future. - */ + /* a transport failure drops this peer only */ if (event & EV_WRITE) { if (imsgbuf_write(&iev->ibuf) == -1) { log_warnx("session %u: write error on listener " @@ -616,12 +491,6 @@ keymgr_dispatch_listener(int fd, short event, void *ar (void)fd; } -/* - * Drops one listener-worker peer: unregisters its event, closes and clears its - * channel, unlinks and frees it -- shared by every exit path in - * keymgr_dispatch_listener() so EOF and transport error give the same outcome; - * imsgbuf_clear() is required or imsgbuf_init()'s allocation leaks. - */ static void keymgr_peer_teardown(struct keymgr_peer *kp) { @@ -632,13 +501,6 @@ keymgr_peer_teardown(struct keymgr_peer *kp) free(kp); } -/* - * Bound-checked read of this imsg's trailing raw bytes into a caller-supplied - * fixed buffer; same "fixed header + trailing raw bytes on one imsg" shape as - * store.c's recv_trailing_array()/imapd.h's imsg_mbox_append, without the - * malloc since KEYMGR_DATA_MAX is a small fixed cap rather than - * message-dependent. - */ static int keymgr_recv_trailing(struct imsg *imsg, uint32_t len, unsigned char *buf, size_t bufsize) @@ -660,12 +522,7 @@ keymgr_recv_trailing(struct imsg *imsg, uint32_t len, return (1); } -/* - * Composes a struct imsg_keymgr_sign_reply plus its trailing output bytes, - * reusing the SAME imsg type as the request (correlated by id) -- matches - * ca_imsg()'s own convention of replying on imsg->hdr.type rather than a - * distinct reply type. - */ +/* the reply reuses the request's type, correlated by id */ static void keymgr_reply(struct imsgev *iev, uint32_t type, uint32_t id, int ok, const void *to, size_t tolen) @@ -674,13 +531,6 @@ keymgr_reply(struct imsgev *iev, uint32_t type, uint32 unsigned char combined[sizeof(rep) + KEYMGR_DATA_MAX]; - /* - * Every caller already bounds its own result, but combined[] is a fixed - * stack buffer holding the private key's output, so this function - * bound-checks independently rather than relying on that discipline - * holding forever; an oversized result is reported as a failed - * operation. - */ if (ok && tolen > KEYMGR_DATA_MAX) { log_warnx("keymgr_reply: %zu-byte result exceeds " "KEYMGR_DATA_MAX (%d), refusing", tolen, @@ -700,19 +550,11 @@ keymgr_reply(struct imsgev *iev, uint32_t type, uint32 sizeof(rep) + (ok ? tolen : 0)) == -1) log_warn("imsg_compose reply"); - /* - * imsg_compose() has copied the reply; this buffer may hold a decrypted - * premaster secret (on RSA_PRIVDEC), so it's scrubbed here like every - * other key-material buffer in this file. - */ + /* may hold a decrypted premaster secret */ explicit_bzero(combined, sizeof(combined)); } -/* - * IMSG_KEYMGR_RSA_PRIVENC / IMSG_KEYMGR_RSA_PRIVDEC: mirrors ca_imsg()'s - * RSA_private_encrypt()/RSA_private_decrypt() dispatch (ca.c:216-253), on - * imapd's own single-key state rather than ca.c's hash-keyed dict. - */ +/* as smtpd's ca.c ca_imsg() */ static void keymgr_handle_rsa(struct imsgev *iev, struct imsg *imsg, uint32_t type, uint32_t id) @@ -730,10 +572,7 @@ keymgr_handle_rsa(struct imsgev *iev, struct imsg *ims keymgr_reply(iev, type, id, 0, NULL, 0); return; } - /* - * imsg_get_buf() guarantees size, not NUL termination, force it (same - * reasoning as auth.c's inbound username/password fields). - */ + /* imsg_get_buf() does not NUL-terminate */ req.hash[sizeof(req.hash) - 1] = '\0'; if (!keymgr_recv_trailing(imsg, req.fromlen, from, sizeof(from))) { @@ -782,14 +621,11 @@ keymgr_handle_rsa(struct imsgev *iev, struct imsg *ims return; } keymgr_reply(iev, type, id, 1, to, (size_t)ret); - /* - * RSA_PRIVDEC's output is the session's decrypted premaster secret; - * don't leave it on this process's stack. - */ + /* the decrypted premaster secret */ explicit_bzero(to, sizeof(to)); } -/* IMSG_KEYMGR_ECDSA_SIGN: mirrors ca_imsg()'s ECDSA_sign() (ca.c:255-279). */ +/* as smtpd's ca.c ca_imsg() */ static void keymgr_handle_ecdsa(struct imsgev *iev, struct imsg *imsg, uint32_t id) { blob - 1b39f7831b03ae04c5fdd26c4b5eeee105463eb1 blob + a44a4d1a1971a77be423a9b5bb97ba7fe47a958c --- src/listener.c +++ src/listener.c @@ -31,11 +31,6 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* - * listener.c, protocol/network process: client sockets, IMAP - * dispatch, TLS. Real TLS private-key operations are forwarded to - * keymgr(8); see keymgr_engine_init() below. - */ #include #include @@ -74,32 +69,16 @@ struct session_list sessions = TAILQ_HEAD_INITIALIZER(sessions); -/* - * Channel to the AUTH process; .ibuf.fd == -1 if this connection's - * auth-worker spawn failed, checked by auth_cmd.c's - * sasl_plain_finish() before sending IMSG_AUTH_REQUEST. - */ struct imsgev iev_auth; -/* - * Channel to the search-oracle process; .ibuf.fd == -1 if - * spawn failed, checked by search_cmd.c's search_dispatch() before - * sending IMSG_SEARCH_PARSE_REQUEST. - */ -struct imsgev iev_search; -struct imsgev iev_parent; /* fd 3, alive for the process's lifetime */ +struct imsgev iev_parent; -/* - * Channel to the keymgr process: private-key ops are forwarded here - * synchronously from OpenSSL callbacks, never via imsgev dispatch, so - * it's a plain struct imsgbuf; see - * keymgr_forward_rsa()/keymgr_forward_ecdsa(). - */ +static int auth_granted; + +/* keymgr: read synchronously from OpenSSL callbacks, never dispatched */ static struct imsgbuf keymgr_ibuf; static struct tls_config *listener_tls_config; -/* NULL if TLS setup failed, no-TLS fallback */ struct tls *listener_tls_ctx; -/* set from imsg */ uint32_t listener_idle_poll_secs = IDLE_POLL_DEFAULT; uint32_t listener_login_grace_secs = LOGIN_GRACE_DEFAULT; uint64_t listener_append_max = APPEND_MAX_DEFAULT; @@ -109,7 +88,7 @@ uint64_t listener_append_max = APPEND_MAX_DEFAULT; struct imap_cmd_entry { const char *name; - unsigned int states; /* bitmask of 1U << SESSION_* */ + unsigned int states; int (*handler)(struct session *, const char *, char *); }; @@ -119,7 +98,7 @@ struct imap_cmd_entry { (1U << SESSION_SELECTING) | (1U << SESSION_SELECTED) | \ (1U << SESSION_FETCHING) | (1U << SESSION_STORING) | \ (1U << SESSION_EXPUNGING) | (1U << SESSION_APPENDING) | \ - (1U << SESSION_SEARCH_PARSING) | (1U << SESSION_SEARCHING) | \ + (1U << SESSION_SEARCHING) | \ (1U << SESSION_STATUSING) | \ (1U << SESSION_COPYING) | (1U << SESSION_CREATING) | \ (1U << SESSION_DELETING) | (1U << SESSION_RENAMING) | \ @@ -169,19 +148,9 @@ static const struct imap_cmd_entry imap_cmds[] = { }; #define NUM_IMAP_CMDS (sizeof(imap_cmds) / sizeof(imap_cmds[0])) -/* - * tls_config_use_fake_private_key() is an internal, undeclared libtls - * symbol forward-declared here, same as smtpd's smtp.c does. Being - * an internal symbol, it can change or vanish without notice. - */ +/* internal to libtls, declared as smtpd/smtp.c does */ void tls_config_use_fake_private_key(struct tls_config *); -/* - * Installs libtls's placeholder private key plus the real certificate - * (smtp.c:187-193's call shape) in one function so listener_main()'s - * tls_config-building if/else-if chains need only one call per - * branch. - */ static int keymgr_set_fake_keypair(struct tls_config *config, const char *cert_buf, size_t cert_len) @@ -191,24 +160,13 @@ keymgr_set_fake_keypair(struct tls_config *config, con cert_len, NULL, 0); } -/* - * RSA/ECDSA privsep engine, installed once process-wide: intercepts - * every private-key operation OpenSSL performs against this - * process's fake key and forwards it to keymgr; adapted from smtpd's - * ca.c (ca.c:289-558) with imapd's own imsg framing. - */ +/* private-key operations go to keymgr, after smtpd's ca.c */ static const RSA_METHOD *keymgr_rsa_default; static RSA_METHOD *keymgr_rsae_method; static const EC_KEY_METHOD *keymgr_ecdsa_default; static EC_KEY_METHOD *keymgr_ecdsae_method; -/* - * Blocks reading keymgr_ibuf directly from inside an OpenSSL - * RSA_METHOD callback; unlike ca.c's rsae_send_imsg(), nothing else - * is ever multiplexed on this channel so there's no need to hand off - * unrelated imsgs. - */ static int keymgr_forward_rsa(uint32_t type, const char *hash, const unsigned char *from, int fromlen, unsigned char *to, size_t tosize, int padding) @@ -275,12 +233,7 @@ keymgr_forward_rsa(uint32_t type, const char *hash, co imsg_free(&imsg); break; } - /* - * Bound by tosize (OpenSSL's actual output buffer, e.g. 256 - * bytes for a 2048-bit key), not by the larger - * KEYMGR_DATA_MAX wire cap, or an oversized reply could - * overrun it; mirrors ca.c's own RSA_size() bound. - */ + /* bounded by OpenSSL's buffer, tosize */ if (rep.ok && rep.tolen <= tosize && imsg_get_len(&imsg) == rep.tolen) { if (imsg_get_buf(&imsg, to, rep.tolen) == -1) @@ -380,45 +333,24 @@ keymgr_forward_ecdsa(const char *hash, const unsigned return (sig); } -/* - * Runs checks A, B and C1, each described at its own test below, - * before any key op is forwarded to keymgr; fatalx(), not a log - * line, since a failure here means privilege separation isn't - * actually in effect. - */ +/* a failure means privilege separation is not in effect */ static void keymgr_assert_fake_key(const char *hash, const BIGNUM *priv, const char *op) { size_t i; - /* - * Check A: a public-key-only object from - * tls_config_use_fake_private_key() never has d/priv_key set, - * so a non-NULL priv here means libtls is no longer using the - * placeholder key. - */ + /* check A: the placeholder key has no private part */ if (priv != NULL) fatalx("%s: key object carries a private component -- " "libtls is no longer using a placeholder key, and this " "process is not separated from the TLS private key", op); - /* - * Check B: unlike smtpd's ca.c, listener configures exactly one - * keypair and never reaches this callback for an unrelated - * key, so a missing pubkey-hash tag is itself the regression, - * not a benign case to fall through on. - */ + /* check B: one keypair, so a missing tag is a regression */ if (hash == NULL) fatalx("%s: no pubkey-hash tag on the key object -- libtls's " "ex_data slot 0 tagging has changed", op); - /* - * Check C1, done before the tag is read as a string: - * strlcpy(3) has no bound once the destination is full, so - * this bounded loop (not memchr/strnlen) confirms the tag is - * NUL-terminated within KEYMGR_HASH_MAX bytes before anything - * trusts it. - */ + /* check C1: NUL-terminated within KEYMGR_HASH_MAX */ for (i = 0; i < KEYMGR_HASH_MAX; i++) if (hash[i] == '\0') return; @@ -454,10 +386,6 @@ keymgr_ecdsa_do_sign(const unsigned char *dgst, int dg { const char *hash = EC_KEY_get_ex_data(eckey, 0); - /* - * inv/rp: ECDSA_sign_setup() precomputation, unused; keymgr does the - * op. - */ (void)inv; (void)rp; @@ -508,12 +436,6 @@ keymgr_ecdsa_engine_init(void) EC_KEY_set_default_method(keymgr_ecdsae_method); } -/* - * Installs both engine overrides; call exactly once, before any - * tls_config touches a key -- listener_main() calls this right - * before its TLS setup block, mirroring ca_engine_init()'s call from - * smtpd's dispatcher() (dispatcher.c:135). - */ static void keymgr_engine_init(void) { @@ -521,7 +443,6 @@ keymgr_engine_init(void) keymgr_ecdsa_engine_init(); } -/* Builds/starts this one session; forward-declared for listener_main(). */ static void listener_start_session(uint32_t, int, int, const struct sockaddr_storage *, socklen_t); @@ -532,7 +453,6 @@ listener_main(void) struct passwd *pw; int auth_peer_fd = -1; int keymgr_peer_fd = -1; - int search_peer_fd = -1; struct imsg imsg; struct imsg_listener_session_init sinit; ssize_t n; @@ -544,19 +464,8 @@ listener_main(void) memset(&sinit, 0, sizeof(sinit)); - /* - * fd-passing allowed here: receives fd-passed peer/session - * messages below; see imsgev_ibuf_init(). - */ imsgev_ibuf_init(&ibuf3, 3); - /* - * This process is spawned fresh per connection, so the - * peer handshake is drained in this same synchronous loop - * rather than separate blocking calls; the auth peer may never - * arrive, and IMSG_SETUP_PEER's id (0 vs session_id) tells - * auth from keymgr, matching parent.c's setup_peer_send(). - */ while (!got_cert || !got_session_init || !got_keymgr_peer) { if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1) fatal("imsgbuf_get"); @@ -578,13 +487,6 @@ listener_main(void) "carried no fd"); break; } - /* - * A repeat can't happen today, but this runs - * pre-pledge/pre-privdrop where trusting the parent - * matters most, so state the invariant rather than - * silently overwrite; imsg_get_fd(3) already handed - * us peer_fd, so the duplicate must be closed here. - */ if (id == 0) { if (auth_peer_fd != -1) { log_warnx("listener: duplicate auth " @@ -605,26 +507,6 @@ listener_main(void) } break; } - case IMSG_SETUP_SEARCH_PEER: { - int peer_fd = imsg_get_fd(&imsg); - - /* - * Optional, like the auth peer above -- not - * gated by the while() condition, spawn_connection() - * may not have wired one at all - */ - if (peer_fd == -1) - log_warnx("listener: IMSG_SETUP_SEARCH_PEER " - "carried no fd"); - else if (search_peer_fd != -1) { - /* already claimed above, so close it here */ - log_warnx("listener: duplicate " - "IMSG_SETUP_SEARCH_PEER, ignoring"); - close(peer_fd); - } else - search_peer_fd = peer_fd; - break; - } case IMSG_TLS_CERT: cert_len = imsg_get_len(&imsg); if (cert_len > sizeof(cert_buf)) { @@ -645,12 +527,6 @@ listener_main(void) log_warnx("bad IMSG_LISTENER_SESSION_INIT"); break; } - /* - * Refuse before claiming, unlike the peer cases - * above: an unclaimed fd on this imsg is closed by - * imsg_free() below, so there's nothing to clean up - * by hand. - */ if (client_fd != -1) { log_warnx("listener: duplicate " "IMSG_LISTENER_SESSION_INIT, ignoring"); @@ -686,16 +562,9 @@ listener_main(void) setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1) fatal("cannot drop privileges to _imapd"); - /* - * Installs RSA_METHOD/EC_KEY_METHOD override before tls_config touches - * key. - */ keymgr_engine_init(); - /* - * Failure isn't fatal; degrades to no-TLS, checked via - * listener_tls_ctx. - */ + /* no TLS rather than no daemon */ if (cert_len == 0) { log_warnx("listener: no TLS cert received, TLS " "disabled for this session"); @@ -737,12 +606,6 @@ listener_main(void) event_init(); - /* - * auth_peer_fd may be -1 (no auth-worker spawned); iev_auth. - * ibuf.fd is left at -1 rather than defaulting to fd 0, and - * auth_cmd.c's sasl_plain_finish() checks that before composing - * to it. - */ if (auth_peer_fd != -1) imsgev_init(&iev_auth, auth_peer_fd, listener_dispatch_auth, NULL); @@ -753,41 +616,14 @@ listener_main(void) "connection gets one", sinit.session_id); } - /* - * search_peer_fd may be -1 (no search-oracle spawned, - * independent of auth's fork); iev_search.ibuf.fd is left at - * -1, checked by search_cmd.c's search_dispatch() before - * composing to it, mirroring iev_auth above. - */ - if (search_peer_fd != -1) - imsgev_init(&iev_search, search_peer_fd, - listener_dispatch_search, - NULL); - else { - iev_search.ibuf.fd = -1; - log_warnx("session %u: no search-oracle was spawned for this " - "connection, SEARCH will fail until a new connection gets " - "one", sinit.session_id); - } - if (imsgbuf_init(&keymgr_ibuf, keymgr_peer_fd) == -1) fatal("imsgbuf_init keymgr"); imsgbuf_set_maxsize(&keymgr_ibuf, MAX_IMSGSIZE); - /* - * Reuses fd 3's populated ibuf; imsgbuf_init() would drop buffered - * bytes. - */ + /* keeps fd 3's buffered bytes */ imsgev_init_from_ibuf(&iev_parent, &ibuf3, listener_dispatch_parent, NULL); - /* - * This process's one and only session, built from what boot - * just drained; must run after event_init() and the TLS setup - * above since session_tls_start()/session_arm_client_read() - * register libevent events needing listener_tls_ctx already - * set. - */ listener_idle_poll_secs = sinit.idle_poll_secs; listener_login_grace_secs = sinit.login_grace_secs; listener_append_max = sinit.append_max; @@ -795,14 +631,7 @@ listener_main(void) listener_start_session(sinit.session_id, client_fd, sinit.implicit_tls, &sinit.remote_ss, sinit.remote_sslen); - /* - * pledge(2) promises: no socket/connect/bind/listen/accept call - * remains here (parent.c owns them), so "inet" is - * dropped since getnameinfo(3) below only formats - * already-numeric bytes; "recvfd" stays for the store child's - * peer fd arriving later; "sendfd" goes since this process - * never attaches a descriptor to an imsg. - */ + /* no "inet": getnameinfo(3) only formats numeric bytes */ #ifdef __OpenBSD__ if (pledge("stdio recvfd", NULL) == -1) fatal("pledge"); @@ -812,16 +641,6 @@ listener_main(void) fatalx("listener: exited event loop"); } -/* - * A connection that completes TCP and then says nothing held a - * listener-worker, an auth-worker and a search-oracle for ever, and at - * MaxStartups "full" that refuses every later connection. RFC 9051 SS5.4 - * permits a shortened pre-authentication timer for exactly this; its 30 - * minute floor governs a post-authentication autologout, which this server - * does not have. sshd's LoginGraceTime and smtpd's SMTPD_SESSION_TIMEOUT - * are the base-system analogues, and both time out in the process holding - * the client descriptor, as this does. - */ static void session_login_grace_expired(int fd, short event, void *arg) { @@ -835,11 +654,6 @@ session_login_grace_expired(int fd, short event, void session_teardown(s, "login-grace"); } -/* - * Covers every pre-authentication stall, not just a missing command: an - * implicit-TLS connection that never sends a ClientHello never reaches the - * command path at all, so a timeout armed on one event would miss it. - */ void session_login_grace_init(struct session *s) { @@ -863,13 +677,6 @@ session_login_grace_disarm(struct session *s) evtimer_del(&s->grace_ev); } -/* - * Builds and starts this process's one and only session from the - * IMSG_LISTENER_SESSION_INIT payload drained at boot, doing - * what the old accept()-driven listener_accept() did: build struct - * session, format remote_addr, log, then begin the TLS handshake or - * send the plaintext greeting. - */ static void listener_start_session(uint32_t session_id, int client_fd, int implicit_tls, const struct sockaddr_storage *ss, socklen_t sslen) @@ -880,12 +687,6 @@ listener_start_session(uint32_t session_id, int client if (s == NULL) { log_warn("calloc"); close(client_fd); - /* - * Exit directly rather than return: nothing else will - * ever run in this process, and returning would park it - * in event_dispatch() forever holding a MaxStartups slot - * with no client and no session to tear down. - */ exit(1); } s->pending_body_fd = -1; /* calloc(3)'s 0 is a real descriptor */ @@ -895,7 +696,6 @@ listener_start_session(uint32_t session_id, int client s->state = SESSION_NOT_AUTH; s->implicit_tls = implicit_tls; session_login_grace_init(s); - /* CLOCK_MONOTONIC; see connected_at in listener.h. */ if (clock_gettime(CLOCK_MONOTONIC, &s->connected_at) == -1) log_warn("session %u: clock_gettime", s->id); TAILQ_INSERT_TAIL(&sessions, s, entry); @@ -903,12 +703,7 @@ listener_start_session(uint32_t session_id, int client { char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV]; - /* - * NI_NUMERIC*: pure formatting of already-numeric address - * bytes, no resolver or network I/O -- the fact - * listener_main()'s pledge() comment rests dropping - * "inet" on. - */ + /* numeric: no resolver or network I/O */ if (getnameinfo((const struct sockaddr *)ss, sslen, hbuf, sizeof(hbuf), sbuf, sizeof(sbuf), NI_NUMERICHOST | NI_NUMERICSERV) == 0) @@ -944,7 +739,6 @@ listener_start_session(uint32_t session_id, int client session_send_greeting(s); } -/* (Re-)registers client_ev for steady-state reads; guards re-registration. */ void session_arm_client_read(struct session *s) { @@ -956,7 +750,6 @@ session_arm_client_read(struct session *s) s->client_ev_added = 1; } -/* Creates per-conn struct tls (non-blocking), arms client_ev to drive it. */ void session_tls_start(struct session *s) { @@ -976,7 +769,6 @@ session_tls_start(struct session *s) s->client_ev_added = 1; } -/* Drives non-blocking TLS handshake, re-arms client_ev for wanted direction. */ void session_tls_handshake(int fd, short event, void *arg) { @@ -1028,19 +820,12 @@ session_send_greeting(struct session *s) session_write(s, greeting, sizeof(greeting) - 1); } -/* Forward decls: 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 *); /* RFC 9051 SS4.3 hard cap on a non-synchronizing literal. */ #define IMAP_NONSYNC_LITERAL_MAX 4096 -/* - * True if `line` ends in a non-synchronizing literal announcement - * "{n+}" (RFC 9051 SS4.3), whose octets are already in flight and - * must be accounted for regardless of the command's fate; octet - * count returned in *lenp. - */ static int line_nonsync_literal(const char *line, uint64_t *lenp) { @@ -1053,7 +838,7 @@ line_nonsync_literal(const char *line, uint64_t *lenp) len = strlen(line); if (len < 4 || line[len - 1] != '}' || line[len - 2] != '+') return (0); - stop = &line[len - 2]; /* one past the last digit */ + stop = &line[len - 2]; if ((open = memrchr(line, '{', len)) == NULL || open + 1 >= stop) return (0); open++; @@ -1072,11 +857,7 @@ line_nonsync_literal(const char *line, uint64_t *lenp) return (1); } -/* - * RFC 9051 SS9: tag = 1*; previously only - * length was checked, so a tag of "+" could turn session_reply()'s - * own reply into a command continuation request. - */ +/* RFC 9051 SS9: a tag may not contain "+" */ static int tag_is_valid(const char *tag) { @@ -1093,24 +874,17 @@ tag_is_valid(const char *tag) return (1); } -/* Splits s->inbuf into CRLF lines (bare LF isn't one, SS2.2); may free *s*. */ void session_dispatch_client(int fd, short event, void *arg) { struct session *s = arg; ssize_t n; char *crlf; - /* - * Bytes offered to tls_read(); re-armed at the end of this - * function -- named tls_want, not want, to avoid shadowing the - * literal-assembly loop's own uint64_t want (-Wshadow). - */ size_t tls_want = 0; (void)event; if (s->write_failed) { - /* A prior session_write() couldn't finish; see listener.h. */ session_teardown(s, "io-error"); return; } @@ -1119,10 +893,7 @@ session_dispatch_client(int fd, short event, void *arg tls_want = sizeof(s->inbuf) - s->inbuflen; n = tls_read(s->tls_ctx, s->inbuf + s->inbuflen, tls_want); if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT) { - /* - * tls_read() can want to write (renegotiation); re-arm - * for what it needs. - */ + /* tls_read() may want to write */ event_del(&s->client_ev); event_set(&s->client_ev, s->client_fd, (n == TLS_WANT_POLLIN) ? EV_READ : EV_WRITE, @@ -1136,16 +907,11 @@ session_dispatch_client(int fd, short event, void *arg session_teardown(s, "tls-error"); return; } - /* restore EV_READ|EV_PERSIST; harmless if OK */ session_arm_client_read(s); } else { n = read(fd, s->inbuf + s->inbuflen, sizeof(s->inbuf) - s->inbuflen); if (n == -1) { - /* - * client_fd is O_NONBLOCK since parent.c's - * parent_accept(). - */ if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) return; @@ -1167,12 +933,7 @@ session_dispatch_client(int fd, short event, void *arg size_t consumed, linelen; int alive; - /* - * Octets of a refused non-synchronizing literal (bad syntax, - * over cap, session busy, or not APPEND) are already on the - * wire, so swallow them instead of parsing the message body - * as further IMAP commands. - */ + /* a refused non-synchronizing literal's octets are in flight */ if (s->literal_discard > 0) { uint64_t take; @@ -1186,13 +947,6 @@ session_dispatch_client(int fd, short event, void *arg } if (s->literal_discard > 0) break; /* need more data */ - /* - * Swallow the announcing command line's trailing CRLF - * so the line parser doesn't see a spurious - * zero-length line and answer "* BAD Empty command - * line" after every refused literal; anything besides - * CRLF is left for the parser. - */ if (s->inbuflen >= 2 && s->inbuf[0] == '\r' && s->inbuf[1] == '\n') { memmove(s->inbuf, s->inbuf + 2, @@ -1202,10 +956,7 @@ session_dispatch_client(int fd, short event, void *arg continue; } - /* - * RFC 9051 SS4.3 literal in flight; checked before CRLF, may - * contain one. - */ + /* RFC 9051 SS4.3 literal; may contain a CRLF */ if (s->literal_pending) { uint64_t want, take; @@ -1214,7 +965,6 @@ session_dispatch_client(int fd, short event, void *arg (uint64_t)s->inbuflen : want; if (take > 0) { - /* on to the store; take <= SESSION_INBUF_MAX */ if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_APPEND_DATA, 0, 0, -1, s->inbuf, (size_t)take) == -1) { @@ -1232,12 +982,8 @@ session_dispatch_client(int fd, short event, void *arg if (s->literal_remaining > 0) break; /* need more data */ - /* - * Literal body received; `command` still needs its CRLF - * (RFC 9051 SS9). - */ if (s->inbuflen < 2) - break; /* trailing CRLF hasn't arrived yet */ + break; if (s->inbuf[0] != '\r' || s->inbuf[1] != '\n') { /* no reliable resync point, give up */ log_warnx("session %u: expected CRLF after " @@ -1262,13 +1008,7 @@ session_dispatch_client(int fd, short event, void *arg linelen = (size_t)(crlf - s->inbuf); consumed = linelen + 2; - /* - * RFC 9051 SS2.2/SS9: CR/LF only appear as the CRLF - * terminator and NUL isn't an ASTRING-CHAR; this is the one - * chokepoint enforcing that before client text gets echoed - * back or silently truncated by the C-string parsers, so a - * violation closes the connection. - */ + /* RFC 9051 SS2.2/SS9: no CR, LF or NUL inside a line */ if (memchr(s->inbuf, '\r', linelen) != NULL || memchr(s->inbuf, '\n', linelen) != NULL || memchr(s->inbuf, '\0', linelen) != NULL) { @@ -1284,12 +1024,6 @@ session_dispatch_client(int fd, short event, void *arg *crlf = '\0'; - /* - * Note a trailing "{n+}" before dispatch since its octets - * follow immediately on the wire; over RFC 9051 SS4.3's - * 4096-octet cap the client is already out of spec and we - * can't guess how much to skip, so close instead. - */ nonsync_len = 0; if (line_nonsync_literal(s->inbuf, &nonsync_len) && nonsync_len > IMAP_NONSYNC_LITERAL_MAX) { @@ -1305,10 +1039,6 @@ session_dispatch_client(int fd, short event, void *arg return; } - /* - * SASL continuation, IDLE's DONE route around tag/name/args - * parser/queue. - */ if (s->auth_cont) { /* the line is the user's base64 password, see below */ s->scrub_inbuf = 1; @@ -1316,14 +1046,7 @@ session_dispatch_client(int fd, short event, void *arg } else if (s->idling) { alive = session_handle_idle_continuation(s, s->inbuf); } else if (session_is_busy(s)) { - /* - * Queue rather than reject a command while one is - * already in flight (RFC 9051 SS5.5 pipelining), except - * one carrying a non-synchronizing literal: its octets - * are arriving now but cmd_append() wouldn't enter - * literal-read mode until dequeued, so refuse and - * swallow instead. - */ + /* RFC 9051 SS5.5: queue while one is in flight */ if (nonsync_len > 0) { session_reply(s, "*", "BAD", "non-synchronizing literal not accepted on " @@ -1348,24 +1071,14 @@ session_dispatch_client(int fd, short event, void *arg if (alive == 0) return; /* s was torn down (LOGOUT), do not touch */ - /* - * Anything cmd_append() didn't take (literal_pending) is - * swallowed here. - */ if (nonsync_len > 0 && !s->literal_pending) s->literal_discard = nonsync_len; - /* - * cmd_starttls() zeroes inbuflen to discard plaintext; clamps - * underflow. - */ + /* cmd_starttls() zeroes inbuflen */ if (consumed > s->inbuflen) consumed = s->inbuflen; - /* - * Don't leave a SASL response (base64 password) in a long-lived - * buffer. - */ + /* don't leave a base64 password in inbuf */ if (s->scrub_inbuf) { explicit_bzero(s->inbuf, consumed); s->scrub_inbuf = 0; @@ -1376,41 +1089,22 @@ session_dispatch_client(int fd, short event, void *arg } if (s->inbuflen == sizeof(s->inbuf)) { - /* - * Buffer full, no CRLF; matches RFC 9051 SS7.1.3's example - * text. - */ + /* 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); - /* - * was a raw write(2): wrong on a TLS session, bytes would land - * unencrypted - */ session_write(s, bad, sizeof(bad) - 1); session_teardown(s, "limit-exceeded"); return; } - /* - * A TLS record can hold more than inbuf's SESSION_INBUF_MAX, - * and level-triggered EV_READ won't refire for bytes libtls is - * still holding, so a full read re-queues this callback via - * event_active() (ncalls=1) to drain the rest; this terminates - * once tls_read() returns TLS_WANT_POLLIN. - */ + /* libtls may hold bytes that EV_READ will not report */ if (s->tls_active && n > 0 && (size_t)n == tls_want && s->inbuflen < sizeof(s->inbuf)) event_active(&s->client_ev, EV_READ, 1); } -/* - * Blocking write(2)/tls_write(): retries EAGAIN/TLS_WANT_POLL* via - * poll(2) up to SESSION_WRITE_POLL_TIMEOUT_MS; on error or timeout it - * only records the failure in s->write_failed rather than tearing s - * down itself -- see listener.h and session_dispatch_client(). - */ #define SESSION_WRITE_POLL_TIMEOUT_MS 5000 void @@ -1421,13 +1115,6 @@ session_write(struct session *s, const char *buf, size if (s->write_failed) return; - /* - * Permanent outbound-traffic diagnostic, gated on - * log_getverbose() since building/scrubbing dbuf is real work - * otherwise done on every write; session_write() carries every - * outbound byte including literal FETCH payloads, so -v -v is a - * message-content-exposure decision, not just a logging knob. - */ if (log_getverbose() > 0) { char dbuf[301]; size_t dlen = len < sizeof(dbuf) - 1 ? len : sizeof(dbuf) - 1; @@ -1443,7 +1130,7 @@ session_write(struct session *s, const char *buf, size } if (!s->tls_active) { - while (sent < len) { /* retry EINTR/EAGAIN via poll */ + while (sent < len) { ssize_t n; struct pollfd pfd; @@ -1509,17 +1196,7 @@ session_write(struct session *s, const char *buf, size } -/* - * Formats one complete response line into a 512-byte buffer and - * writes it. The one thing this must never do is emit a line the - * client cannot frame, so on overflow it forces the last two bytes - * back to CRLF rather than send a truncated, unterminated line -- - * that fixup is the whole reason session_reply() and - * session_untagged() are two lines each instead of fifteen: they - * differed only in their format string, and this is everything else - * they had in common. fuzz/fuzz_session_reply.c exists to hold - * exactly this invariant and carries its own stub copy of all three. - */ +/* on overflow the line still ends in CRLF */ static void session_writef(struct session *, const char *, ...) __attribute__((__format__ (printf, 2, 3))); @@ -1558,13 +1235,6 @@ session_untagged(struct session *s, const char *text) session_writef(s, "* %s\r\n", text); } -/* - * Shared client-composing send for every "fixed request struct + N - * elemsize-sized trailing elements" imsg to s->store_iev, plus the - * degenerate no-trailing-array form CREATE/DELETE/RENAME/LIST/STATUS - * use; malloc failure and imsg_compose failure are both reported - * back; the caller replies NO and restores s->state. - */ int send_mbox_request(struct session *s, int imsg_type, const char *what, const char *imsgname, const void *req, size_t reqlen, const void *elems, @@ -1573,13 +1243,6 @@ send_mbox_request(struct session *s, int imsg_type, co size_t bodylen = (size_t)nelems * elemsize; char *combined; - /* - * Nothing trailing: compose the caller's request where it - * already sits rather than malloc a copy of it just to hand it - * straight to imsg_compose(). Reached by the fixed-size commands, - * and by SELECT/EXPUNGE whenever their sequence-set is - * legitimately empty. - */ if (bodylen == 0) { if (imsg_compose(&s->store_iev->ibuf, imsg_type, 0, 0, -1, req, reqlen) == -1) { @@ -1597,14 +1260,6 @@ send_mbox_request(struct session *s, int imsg_type, co memcpy(combined, req, reqlen); memcpy(combined + reqlen, elems, bodylen); - /* - * Report a compose failure via return value instead of just - * logging it: previously s->state stayed at the busy value with - * nothing in flight, so session_is_busy() blocked forever - * waiting for a reply that would never be sent; every caller - * already handles a 0 return by replying NO and restoring - * state. - */ if (imsg_compose(&s->store_iev->ibuf, imsg_type, 0, 0, -1, combined, reqlen + bodylen) == -1) { log_warn("session %u: imsg_compose %s", s->id, imsgname); @@ -1615,7 +1270,6 @@ send_mbox_request(struct session *s, int imsg_type, co return (1); } -/* RFC 7162 SS3.1: marks CONDSTORE-aware; emits unsolicited HIGHESTMODSEQ. */ void session_condstore_enable(struct session *s) { @@ -1632,7 +1286,6 @@ session_condstore_enable(struct session *s) } } -/* Splits a CRLF-stripped line into tag/name/args (RFC 9051 `tag SP ...`). */ int parse_command_line(char *line, char **tag, char **name, char **args) { @@ -1676,11 +1329,6 @@ parse_command_line(char *line, char **tag, char **name return (0); } -/* - * 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) { @@ -1690,7 +1338,6 @@ session_is_busy(const struct session *s) case SESSION_STORING: case SESSION_EXPUNGING: case SESSION_APPENDING: - case SESSION_SEARCH_PARSING: case SESSION_SEARCHING: case SESSION_STATUSING: case SESSION_COPYING: @@ -1704,7 +1351,6 @@ session_is_busy(const struct session *s) } } -/* Appends a line to s->cmd_queue; 0 on a full queue or strdup(3) failure. */ static int session_enqueue_cmd(struct session *s, const char *line) { @@ -1722,7 +1368,6 @@ session_enqueue_cmd(struct session *s, const char *lin return (1); } -/* Dispatches queued commands while idle; returns 0 if one tore *s* down. */ int session_dequeue_next(struct session *s) { @@ -1743,7 +1388,7 @@ session_dequeue_next(struct session *s) return (1); } -/* Returns 1 if alive, 0 if torn down; caller must not touch *s* if 0. */ +/* returns 0 if s was torn down */ int session_handle_line(struct session *s, char *line) { @@ -1756,12 +1401,7 @@ session_handle_line(struct session *s, char *line) return (1); } - /* - * AUTHENTICATE's optional initial response is the base64 of the - * user's cleartext password (RFC 4616 SS2), and LOGIN sends it - * in the clear when LOGINDISABLED is ignored, so log the - * command but never its arguments. - */ + /* RFC 4616 SS2: the initial response holds the password */ if (name != NULL && (strcasecmp(name, "AUTHENTICATE") == 0 || strcasecmp(name, "LOGIN") == 0)) log_debug("session %u: <<< %s %s ", s->id, tag, @@ -1771,15 +1411,11 @@ session_handle_line(struct session *s, char *line) name != NULL ? " " : "", name != NULL ? name : "", args != NULL ? " " : "", args != NULL ? args : ""); - /* - * Checked once here, not per strlcpy(3) site: tag must not echo - * truncated. - */ if (strlen(tag) >= IMAP_TAG_MAX) { session_reply(s, "*", "BAD", "Tag too long"); return (1); } - /* Replied to with "*", never with the tag: see tag_is_valid(). */ + /* answered with "*", never the tag */ if (!tag_is_valid(tag)) { session_reply(s, "*", "BAD", "Invalid tag"); return (1); @@ -1798,10 +1434,7 @@ session_handle_line(struct session *s, char *line) return (1); } if (!(imap_cmds[i].states & (1U << s->state))) { - /* - * RFC 9051 SS3: BAD or NO for wrong-state command, BAD chosen - * here. - */ + /* RFC 9051 SS3 allows BAD or NO */ session_reply(s, tag, "BAD", "Command not permitted in this state"); return (1); @@ -1817,10 +1450,6 @@ listener_dispatch_auth(int fd, short event, void *arg) struct imsg imsg; ssize_t n; - /* - * Without this, a queued imsg_compose() never flushes; EV_WRITE - * busy-loops. - */ if (event & EV_WRITE) { if (imsgbuf_write(&iev->ibuf) == -1) fatal("imsgbuf_write"); @@ -1830,15 +1459,11 @@ listener_dispatch_auth(int fd, short event, void *arg) if ((n = imsgbuf_read(&iev->ibuf)) == -1) fatal("imsgbuf_read"); if (n == 0) { - /* - * This session's auth-worker may die - * independently of the session (already - * authenticated, or unable to AUTHENTICATE again); - * close and mark it dead rather than just event_del(), - * or a later AUTHENTICATE would compose onto a dead fd - * and the client would hang waiting for a reply. - */ - log_warnx("auth-worker closed channel"); + /* the auth-worker exits after its grant */ + if (auth_granted) + log_debug("auth-worker exited after the login"); + else + log_warnx("auth-worker closed channel"); event_del(&iev->ev); close(iev->ibuf.fd); imsgbuf_clear(&iev->ibuf); @@ -1877,6 +1502,7 @@ listener_dispatch_auth(int fd, short event, void *arg) break; } + auth_granted = 1; s->state = SESSION_STORE_PENDING; setproctitle("session %u [authenticated]", s->id); break; @@ -1892,168 +1518,13 @@ listener_dispatch_auth(int fd, short event, void *arg) (void)fd; } -/* - * SEARCH-ORACLE channel: at most one - * IMSG_SEARCH_PARSE_REQUEST/RESULT round trip is ever in flight for - * this process's one session, so TAILQ_FIRST(&sessions) is - * unambiguously it; session_id lookup elsewhere is kept only for - * parity with auth.c's imsg shape. - */ void -listener_dispatch_search(int fd, short event, void *arg) -{ - struct imsgev *iev = arg; - struct session *s = TAILQ_FIRST(&sessions); - 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) { - /* - * This session's search-oracle may die - * independently of the session, same fail-soft shape - * as listener_dispatch_auth()'s channel-EOF handling -- - * an in-flight SEARCH gets a synthesized NO, and a - * later one fails fast via iev_search.ibuf.fd == -1. - */ - log_warnx("search-oracle closed channel"); - event_del(&iev->ev); - close(iev->ibuf.fd); - imsgbuf_clear(&iev->ibuf); - iev->ibuf.fd = -1; - - if (s != NULL && s->state == SESSION_SEARCH_PARSING) { - s->state = SESSION_SELECTED; - session_reply(s, s->pending_tag, "NO", - "[UNAVAILABLE] search temporarily " - "unavailable"); - if (!session_dequeue_next(s)) - /* s torn down by a queued LOGOUT */ - return; - } - return; - } - } - - for (;;) { - if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1) - fatal("imsgbuf_get"); - if (n == 0) - break; - - switch (imsg_get_type(&imsg)) { - case IMSG_SEARCH_PARSE_RESULT: { - struct imsg_search_parse_result res; - struct search_node *nodes = NULL; - size_t bodylen; - - if (s == NULL || s->state != SESSION_SEARCH_PARSING) { - log_debug("IMSG_SEARCH_PARSE_RESULT with no " - "SEARCH awaiting one, ignored"); - break; - } - - if (imsg_get_buf(&imsg, &res, sizeof(res)) == -1) { - log_warnx("bad IMSG_SEARCH_PARSE_RESULT " - "(header)"); - s->state = SESSION_SELECTED; - session_reply(s, s->pending_tag, "NO", - "[SERVERBUG] internal error"); - break; - } - /* - * imsg_get_buf() guarantees size, not NUL termination, - * and this field goes straight to session_reply() via - * snprintf("%s"), so treat the least-trusted process's - * framing as untrusted, same as auth.c's - * username/password and parent.c's maildir. - */ - res.errmsg[sizeof(res.errmsg) - 1] = '\0'; - - if (res.rc == 0) { - bodylen = imsg_get_len(&imsg); - if (res.nnodes > SEARCH_PROGRAM_MAX_NODES || - bodylen != (size_t)res.nnodes * - sizeof(struct search_node)) { - log_warnx("bad " - "IMSG_SEARCH_PARSE_RESULT " - "(nnodes %u, %zu trailing " - "bytes)", res.nnodes, bodylen); - s->state = SESSION_SELECTED; - session_reply(s, s->pending_tag, "NO", - "[SERVERBUG] internal error"); - break; - } - if (bodylen > 0) { - if ((nodes = malloc(bodylen)) == NULL) { - log_warn("malloc SEARCH nodes"); - s->state = SESSION_SELECTED; - session_reply(s, - s->pending_tag, "NO", - "[SERVERBUG] internal " - "error"); - break; - } - if (imsg_get_buf(&imsg, nodes, bodylen) - == -1) { - log_warnx("bad " - "IMSG_SEARCH_PARSE_RESULT " - "(nodes)"); - free(nodes); - s->state = SESSION_SELECTED; - session_reply(s, - s->pending_tag, "NO", - "[SERVERBUG] internal " - "error"); - break; - } - } - } - - search_dispatch_finish(s, &res, nodes); - free(nodes); - break; - } - default: - log_debug("listener_dispatch_search: unhandled %d", - imsg_get_type(&imsg)); - break; - } - imsg_free(&imsg); - } - imsgev_rearm_read(iev); - (void)fd; - - if (s != NULL) { - if (!session_dequeue_next(s)) - return; /* s torn down by a queued LOGOUT */ - } -} - -/* - * PARENT channel (fd 3): IMSG_STORE_FORK (spawn failure) and - * IMSG_SETUP_PEER (spawn success, imsg_get_id() has session id). No - * SIGHUP-driven reload here any more: this process is spawned - * fresh per connection and gets its own current TLS cert once at - * spawn time (IMSG_TLS_CERT, in listener_main()'s boot-drain loop), - * so it never lives long enough to need one -- see parent.c's header - * comment. - */ -void listener_dispatch_parent(int fd, short event, void *arg) { struct imsgev *iev = arg; struct imsg imsg; ssize_t n; - /* See listener_dispatch_auth()'s comment on this EV_WRITE check. */ if (event & EV_WRITE) { if (imsgbuf_write(&iev->ibuf) == -1) fatal("imsgbuf_write"); @@ -2089,11 +1560,18 @@ listener_dispatch_parent(int fd, short event, void *ar log_warnx("bad IMSG_STORE_FORK reply"); break; } - if ((s = session_find(fail.session_id)) != NULL) { - s->state = SESSION_NOT_AUTH; + if ((s = session_find(fail.session_id)) == NULL) + break; + if (fail.limit) { session_reply(s, s->pending_tag, "NO", + "[LIMIT] too many sessions for this " + "account"); + session_teardown(s, "limit-exceeded"); + } else { + session_reply(s, s->pending_tag, "NO", "authentication succeeded but mailbox " "store unavailable"); + session_teardown(s, "io-error"); } break; } @@ -2122,11 +1600,6 @@ listener_dispatch_parent(int fd, short event, void *ar imsgev_init(s->store_iev, store_fd, session_store_dispatch, s); s->state = SESSION_AUTHENTICATED; - /* - * Authenticated: stop the pre-authentication timer. - * This is the same point parent.c marks the session - * authenticated for MaxStartups. - */ session_login_grace_disarm(s); log_debug("session %u: store peer wired", sess_id); /* RFC 9051 SS6.2.2's PLAIN example text, verbatim. */ @@ -2157,7 +1630,6 @@ session_find(uint32_t id) return (NULL); } -/* Best-effort IMSG_STORE_SHUTDOWN to store child; closes fds, unregisters. */ /* reason: one of eight fixed tokens, never NULL, never attacker text */ void session_teardown(struct session *s, const char *reason) @@ -2184,10 +1656,6 @@ session_teardown(struct session *s, const char *reason if (s->tls_ctx != NULL) { int ret = tls_close(s->tls_ctx); - /* - * tls_close() closes the fd unless close_notify wants one more - * round trip. - */ if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) close(s->client_fd); /* debug: a missing close_notify is routine; httpd ignores it */ @@ -2199,10 +1667,6 @@ session_teardown(struct session *s, const char *reason close(s->client_fd); } - /* - * All NULL-safe; may be set on a mid-stream teardown (FETCH/SEARCH - * etc). - */ free(s->search_matches); free(s->vanished_ranges); free(s->qresync_fetches); @@ -2213,10 +1677,6 @@ session_teardown(struct session *s, const char *reason free(s->pending_envelope_buf); free(s->pending_bodystructure_buf); - /* - * Commands pipelined behind the in-flight one when session was torn - * down. - */ while (s->cmd_queue_n > 0) free(s->cmd_queue[--s->cmd_queue_n]); @@ -2240,20 +1700,10 @@ session_teardown(struct session *s, const char *reason s->tls_active ? "yes" : "no", dur); } - /* - * inbuf may hold a base64 SASL response; don't hand it to allocator - * intact. - */ + /* inbuf may hold a base64 SASL response */ explicit_bzero(s, sizeof(*s)); free(s); - /* - * This process serves exactly this one session and never - * another, so exit rather than idle forever in event_dispatch() - * leaking a process per finished connection; parent.c's - * reap_child() already treats this exit as expected, matching - * store.c's store_shutdown(). - */ log_debug("listener-worker: session closed, exiting"); exit(0); } blob - 8755fed3f2e8207aa45e1e8304bd299107a67642 blob + 0ba65946e76d2bf2869f7b2e0ec1b71892492b88 --- src/listener.h +++ src/listener.h @@ -16,9 +16,6 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -/* listener.h: declarations private to the listener process, so */ -/* listener.c's split files can see struct session and each other. */ - #ifndef LISTENER_H #define LISTENER_H @@ -32,107 +29,51 @@ 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 */ + SESSION_AUTHENTICATING, + SESSION_STORE_PENDING, SESSION_AUTHENTICATED, - SESSION_SELECTING, /* IMSG_MBOX_SELECT sent, awaiting reply */ + SESSION_SELECTING, SESSION_SELECTED, - SESSION_FETCHING, /* IMSG_MBOX_FETCH sent, awaiting the - * IMSG_MBOX_FETCH_META stream + terminal - * IMSG_MBOX_RESULT */ - SESSION_STORING, /* IMSG_MBOX_STORE sent; reuses FETCH's - * reply shape (see cmd_store_cmd()) */ - SESSION_EXPUNGING, /* IMSG_MBOX_EXPUNGE sent (EXPUNGE, or CLOSE - * with silent=1), awaiting IMSG_MBOX_EXPUNGED - * stream + terminal IMSG_MBOX_RESULT */ - SESSION_APPENDING, /* IMSG_MBOX_APPEND_END sent, awaiting the - * single terminal IMSG_MBOX_APPENDED reply. - * Distinct from the client-literal-read phase - * (s->literal_pending) that precedes it -- - * this covers only the store round trip. */ - SESSION_SEARCH_PARSING, /* IMSG_SEARCH_PARSE_REQUEST sent - * to the per-connection search-oracle, - * awaiting IMSG_SEARCH_PARSE_RESULT -- - * precedes SESSION_SEARCHING below, a SEARCH - * is now a two-hop async round trip (oracle - * parse, then store execute), not one. - * Handled by listener_dispatch_search() - * (listener.c), which calls - * search_dispatch_finish() (search_cmd.c) - * once the oracle replies. */ - SESSION_SEARCHING, /* IMSG_MBOX_SEARCH sent, awaiting the - * IMSG_MBOX_SEARCH_MATCH stream + terminal - * IMSG_MBOX_RESULT; handled by - * session_handle_mbox_result(), which - * branches to session_finish_search(). */ - SESSION_STATUSING, /* IMSG_MBOX_STATUS sent, awaiting the single - * terminal IMSG_MBOX_STATUS_RESULT reply. - * Never changes s->state's SELECTED-ness - * (RFC 9051 SS6.3.11); s->status_prev_state - * records the state 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) + terminal IMSG_MBOX_RESULT; - * branches to - * session_finish_copy_or_move(). */ + SESSION_FETCHING, + SESSION_STORING, + SESSION_EXPUNGING, + SESSION_APPENDING, + SESSION_SEARCHING, + SESSION_STATUSING, + SESSION_COPYING, - /* RFC 9051 SS6.3.4-SS6.3.9. All six are command-auth and never */ - /* change s->state's SELECTED-ness; mbox_op_prev_state records what */ - /* to restore, and only one is in flight per session. */ - SESSION_CREATING, /* IMSG_MBOX_CREATE sent, single terminal - * IMSG_MBOX_RESULT reply */ - SESSION_DELETING, /* IMSG_MBOX_DELETE sent, same shape as - * SESSION_CREATING */ - SESSION_RENAMING, /* IMSG_MBOX_RENAME sent, same shape as - * SESSION_CREATING */ - SESSION_LISTING, /* IMSG_MBOX_LIST sent, awaiting the - * IMSG_MBOX_LIST_ITEM stream + terminal - * IMSG_MBOX_RESULT; branches to - * session_finish_list(). */ - SESSION_SUBSCRIBING, /* IMSG_MBOX_SUBSCRIBE sent, same shape as - * SESSION_CREATING */ - SESSION_UNSUBSCRIBING /* IMSG_MBOX_UNSUBSCRIBE sent, same shape as - * SESSION_CREATING */ + /* RFC 9051 SS6.3.4-SS6.3.9; mbox_op_prev_state is restored after */ + SESSION_CREATING, + SESSION_DELETING, + SESSION_RENAMING, + SESSION_LISTING, + SESSION_SUBSCRIBING, + SESSION_UNSUBSCRIBING }; -/* Line-length cap for the raw read buffer. RFC 9051 mandates no limit, */ -/* but one is needed to bound a client that never sends CRLF. */ +/* RFC 9051 sets no limit; this bounds a client that never sends CRLF */ #define SESSION_INBUF_MAX 8192 -/* Bound on a client-chosen tag held across an async round trip. RFC */ -/* 9051 SS9 sets no limit; an over-long tag is truncated, not rejected. */ +/* RFC 9051 SS9 sets no limit; a longer tag is truncated */ #define IMAP_TAG_MAX 64 -/* Bound on lines pipelined ahead of one awaiting a store round trip */ -/* (RFC 9051 SS5.5). Queueing past this disconnects the session rather */ -/* than granting unbounded memory. Not applied before authentication. */ +/* pipelined lines (RFC 9051 SS5.5); past this the session is dropped */ #define SESSION_CMD_QUEUE_MAX 8 -/* Cap on the verbatim label echoed back as "BODY[