From 4829d096feb337a55e2b866adb19acab9617b071 Mon Sep 17 00:00:00 2001 From: Eric Gillespie Date: Mon, 1 Nov 2021 08:27:15 -0500 Subject: [PATCH] expect this commit to be force-replaced periodically stability will come soon when i push to savannah --- .dir-locals.el.in | 13 + Cargo.lock | 124 ++ Cargo.toml | 2 + Makefile.am | 52 + configure.ac | 17 +- docs/imap/DESIGN | 373 +++++ docs/imap/testing/cyrus/cyrus.conf | 18 + docs/imap/testing/cyrus/imapd.conf | 14 + docs/imap/testing/dovecot.conf | 35 + docs/imap/testing/notes | 7 + docs/imap/testing/run | 254 ++++ guide | 9 - h/folder.h | 40 + h/folder_manager_protocol.h | 16 + h/managed_folders.h | 133 ++ h/oauth.h | 5 +- imap/folder-manager/Cargo.toml | 14 + imap/folder-manager/src/cf.rs | 140 ++ imap/folder-manager/src/error.rs | 128 ++ imap/folder-manager/src/imap.rs | 49 + imap/folder-manager/src/imap/auth.rs | 187 +++ imap/folder-manager/src/imap/mailbox.rs | 23 + imap/folder-manager/src/imap/protocol.rs | 936 ++++++++++++ .../src/imap/protocol/response.rs | 143 ++ .../folder-manager/src/imap/protocol/tests.rs | 201 +++ imap/folder-manager/src/imap/session.rs | 333 +++++ imap/folder-manager/src/imap/session/fetch.rs | 256 ++++ .../src/imap/session/mailbox.rs | 263 ++++ imap/folder-manager/src/imap/session/tests.rs | 8 + imap/folder-manager/src/logging.rs | 181 +++ imap/folder-manager/src/mailbox_rule.rs | 59 + imap/folder-manager/src/main.rs | 40 + imap/folder-manager/src/manager.rs | 663 +++++++++ imap/folder-manager/src/manager/command.rs | 5 + .../folder-manager/src/manager/command/inc.rs | 278 ++++ .../src/manager/command/mark.rs | 200 +++ .../src/manager/command/mark/add.rs | 53 + .../src/manager/command/mark/delete.rs | 47 + .../src/manager/command/pick.rs | 130 ++ imap/folder-manager/src/manager/mapper.rs | 41 + imap/folder-manager/src/manager/pick.rs | 329 +++++ imap/folder-manager/src/regex.rs | 52 + imap/folder-manager/src/regex/tests.rs | 15 + imap/folder-manager/src/run.rs | 127 ++ imap/mhimap/Cargo.toml | 9 + imap/mhimap/src/main.rs | 92 ++ imap/store/Cargo.toml | 13 + imap/store/src/append.rs | 118 ++ imap/store/src/check.rs | 62 + imap/store/src/lib.rs | 335 +++++ imap/store/src/mapper.rs | 36 + imap/store/src/tests.rs | 94 ++ imap/store/src/transaction.rs | 290 ++++ imap/store/src/transaction/cursor.rs | 131 ++ imap/types/Cargo.toml | 9 + imap/types/src/lib.rs | 37 + imap/types/src/uid.rs | 25 + imap/types/tests/tests.rs | 1 + imap/types/tests/uid/mod.rs | 19 + managedfolders/Cargo.toml | 10 + managedfolders/src/command.rs | 159 ++ managedfolders/src/components.rs | 36 + managedfolders/src/lib.rs | 34 + managedfolders/src/protocol.rs | 249 ++++ managedfolders/src/protocol/message.rs | 44 + managedfolders/src/protocol/read.rs | 71 + managedfolders/src/protocol/read/tests.rs | 85 ++ managedfolders/src/protocol/write.rs | 29 + managedfolders/src/protocol/write/tests.rs | 50 + managedfolders/tests/tests.rs | 12 + rls.in | 2 + sbr/arglist.c | 3 +- sbr/brkstring.c | 8 +- sbr/brkstring.h | 7 +- sbr/client.c | 6 +- sbr/client.h | 2 +- sbr/folder_manager_client.c | 1296 +++++++++++++++++ sbr/folder_manager_protocol.c | 164 +++ sbr/folder_operations.c | 296 ++++ sbr/folder_read.c | 4 +- sbr/getarguments.c | 7 +- sbr/m_convert.c | 12 +- sbr/managed_folders.c | 146 ++ sbr/netsec.c | 41 +- sbr/oauth.c | 6 +- sbr/read_switch_multiword.c | 6 +- sbr/read_switch_multiword.h | 2 +- sbr/read_switch_multiword_via_readline.c | 10 +- sbr/read_switch_multiword_via_readline.h | 2 +- sbr/refile.c | 2 +- sbr/refile.h | 2 +- sbr/rust/Cargo.toml | 8 + sbr/rust/bindgen.h | 8 + sbr/rust/build.rs.in | 10 + sbr/rust/src/base64.rs | 23 + sbr/rust/src/client.rs | 86 ++ sbr/rust/src/error.rs | 132 ++ sbr/rust/src/folder.rs | 33 + sbr/rust/src/hacks.rs | 17 + sbr/rust/src/lib.rs | 61 + sbr/rust/src/message.rs | 59 + sbr/rust/src/message/set.rs | 119 ++ sbr/rust/src/netsec.rs | 31 + sbr/rust/src/netsec/build.rs | 106 ++ sbr/rust/src/netsec/netsec.rs | 139 ++ sbr/rust/src/netsec/sasl.rs | 264 ++++ sbr/rust/src/netsec/tls.rs | 49 + sbr/rust/src/profile.rs | 126 ++ sbr/rust/src/sbr.rs | 61 + sbr/rust/src/str.rs | 254 ++++ sbr/rust/src/str/tests.rs | 28 + sbr/rust/tests/base64/mod.rs | 21 + sbr/rust/tests/client/mod.rs | 52 + sbr/rust/tests/message/mod.rs | 47 + sbr/rust/tests/message/set.rs | 71 + sbr/rust/tests/netsec/mod.rs | 71 + sbr/rust/tests/str/mod.rs | 94 ++ sbr/rust/tests/tests.rs | 5 + sbr/seq_read.c | 4 +- sbr/seq_setprev.c | 20 +- sbr/seq_setunseen.c | 29 +- sbr/showfile.c | 2 +- sbr/showfile.h | 2 +- sbr/vector.c | 14 +- sbr/vector.h | 7 +- test/common.sh.in | 111 +- test/comp/test-draft-fcc | 205 +++ test/fakeinetd.c | 135 ++ test/imap/test-astring | 121 ++ test/imap/test-bad | 83 ++ test/imap/test-bad-fetch-body | 90 ++ test/imap/test-folder-create-disallowed | 159 ++ test/imap/test-gmail-all | 61 + test/imap/test-inc | 476 ++++++ test/imap/test-inc-missing-msg | 130 ++ test/imap/test-inc-missing-msg-case-2 | 83 ++ test/imap/test-live | 775 ++++++++++ test/imap/test-login | 374 +++++ test/imap/test-mark | 105 ++ test/imap/test-quirks-gmail | 94 ++ test/imap/test-refile | 198 +++ test/imap/test-rmm | 98 ++ test/imap/test-scan | 259 ++++ test/imap/test-search | 132 ++ test/imap/test-server-drops | 81 ++ test/imap/test-uidvalidity | 139 ++ test/imap/test-xoauth | 89 ++ test/managed-folders/protocol-tester.c | 116 ++ test/managed-folders/test-message-len | 318 ++++ test/managed-folders/test-read-message | 123 ++ test/oauth/common.sh | 7 +- test/server.c | 2 +- uip/ali.c | 4 +- uip/comp.c | 81 +- uip/dist.c | 5 +- uip/flist.c | 7 +- uip/fmttest.c | 6 +- uip/folder.c | 25 +- uip/forw.c | 5 +- uip/imaptest.c | 3 +- uip/inc.c | 171 ++- uip/mark.c | 28 + uip/mhbuildsbr.c | 2 +- uip/mhlogin.c | 45 +- uip/mhlsbr.c | 5 +- uip/mhstoresbr.c | 2 +- uip/new.c | 10 +- uip/refile.c | 52 +- uip/repl.c | 11 +- uip/rmf.c | 43 +- uip/rmm.c | 20 + uip/scan.c | 67 +- uip/send.c | 73 +- uip/sendsbr.c | 4 +- uip/show.c | 49 +- uip/whatnowsbr.c | 13 +- uip/whom.c | 4 +- 177 files changed, 17091 insertions(+), 348 deletions(-) create mode 100644 .dir-locals.el.in create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 docs/imap/DESIGN create mode 100644 docs/imap/testing/cyrus/cyrus.conf create mode 100644 docs/imap/testing/cyrus/imapd.conf create mode 100644 docs/imap/testing/dovecot.conf create mode 100644 docs/imap/testing/notes create mode 100755 docs/imap/testing/run delete mode 100644 guide create mode 100644 h/folder.h create mode 100644 h/folder_manager_protocol.h create mode 100644 h/managed_folders.h create mode 100644 imap/folder-manager/Cargo.toml create mode 100644 imap/folder-manager/src/cf.rs create mode 100644 imap/folder-manager/src/error.rs create mode 100644 imap/folder-manager/src/imap.rs create mode 100644 imap/folder-manager/src/imap/auth.rs create mode 100644 imap/folder-manager/src/imap/mailbox.rs create mode 100644 imap/folder-manager/src/imap/protocol.rs create mode 100644 imap/folder-manager/src/imap/protocol/response.rs create mode 100644 imap/folder-manager/src/imap/protocol/tests.rs create mode 100644 imap/folder-manager/src/imap/session.rs create mode 100644 imap/folder-manager/src/imap/session/fetch.rs create mode 100644 imap/folder-manager/src/imap/session/mailbox.rs create mode 100644 imap/folder-manager/src/imap/session/tests.rs create mode 100644 imap/folder-manager/src/logging.rs create mode 100644 imap/folder-manager/src/mailbox_rule.rs create mode 100644 imap/folder-manager/src/main.rs create mode 100644 imap/folder-manager/src/manager.rs create mode 100644 imap/folder-manager/src/manager/command.rs create mode 100644 imap/folder-manager/src/manager/command/inc.rs create mode 100644 imap/folder-manager/src/manager/command/mark.rs create mode 100644 imap/folder-manager/src/manager/command/mark/add.rs create mode 100644 imap/folder-manager/src/manager/command/mark/delete.rs create mode 100644 imap/folder-manager/src/manager/command/pick.rs create mode 100644 imap/folder-manager/src/manager/mapper.rs create mode 100644 imap/folder-manager/src/manager/pick.rs create mode 100644 imap/folder-manager/src/regex.rs create mode 100644 imap/folder-manager/src/regex/tests.rs create mode 100644 imap/folder-manager/src/run.rs create mode 100644 imap/mhimap/Cargo.toml create mode 100644 imap/mhimap/src/main.rs create mode 100644 imap/store/Cargo.toml create mode 100644 imap/store/src/append.rs create mode 100644 imap/store/src/check.rs create mode 100644 imap/store/src/lib.rs create mode 100644 imap/store/src/mapper.rs create mode 100644 imap/store/src/tests.rs create mode 100644 imap/store/src/transaction.rs create mode 100644 imap/store/src/transaction/cursor.rs create mode 100644 imap/types/Cargo.toml create mode 100644 imap/types/src/lib.rs create mode 100644 imap/types/src/uid.rs create mode 100644 imap/types/tests/tests.rs create mode 100644 imap/types/tests/uid/mod.rs create mode 100644 managedfolders/Cargo.toml create mode 100644 managedfolders/src/command.rs create mode 100644 managedfolders/src/components.rs create mode 100644 managedfolders/src/lib.rs create mode 100644 managedfolders/src/protocol.rs create mode 100644 managedfolders/src/protocol/message.rs create mode 100644 managedfolders/src/protocol/read.rs create mode 100644 managedfolders/src/protocol/read/tests.rs create mode 100644 managedfolders/src/protocol/write.rs create mode 100644 managedfolders/src/protocol/write/tests.rs create mode 100644 managedfolders/tests/tests.rs create mode 100644 rls.in create mode 100644 sbr/folder_manager_client.c create mode 100644 sbr/folder_manager_protocol.c create mode 100644 sbr/folder_operations.c create mode 100644 sbr/managed_folders.c create mode 100644 sbr/rust/Cargo.toml create mode 100644 sbr/rust/bindgen.h create mode 100644 sbr/rust/build.rs.in create mode 100644 sbr/rust/src/base64.rs create mode 100644 sbr/rust/src/client.rs create mode 100644 sbr/rust/src/error.rs create mode 100644 sbr/rust/src/folder.rs create mode 100644 sbr/rust/src/hacks.rs create mode 100644 sbr/rust/src/lib.rs create mode 100644 sbr/rust/src/message.rs create mode 100644 sbr/rust/src/message/set.rs create mode 100644 sbr/rust/src/netsec.rs create mode 100644 sbr/rust/src/netsec/build.rs create mode 100644 sbr/rust/src/netsec/netsec.rs create mode 100644 sbr/rust/src/netsec/sasl.rs create mode 100644 sbr/rust/src/netsec/tls.rs create mode 100644 sbr/rust/src/profile.rs create mode 100644 sbr/rust/src/sbr.rs create mode 100644 sbr/rust/src/str.rs create mode 100644 sbr/rust/src/str/tests.rs create mode 100644 sbr/rust/tests/base64/mod.rs create mode 100644 sbr/rust/tests/client/mod.rs create mode 100644 sbr/rust/tests/message/mod.rs create mode 100644 sbr/rust/tests/message/set.rs create mode 100644 sbr/rust/tests/netsec/mod.rs create mode 100644 sbr/rust/tests/str/mod.rs create mode 100644 sbr/rust/tests/tests.rs create mode 100755 test/comp/test-draft-fcc create mode 100644 test/fakeinetd.c create mode 100755 test/imap/test-astring create mode 100755 test/imap/test-bad create mode 100755 test/imap/test-bad-fetch-body create mode 100755 test/imap/test-folder-create-disallowed create mode 100755 test/imap/test-gmail-all create mode 100755 test/imap/test-inc create mode 100755 test/imap/test-inc-missing-msg create mode 100755 test/imap/test-inc-missing-msg-case-2 create mode 100755 test/imap/test-live create mode 100755 test/imap/test-login create mode 100755 test/imap/test-mark create mode 100755 test/imap/test-quirks-gmail create mode 100755 test/imap/test-refile create mode 100755 test/imap/test-rmm create mode 100755 test/imap/test-scan create mode 100755 test/imap/test-search create mode 100755 test/imap/test-server-drops create mode 100755 test/imap/test-uidvalidity create mode 100755 test/imap/test-xoauth create mode 100644 test/managed-folders/protocol-tester.c create mode 100755 test/managed-folders/test-message-len create mode 100755 test/managed-folders/test-read-message diff --git a/.dir-locals.el.in b/.dir-locals.el.in new file mode 100644 index 00000000..17afff82 --- /dev/null +++ b/.dir-locals.el.in @@ -0,0 +1,13 @@ +( + (c-mode + . ( + (c-file-style . "awk") + (indent-tabs-mode . t) + )) + + (rust-mode + . ( + (eglot-server-programs (rust-mode . (eglot-rls "@abs_builddir@/rls"))) + (indent-tabs-mode . nil) + )) + ) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..ffb74653 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,124 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "byteorder" +version = "1.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de" + +[[package]] +name = "cc" +version = "1.0.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef611cc68ff783f18535d77ddd080185275713d852c4f5cbb6122c462a7a825c" + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "imap-folder-manager" +version = "0.0.0" +dependencies = [ + "libc", + "log", + "managed-folders", + "nmh", + "nmhimapstore", + "nmhimaptypes", +] + +[[package]] +name = "libc" +version = "0.2.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa7087f49d294270db4e1928fc110c976cd4b9e5a16348e0a1df09afa99e6c98" + +[[package]] +name = "lmdb-rkv" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447a296f7aca299cfbb50f4e4f3d49451549af655fb7215d7f8c0c3d64bad42b" +dependencies = [ + "bitflags", + "byteorder", + "libc", + "lmdb-rkv-sys", +] + +[[package]] +name = "lmdb-rkv-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b27470ac25167b3afdfb6af8fcd3bc1be67de50ffbdaf4073378cfded6ae24a5" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "log" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcf3805d4480bb5b86070dcfeb9e2cb2ebc148adb753c5cca5f884d1d65a42b2" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "managed-folders" +version = "0.0.0" +dependencies = [ + "log", + "nmh", +] + +[[package]] +name = "mhimap" +version = "0.0.0" +dependencies = [ + "nmh", + "nmhimapstore", +] + +[[package]] +name = "nmh" +version = "0.0.0" +dependencies = [ + "libc", +] + +[[package]] +name = "nmhimapstore" +version = "0.0.0" +dependencies = [ + "lmdb-rkv", + "lmdb-rkv-sys", + "log", + "nmh", + "nmhimaptypes", +] + +[[package]] +name = "nmhimaptypes" +version = "0.0.0" +dependencies = [ + "nmh", +] + +[[package]] +name = "pkg-config" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d36492546b6af1463394d46f0c834346f31548646f6ba10849802c9c9a27ac33" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..8586262b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,2 @@ +[workspace] +members = ["imap/folder-manager", "imap/mhimap"] diff --git a/Makefile.am b/Makefile.am index 57a46e35..1c620763 100644 --- a/Makefile.am +++ b/Makefile.am @@ -31,6 +31,7 @@ MHNSEARCHPROG = $(srcdir)/etc/mhn.find.sh ## TESTS_ENVIRONMENT = MH_OBJ_DIR="@abs_builddir@" \ MH_VERSION="$(VERSION)" \ + MAKE=$(MAKE) \ OAUTH_SUPPORT='@OAUTH_SUPPORT@' \ CURL_USER_AGENT='@CURL_USER_AGENT@' \ MH_TEST_DIR=`cd "@abs_builddir@" && pwd -P`/test/testdir \ @@ -53,6 +54,7 @@ TESTS = \ test/burst/test-burst \ test/burst/test-burst-mime \ test/comp/test-comp-format \ + test/comp/test-draft-fcc \ test/dist/test-dist \ test/folder/test-coverage \ test/folder/test-create \ @@ -77,6 +79,9 @@ TESTS = \ test/forw/test-forw-coverage \ test/forw/test-forw-digest \ test/forw/test-forw-format \ + test/imap/test-inc \ + test/imap/test-live \ + test/imap/test-scan \ test/inc/test-deb359167 \ test/inc/test-eom-align \ test/inc/test-inc-scanout \ @@ -86,6 +91,7 @@ TESTS = \ test/install-mh/test-version-check \ test/locking/test-datalocking \ test/locking/test-spoollocking \ + test/managed-folders/test-read-message \ test/manpages/test-manpages \ test/mhbuild/test-attach \ test/mhbuild/test-cte \ @@ -164,15 +170,19 @@ TESTS = \ test/whom/test-whom \ test/cleanup ## The "cleanup" test should always be last. +check_DATA = fake.pem + check_SCRIPTS = test/common.sh check_PROGRAMS = \ test/fakehttp \ + test/fakeinetd \ test/fakepop \ test/fakesmtp \ test/getcanon \ test/getcwidth \ test/getfullname \ + test/managed-folders/protocol-tester \ test/runpty \ # @@ -336,7 +346,10 @@ noinst_HEADERS = \ h/dropsbr.h \ h/fmt_compile.h \ h/fmt_scan.h \ + h/folder.h \ + h/folder_manager_protocol.h \ h/icalendar.h \ + h/managed_folders.h \ h/mf.h \ h/mh.h \ h/mhparse.h \ @@ -980,6 +993,9 @@ test_fakesmtp_LDADD = $(POSTLINK) test_fakehttp_SOURCES = test/fakehttp.c test/server.c test_fakehttp_LDADD = $(LDADD) $(POSTLINK) +test_fakeinetd_SOURCES = test/fakeinetd.c +test_fakeinetd_LDADD = $(LDADD) $(POSTLINK) + test_getcwidth_SOURCES = test/getcwidth.c test_getcwidth_LDADD = $(POSTLINK) @@ -1100,6 +1116,9 @@ sbr_libmh_a_SOURCES = \ sbr/folder_addmsg.c \ sbr/folder_delmsgs.c \ sbr/folder_free.c \ + sbr/folder_manager_client.c \ + sbr/folder_manager_protocol.c \ + sbr/folder_operations.c \ sbr/folder_pack.c \ sbr/folder_read.c \ sbr/folder_realloc.c \ @@ -1124,6 +1143,7 @@ sbr_libmh_a_SOURCES = \ sbr/m_rand.c \ sbr/maildir_read_and_sort.c \ sbr/makedir.c \ + sbr/managed_folders.c \ sbr/message_id.c \ sbr/mf.c \ sbr/mime_type.c \ @@ -1308,6 +1328,14 @@ gcov-clean: gcov-mostlyclean .PHONY: gcov gcov-rebuild gcov-run gcov-process gcov-report \ gcov-mostlyclean gcov-clean +# TODO rust coverage +# cargo +nightly install cargo-binutils grcov rustfilt +# RUSTFLAGS=-Zinstrument-coverage make CARGO_FLAGS=+nightly cargo +# make check +# TODO rename .llvmprof to .profdata so grcov will see it +# cargo +nightly profdata -- merge -sparse test/imap/*.llvmprof -o profdata +# cargo +nightly cov -- show target/debug/imap-folder-manager -instr-profile=profdata "-path-equivalence=,${top_srcdir}" +# grcov --branch --llvm -b target/debug -o ~/public_html/cov -t html -s ../.. test/imap/cov ## ## Rules to upload the distribution to savannah @@ -1331,3 +1359,27 @@ upload: dist @echo "Uploading to savannah" scp -p $(DIST_ARCHIVES)* $(SAVANNAH_USERNAME)@dl.sv.nongnu.org:/releases/nmh .PHONY: upload + +# TODO autoconf check for cargo and bindgen +.PHONY: cargo cargo-doc +cargo: $(srcdir)/sbr/rust/src/bindgen.rs $(abs_srcdir)/sbr/rust/build.rs + cd $(srcdir) && CARGO_TARGET_DIR=$(abs_builddir)/target cargo $(CARGO_FLAGS) build +cargo-doc: cargo + cd $(srcdir) && CARGO_TARGET_DIR=$(abs_builddir)/target cargo $(CARGO_FLAGS) doc --no-deps + +# TODO default to release build with optional debug build +# TODO install mhimap to bindir and imap-folder-manager to libexecdir +# TODO proper build rules, including dependency on libmh.a +target/debug/mhimap target/debug/imap-folder-manager: cargo + +# TODO proper out-of-tree build +$(abs_srcdir)/sbr/rust/build.rs: $(abs_builddir)/sbr/rust/build.rs + [ $(abs_srcdir) = $(abs_builddir) ] || cp $(builddir)/sbr/rust/build.rs $(srcdir)/sbr/rust/build.rs + +$(srcdir)/sbr/rust/src/bindgen.rs: $(srcdir)/sbr/rust/bindgen.h + bindgen --no-derive-copy -o $(srcdir)/sbr/rust/src/bindgen.rs.tmp $(srcdir)/sbr/rust/bindgen.h -- -I$(srcdir) \ + && mv $(srcdir)/sbr/rust/src/bindgen.rs.tmp $(srcdir)/sbr/rust/src/bindgen.rs + +# TODO autoconf check for openssl +fake.pem: + yes '' | openssl req -new -x509 -nodes -out fake.pem -keyout fake.pem diff --git a/configure.ac b/configure.ac index 1f8dc6c8..a0e50ac9 100644 --- a/configure.ac +++ b/configure.ac @@ -647,9 +647,24 @@ TLS support : ${tls_support} OAuth support : ${oauth_support} ])])dnl +dnl TODO move up with other as-yet-unwritten rust configuration +dnl TODO ac_pwd doesn't seem right +CARGO_TARGET_DIR=$ac_pwd/target +AC_SUBST([CARGO_TARGET_DIR]) +dnl TODO +RLS=rls +AC_SUBST([RLS]) + dnl --------------- dnl OUTPUT MAKEFILE dnl --------------- -AC_CONFIG_FILES([Makefile test/common.sh]) +AC_CONFIG_FILES([.dir-locals.el], [cp .dir-locals.el $ac_abs_srcdir]) +AC_CONFIG_FILES([ + Makefile + sbr/rust/build.rs + test/common.sh +]) +AC_CONFIG_FILES([rls], [chmod +x rls]) AC_CONFIG_FILES([uip/spost], [chmod +x uip/spost]) + AC_OUTPUT diff --git a/docs/imap/DESIGN b/docs/imap/DESIGN new file mode 100644 index 00000000..d27450a2 --- /dev/null +++ b/docs/imap/DESIGN @@ -0,0 +1,373 @@ +#+TITLE: IMAP Synchronization for nmh + +* Status + +An EXPERIMENTAL, almost-entirely-happy-path-only partial implementation +exists. Most of the nmh commands that are within the scope of the project +have not yet been updated. + +All functionality behind --enable-experimental-imap-folder-manager +configuration flag. + +* Rationale + +The world is now mobile-first, and many of are are mobile-only, or nearly so. +When my smart phone took over my life, two things happened to my nmh usage: + +- At work, I was frustrated constantly by messages read under nmh being unread + on my phone and vice versa. +- In my personal life, that same frustration drove me away from nmh completely. + Whenever I need to reply to email from my computer, I find my self + incorporating months of messages. + +We can use IMAP to make nmh a first-class citizen in the modern mail world. + +* Goals + +- Treat the IMAP server as the source of truth. +- Minimize startup penalty to nmh commands. +- Minimize the impact to the nmh code-base. +- Minimize round-trips to the server. +- Enhance =inc= to incorporate mail from a hierarchy of IMAP mailboxes. +- Enhance =inc= to delete mail locally that has been deleted on the server. +- Enhance all commands interpreting the user's =Unseen-Sequence= (i.e. all those + taking message specifications (=mh-sequence(5)=), e.g. =mark=, =pick=, =scan=, + =show=) to interpret references to the =Unseen-Sequence= as all messages not + flagged as =\Seen= on the server. +- Enhance =scan= output to reflect live =\Seen= status when it is asked to + display whether messages appear in the user's =Unseen-Sequence=. +- Enhance =show= to set the =\Seen= flag on messages shown. +- Enhance =mark= to set or clear the =\Seen= flag for messages removed from or + added to the user's =Unseen-Sequence=, respectively. +- Enhance =rmm= to delete messages from the server. +- Enhance =refile= to move messages on the server. +- Enhance =comp= and =repl= to allow usage of a server-side draft folder. + +** Deferred goals + +- Off-line mode might be useful +- Synchronizing flags other than =\Seen= +- Storing sequences on the server (possibly the same problem as the previous) +- IMAP defines a message UID as an unsigned 32-bit integer, allowing for + 4,294,967,295 messages, but nmh uses SIGNED =int=, which is 32 bits on + amd64, allowing for only 2,147,483,647 messages. It's not clear any file + system offers reasonable performance with directories of that size, so we + may need to redesign nmh folders before the =int= becomes a problem. + Assuming any of this is a practical concern... + +* Approach + +** Architecture + +*** Managed folders + +"Minimize startup penalty" and "Minimize round-trips" suggest we start a +separate process which can keep state between nmh commands. Each nmh command +does not have to can pay the relatively expensive TLS connection setup cost. + +"Minimize impact to the nmh code-base" means continuing to represent folders + as directories and messages as numbered files within. + +To solve those, we introduce managed folders, which are folders in the nmh +mail store managed by a folder manager. When the user requests an operation +on a managed folder, we start by informing the folder manager of the operation +to be taken. In some cases, the folder does nearly all the work. In other +cases, it simply saves some information from the server where the rest of nmh +can easily find it and then gets out of the way. + +For example, =scan= hands the user-specified message sequences to the folder +manager, which consults the IMAP server and stores the results in a special +sequence, and then the rest of =scan= as we all know and love it takes over, +blissfully unaware the folder is special. + +*** Source of truth + +"Treat the IMAP server as the source of truth" gives us a simple and +robust model. + +If a user =rmm= a message, we first delete it from the server. If that fails, +we report the failure to the user and do not proceed. The message remains in +both the nmh folder and on the IMAP server. If deletion from the server +succeeds, but we fail to delete locally, we still report an error, but the +message is now treated as though some other client had deleted it from the +server: we will delete it on the next =inc=. + +** Language + +I've programmed in C and C++ for most of my career and I've just about had it +with unsafe languages. The thought of implementing IMAP synchronization in C +discouraged me from even starting for years. + +In my last position at Google, my fellow tech lead and I would often joke +about dividing the team in two, with one team continuing to chase down a +memory corruption error we'd failed for months to find, and the other +rewriting the system in Rust. It was a joke, but the idea stuck with me. + +Since Rust supports the C ABI, we can seamlessly embed Rust into nmh programs. +Why not give it a shot? + +In this case it's even easier: write the folder manager in Rust. + +* Implementation + +** Managed folders + +*** New profile parameters + +- =Managed-Folders= is a space-separated list of folders to be managed. + Each folder must have a corresponding =[folder]-manager= component naming the + folder manager. +- =[folder]-manager= where =folder= is the name of the folder manager to start. +- [folder]-socket= is the path of the UNIX domain socket. + +*** Starting the folder manager + +1. Try to connect to running manager through =[folder]-socket=. If that works, done! +2. If error was not ~ENOENT~, ~unlink(2)~ the stale socket. +3. ~pipe(2)~ +4. ~fork~ +5. child - folder-manager; prints to stdout if successfully started (e.g. connected to IMAP) +5. parent - read from pipe to child +6. parent - if error, die +7. parent - else, time to connect again! this time, die if error + +*** Protocol + +The protocol isn't the best, and we might want to replace it at some point, but +I have no plans to do that until finishing the first feature set. + +Before refining this protocol, we should look into IPC. + +nmh commands and the folder manager communicate over a UNIX domain socket +configured in the MH profile. An nmh command opens a connection to the folder +manager and sends it a message encoded as a standard mail message header. +The folder manager replies in the same format. + +Unlike in mail messages, these messages MUST start with a =len= component whose +value is the total length of the messgae including the =len= component itself. + +**** Common messages + +The following common message types are defined: + +***** Folder + +This message contains only a single folder name. + +#+BEGIN_EXAMPLE +folder:... +#+END_EXAMPLE + +***** Mark + +This message contains a folder in which to operate, specifications of which +messages to pick and sequences to add to or remove from. + +#+BEGIN_EXAMPLE +folder:... +msgs:... +seqs:... +#+END_EXAMPLE + +***** Empty response + +Many operations have nothing else to respond with but status and optional error message. + +#+BEGIN_EXAMPLE +status:OK | NOTOK +error:error message present only if status:NOTOK +#+END_EXAMPLE + +**** inc + +=inc= sends the request to the folder manager, which opens the local database to +see which which messages exist, sends IMAP =FETCH= requests (up to +=max-fetch-messages-in-flight= at a time), stores the messages, and sends one +response back to =inc= per message, so =inc= can scan we go. + +***** request + +The =inc= request is =op:inc= followed by the folder message: + +#+BEGIN_EXAMPLE +op:inc +folder:... +#+END_EXAMPLE + +***** response + +THe inc response is a series of messages. + +First, one inc message response per message: + +#+BEGIN_EXAMPLE +status:INC_FOLDER +folder:... +msg:N +unseen:N +#+END_EXAMPLE + +Finally a status:OK empty response indicates completion. + +**** mark-add / mark-del + +These operations are inverse of each other, so request and response are the same. + +***** request + +#+BEGIN_EXAMPLE +op:mark-(add / del) +folder:... +msgs:... +seqs:...; +#+END_EXAMPLE + +***** response + +The empty response. + +**** TODO the rest + +** IMAP + +*** New profile parameters + +- [folder]-host, [folder]-port - to connect +- [folder]-tls TLS configuration +- [folder]-user: username +- [folder]-auth: PREAUTH | SASL - how to authenticate +- [folder]-authservice: for XOAUTH2 +- [folder]-saslmech: force a SASL mechanism in case nmh chooses the wrong default +- [folder]-db: path to the database +- [folder]-mailbox-root: only look for mailboxes under this root + +*** Folder store + +imap-folder-manager needs to store the following per folder (mailbox): + +- forward and reverse map of message UID and local message number +- mailbox UIDNEXT +- mailbox UIDVALIDITY + +We store UIDNEXT and UIDVALIDITY in plain text files. For the UID maps, we +need something fast. + +LMDB is a simple and fast embedded memory-mapped database library with ACID +semantics, and seems like a reasonable choice. It might be worth trying out +some alternatives, but this is a fine place to start. + +lmdb-rkv is a Rust wrapper from Mozilla which seemed like a decent choice in 2020, +but that was before the Great Rust Layoff, and it now seems to be abandoned. +It might be worth looking into taking it over, or finding an alternative, or just +building our own simple binding only exposing what we need. + +*** mhimap - a new nmh command for manipulating the imap-folder-manager + +Currently, the only facilities this offers are for the tests. They are: + +- start +- shutdown +- noop +- check + +=shutdown= may be useful to users who don't want to leave the authenticated +process running indefinitely. + +=check= verifies the integrity of the synchronization database. + +*** Code organization + +**** /sbr/rust - crate nmh + +=nmh= is the base crate. It offers Rusty wrappers around the core subroutines +in the =sbr= directory (=libmh.a=). + +**** /managedfolders + +The =managed_folders= crate implements the folder-manager protocol. +Nothing specific to IMAP is here; if anyone wanted to implement a different +folder manager, they could use this. + +**** /imap/types + +Exposes types common to the 4 IMAP crates. + +**** /imap/store + +This crate implements Folder store described above. + +**** /imap/folder-manager + +This is =imap-folder-manager= itself. + +**** /imap/mhimap + +This is the =mhimap= command described above. + +** Dependencies + +*** Direct +libc = 0.2.78 +lmdb-rkv = 0.14 +lmdb-rkv-sys = 0.11 +log = 0.4 + +*** Indirect +bitflags = 1.2.1 +byteorder = 1.3 +cc = 1.0.60 +cfg-if = 0.1.10 +pkg-config = 0.3.18 + +*** Unstable +cfg-if = "0.1.10" +libc = "0.2.78" +lmdb-rkv = "0.14.0" +lmdb-rkv-sys = "0.11.0" +log = "0.4.13" +pkg-config = "0.3.18" + +*** License +pkg-config MIT/Apache-2.0 +bitflags MIT/Apache-2.0 +byteorder MIT +cc MIT/Apache-2.0 +cfg-if MIT/Apache-2.0 +libc MIT OR Apache-2.0 +lmdb-rkv Apache-2.0 +lmdb-rkv-sys Apache-2.0 +log MIT OR Apache-2.0 + +* Testing + +Look in the =testing= directory beside this file for more information. + +- =tests/imap/test-live= exercises as much of the IMAP functionality as possible + against any supported server. Because we run it against all servers, any test + that can live here should live here. + +- New tests using the common nmh test suite, run under =make check=. + These tests use the classic Panda IMAP server. =make check= also includes + =tests/imap/test-live=, against Panda only. Add test cases at this level only + if they can't be written for =test-live=. + +- Rust "integration tests", which go in the =tests= directory of each crate. + Add test cases at this level only if they can't be written for the common nmh + test suite. + +- Rust "unit tests", which go in =tests= sub-modules under the =src= directory + of each crate. Add test cases at this level only if they can't be written as + Rust "integration tests". + +* Supported servers somewhere + +** Free implementations + +- Cyrus +- Dovecot +- Panda + +** Service providers + +- Gandi (they seem to run Dovecot) +- GMail diff --git a/docs/imap/testing/cyrus/cyrus.conf b/docs/imap/testing/cyrus/cyrus.conf new file mode 100644 index 00000000..a437c2e1 --- /dev/null +++ b/docs/imap/testing/cyrus/cyrus.conf @@ -0,0 +1,18 @@ +START { + recover cmd="ctl_cyrusdb -r" +} + +SERVICES { + imap cmd="imapd" listen="127.0.0.1:imap" prefork=0 +} + +EVENTS { + checkpoint cmd="ctl_cyrusdb -c" period=30 + delprune cmd="cyr_expire -E 3" at=0400 + deleteprune cmd="cyr_expire -E 4 -D 28" at=0430 + expungeprune cmd="cyr_expire -E 4 -X 28" at=0445 + tlsprune cmd="tls_prune" at=0400 +} + +DAEMON { +} diff --git a/docs/imap/testing/cyrus/imapd.conf b/docs/imap/testing/cyrus/imapd.conf new file mode 100644 index 00000000..1ece3384 --- /dev/null +++ b/docs/imap/testing/cyrus/imapd.conf @@ -0,0 +1,14 @@ +configdirectory: /var/imap +partition-default: /var/spool/imap + +unixhierarchysep: yes + +allowplaintext: yes + +autocreate_quota: 0 + +sasl_pwcheck_method: auxprop + +# TODO unneeded right? +sieveusehomedir: false +sievedir: /var/imap/sieve diff --git a/docs/imap/testing/dovecot.conf b/docs/imap/testing/dovecot.conf new file mode 100644 index 00000000..6f5b6308 --- /dev/null +++ b/docs/imap/testing/dovecot.conf @@ -0,0 +1,35 @@ +base_dir = /home/epg/work/nmh/.o/std/dovecot +state_dir = /home/epg/work/nmh/.o/std/dovecot/db +log_path = /home/epg/work/nmh/.o/std/dovecot/log + +default_internal_user = epg +default_login_user = epg +default_internal_group = epg + +mail_location = mbox:/home/epg/work/nmh/.o/std/dovecot/db/mail/%d/%1n/%n:INDEX=/home/epg/work/nmh/.o/std/dovecot/db/indexes/%d/%1n/%n + +protocols = imap +listen = 127.0.0.1 + +passdb { + driver = static + args = password=testpw +} + +namespace inbox { + inbox = yes +} + +service anvil { + chroot = +} + +service imap-login { + chroot = + inet_listener imap { + port = 1043 + } + inet_listener imaps { + port = 1044 + } +} diff --git a/docs/imap/testing/notes b/docs/imap/testing/notes new file mode 100644 index 00000000..269da422 --- /dev/null +++ b/docs/imap/testing/notes @@ -0,0 +1,7 @@ +* cyrus + +~sudo saslpasswd2 -c test~ + +* panda + +All IMAP tests run against panda with a simple ~make check~. diff --git a/docs/imap/testing/run b/docs/imap/testing/run new file mode 100755 index 00000000..b679735e --- /dev/null +++ b/docs/imap/testing/run @@ -0,0 +1,254 @@ +#!/usr/local/bin/zsh + +# Quick-n-dirty script to ease running the IMAP tests in various configurations. +# Also generates coverage reports (requires Rust nightly). +# TODO redo in more standard fashion under `make check`. + +# Configuration: +# export MH_TEST_IMAP_LOG_LEVEL=trace +# export MH_TEST_NOCLEANUP=1 +# export MH_TEST_STUNNEL_DEBUG=7 +# export MH_TEST_STUNNEL_OUTPUT=/dev/stderr +# export MH_TEST_VERBOSE=1 +# export RUST_BACKTRACE=1 +# abs_srcdir=$HOME/work/nmh +# abs_builddir=$abs_srcdir/.o/clang13 +# LLVM_PROFDATA=llvm-profdata13 +# LLVM_COV=llvm-cov13 +# MAKE=make +# MAKE_JOBS=-j4 +# PANDA_IMAPD=/usr/local/libexec/imapd +# # optional: +# gandi_user='example@example.com' +# gandi_pw='...' +#gmail_user='example@example.com' +#gmail_oauth=/path/to/oauth-gmail + +set -ex + +abs_builddir=$PWD + +cf=$1 +if ! [[ -n $cf ]]; then + print -u2 "usage imaptest CONFIGURATION ACTION" + exit 2 +fi +shift + +. $cf + +for v in \ + abs_srcdir \ + LLVM_PROFDATA \ + LLVM_COV \ + MAKE \ + PANDA_IMAPD \ + ; do + if ! eval "[[ -n \$$v ]]"; then + print -u2 "$v must be set in $cf" + exit 2 + fi +done + +testdir=$abs_builddir/test/testdir + +export CARGO_TARGET_DIR=$abs_builddir/target + +# Coverage documentation +# https://doc.rust-lang.org/beta/unstable-book/compiler-flags/source-based-code-coverage.html +# https://clang.llvm.org/docs/SourceBasedCodeCoverage.html +# https://llvm.org/docs/CommandGuide/llvm-profdata.html +# https://llvm.org/docs/CommandGuide/llvm-cov.html +# Bugs +# https://github.com/rust-lang/rust/issues/79121 Tracking Issue for LLVM InstrProf code coverage +# https://github.com/rust-lang/rust/issues/85902 Add a cargo option to generate coverage reports using -Z instrument-coverage +# https://github.com/rust-lang/rust/issues/79649 Add support for branch coverage in source-based coverage +report-coverage() { + typeset profile=$1 + typeset profile_dir=$2 + typeset profdata=$abs_builddir/covdata/$profile.profdata + find $abs_builddir/covdata/$profile_dir -name '*.profraw' | $nop xargs $LLVM_PROFDATA merge --sparse \ + -o $profdata + $nop $LLVM_COV show -Xdemangler=$HOME/.cargo/bin/rustfilt '--ignore-filename-regex=/tests/' '--ignore-filename-regex=/tests\.rs' '--ignore-filename-regex=\.cargo' --format=html \ + -o $abs_builddir/covdata/html/$profile --instr-profile=$profdata \ + ${=${(j: --object :)test_objects}} +} + +check-litter() { + setopt LOCAL_OPTIONS NULL_GLOB + typeset litter=($testdir/*.actual) + (( $#litter > 0 )) || return 0 + print -u2 "$1 littered $litter" + false +} + +report_only=false +coverage=false +typeset -a actions profiles +while [[ $# -gt 0 ]]; do + word=$1 + shift + case $word in + -n) + nop=(print --) + ;; + + -cover*) + coverage=: + if [[ $word = -cover-report ]]; then + report_only=: + else + $nop rm -rf $abs_builddir/covdata + fi + . ~/.cargo/env + export RUSTFLAGS="-Zinstrument-coverage $RUSTFLAGS" + ;; + + all) + # We test implicit TLS with Gmail and explicit with Gandi; any reason to test both ways both places? + set -- cargot test live/panda live/dovecot live/cyrus live/gandi-starttls live/gmail "$@" + ;; + + build|clean) + actions+=$word + ;; + + cargot|test|live/*) + actions+=$word + profiles+=$word + ;; + + test/*) + actions+=$word + profiles+=test + ;; + + *) + print -u2 "get outta here $word" + exit 3 + ;; + esac +done + +if ! $report_only; then +for action in $actions; do + case $action in + build) + (cd $abs_builddir && $nop $MAKE $MAKE_JOBS all cargo) + ;; + + cargot) + $nop env LLVM_PROFILE_FILE="$abs_builddir/covdata/cargot/%m-%p.profraw" cargo t -- --test-threads=1 + ;; + + test) + tests=( + test/imap/test-astring + test/imap/test-bad + test/imap/test-bad-fetch-body + test/imap/test-folder-create-disallowed + # Downloads all mail from your test account; better be sure you want that! + # test/imap/test-gmail-all + test/imap/test-inc + test/imap/test-inc-missing-msg + test/imap/test-inc-missing-msg-case-2 + # Handled specially below. + # test/imap/test-live + test/imap/test-login + test/imap/test-mark + test/imap/test-quirks-gmail + # TODO fix the bitrot + # test/imap/test-refile + test/imap/test-rmm + test/imap/test-scan + test/imap/test-server-drops + test/imap/test-uidvalidity + test/imap/test-xoauth + ) + (cd $abs_builddir && for test in $tests; do + $nop env PANDA_IMAPD=$PANDA_IMAPD MH_COVERAGE_PROFILE=test $MAKE check TESTS=$test + check-litter $test + done) + ;; + + test/*) + (cd $abs_builddir && $nop env PANDA_IMAPD=$PANDA_IMAPD MH_COVERAGE_PROFILE=test $MAKE check TESTS=$action) + check-litter $action + ;; + + live/panda) + (cd $abs_builddir && $nop env PANDA_IMAPD=$PANDA_IMAPD MH_COVERAGE_PROFILE=live/panda MH_IMAP_TEST_SERVER=panda \ + $MAKE check TESTS=test/imap/test-live) + check-litter $action + ;; + + live/dovecot) + (cd $abs_builddir && $nop env MH_COVERAGE_PROFILE=live/dovecot MH_IMAP_TEST_SERVER=127.0.0.1 MH_IMAP_TEST_PORT=1043 \ + MH_IMAP_TEST_USER=test MH_IMAP_TEST_PASSWORD=testpw MH_IMAP_TEST_EMAIL_ADDRESS=test MH_IMAP_TEST_TLS=no \ + MH_IMAP_SERVER_DRAFT_BORN_UNSEEN=' ' MH_IMAP_SERVER_EATS_TRAILING_EOL=' ' \ + $MAKE check TESTS=test/imap/test-live) + check-litter $action + ;; + + live/cyrus) + (cd $abs_builddir && $nop env MH_COVERAGE_PROFILE=live/cyrus MH_IMAP_TEST_SERVER=127.0.0.1 MH_IMAP_TEST_PORT=143 MH_IMAP_TEST_USER=test \ + MH_IMAP_TEST_PASSWORD=test MH_IMAP_TEST_EMAIL_ADDRESS=test MH_IMAP_TEST_TLS=no MH_IMAP_TEST_MAILBOX_ROOT=test/ \ + MH_IMAP_SERVER_DRAFT_BORN_UNSEEN='U' \ + $MAKE check TESTS=test/imap/test-live) + check-litter $action + ;; + + # Looks like Gandi use Dovecot + live/gandi) + (cd $abs_builddir && $nop env MH_COVERAGE_PROFILE=live/gandi MH_IMAP_TEST_SERVER=mail.gandi.net MH_IMAP_TEST_PORT=imaps \ + MH_IMAP_TEST_USER=$gandi_user MH_IMAP_TEST_PASSWORD=$gandi_pw MH_IMAP_TEST_EMAIL_ADDRESS=$gandi_user \ + MH_IMAP_TEST_TLS=implicit MH_IMAP_TEST_MAILBOX_ROOT=test/ \ + MH_IMAP_SERVER_DRAFT_BORN_UNSEEN=' ' \ + $MAKE check TESTS=test/imap/test-live) + check-litter $action + ;; + + live/gandi-starttls) + (cd $abs_builddir && $nop env MH_COVERAGE_PROFILE=live/gandi-starttls MH_IMAP_TEST_SERVER=mail.gandi.net MH_IMAP_TEST_PORT=imap \ + MH_IMAP_TEST_USER=$gandi_user MH_IMAP_TEST_PASSWORD=$gandi_pw MH_IMAP_TEST_EMAIL_ADDRESS=$gandi_user \ + MH_IMAP_TEST_TLS=explicit MH_IMAP_TEST_MAILBOX_ROOT=test/ \ + MH_IMAP_SERVER_DRAFT_BORN_UNSEEN=' ' \ + $MAKE check TESTS=test/imap/test-live) + check-litter $action + ;; + + live/gmail) + (cd $abs_builddir && $nop env MH_COVERAGE_PROFILE=live/gmail MH_IMAP_TEST_SERVER=gmail MH_IMAP_TEST_PORT=imaps MH_IMAP_TEST_USER=$gmail_user \ + MH_IMAP_TEST_OAUTH_FILE=$gmail_oauth MH_IMAP_TEST_EMAIL_ADDRESS=$gmail_user MH_IMAP_TEST_MAILBOX_ROOT=test/ \ + MH_IMAP_SERVER_DRAFT_BORN_UNSEEN='U' \ + $MAKE check TESTS=test/imap/test-live) + check-litter $action + ;; + + report-coverage) + ;; + + clean) + $nop rm -rf $abs_builddir/target + $nop rm -rf $testdir + ;; + esac +done +fi + +if $coverage; then + test_objects=( + $abs_builddir/target/debug/imap-folder-manager + $($nop env LLVM_PROFILE_FILE="$abs_builddir/covdata/cargot/%m-%p.profraw" cargo t --tests --no-run --message-format=json 2>/dev/null \ + | $nop perl -MJSON::PP -lne 'eval { $item = decode_json($_) }; if ($@) { @all=(); die($@) } $fns = $item->{profile}->{test} && $item->{filenames}; $fns && push(@all, @$fns); END { print join("\n", @all) }') + ) + print -l $test_objects + cd $abs_srcdir + report-coverage all + (( ${profiles[(i)live/*]} <= ${#profiles} )) && report-coverage live live + for profile in $profiles; do + report-coverage $profile $profile + done + $nop chmod -R a+rX $abs_builddir/covdata/html + exit +fi diff --git a/guide b/guide deleted file mode 100644 index b930430c..00000000 --- a/guide +++ /dev/null @@ -1,9 +0,0 @@ -# guide file for acme(1) - -make clean make ctags 'CTAGS=atags' -sh autogen.sh -./configure '--prefix=/usr/local' - -tags grep -rn . - -git diff git status git pull diff --git a/h/folder.h b/h/folder.h new file mode 100644 index 00000000..bae4ae1f --- /dev/null +++ b/h/folder.h @@ -0,0 +1,40 @@ +/* folder.h -- mh_folder and associated operations.. + * + * TODO - Replace most (all?) direct use of folder_read, m_convert, etc. with + * common operations. To start with, move each command's own version in here. + * Once they're all factored out, we can factor out commonalities and reduce to + * the smallest set of common operations. + * + * Operations are: + * - Open + * - Read + * - Select + * - ??? + * - Close + */ + +typedef struct mh_folder mh_folder; + +bool +mh_folder_open(mh_folder **result, char const *name, mh_managed_folders *mmfs); + +char const * +mh_folder_get_err_string(mh_folder const *mhf); + +void +mh_folder_close(mh_folder *mhf); + +/* TODO Factor just a few common message selection routines out of all these + * after message selection has been factored out of all uip programs. */ + +// TODO Do we need to chdir? +struct msgs const * +mh_folder_select_and_chdir_for_scan(mh_folder *mhf, struct msgs_array msgs); +bool +mh_folder_message_is_unseen_for_scan(mh_folder *mhf, int msgnum); + +struct msgs const * +mh_folder_select_and_chdir_for_send(mh_folder *mhf, char *const *msgs, size_t msg_count); + +struct msgs const * +mh_folder_select_and_chdir_and_mark_seen(struct msgs_array *vec, mh_folder *mhf, struct msgs_array msgs); diff --git a/h/folder_manager_protocol.h b/h/folder_manager_protocol.h new file mode 100644 index 00000000..2f59f4c9 --- /dev/null +++ b/h/folder_manager_protocol.h @@ -0,0 +1,16 @@ + +// TODO opaque (or just replace with the rust implementation) +typedef struct { + char name[NAMESZ], value_buf[BUFSIZ]; + m_getfld_state_t getfld_ctx; +} mh_folder_manager_protocol_read_message_t; + +bool +mh_folder_manager_protocol_read_message(svector_t, mh_folder_manager_protocol_read_message_t *); + +bool +mh_folder_manager_protocol_write_message(FILE *fp, char const *message, size_t partial_len); + +/* TODO out of place */ +void +free_svector_and_elements(svector_t *); diff --git a/h/managed_folders.h b/h/managed_folders.h new file mode 100644 index 00000000..a20fd4be --- /dev/null +++ b/h/managed_folders.h @@ -0,0 +1,133 @@ +typedef struct mh_managed_folders mh_managed_folders; + +typedef struct mh_managed_folder mh_managed_folder; + +// TODO separate .h / .c file for mh_folder_manager_*? +typedef struct mh_folder_manager_client mh_folder_manager_client; +typedef struct mh_folder_manager_inc_context mh_folder_manager_inc_context; + +/* + * On error, return false and set an error in mmfs; mmfs is always allocated. + */ +bool +mh_managed_folders_open(mh_managed_folders **mmfs); + +/* + * Return null-terminated error message after some function indicated an error. + * + * Never returns NULL, but must not be called if an error was not indicated. + * Return value must be freed ONLY via mh_managed_folders_free. + */ +char const * +mh_managed_folders_get_err_string(mh_managed_folders *); + +char const * +mh_managed_folder_get_err_string(mh_managed_folder *); + +/* + * Return managed folder information if folder is managed, else NULL. + */ +mh_managed_folder * +mh_managed_folder_lookup(mh_managed_folders const *mmfs, char const *name, size_t name_len); + +char const * +mh_managed_folder_manager_path(mh_managed_folder const *mmf); + +char const * +mh_managed_folder_socket_path(mh_managed_folder const *mmf); + +void +mh_managed_folders_free(mh_managed_folders *mmfs); + +char const * +mh_managed_folder_name(mh_managed_folder const *mmf); + +bool +mh_folder_manager_connect(mh_folder_manager_client **mfmc, mh_managed_folder *mmf); + +bool +mh_folder_manager_close(mh_folder_manager_client *mfmc); + +int +mh_folder_manager_create_draft( + mh_folder_manager_client *mfmc, + char const *draft_folder); + +bool +mh_folder_manager_make_folder( + mh_folder_manager_client *mfmc, + char const *folder); + +bool +mh_folder_manager_update_draft( + mh_folder_manager_client *mfmc, + char const *draft_folder, + int msgnum); + +/* + * Returns NULL on error. + */ +mh_folder_manager_inc_context * +mh_folder_manager_inc(mh_folder_manager_client *mfmc, char const *folder); + +bool +mh_folder_manager_folder_read(mh_folder_manager_client *mfmc, char const *dfolder); + +bool +mh_folder_manager_refile_move( + mh_folder_manager_client *mfmc, + char const *src_folder, + struct msgs_array msgs, + char const *dst_folder); + +bool +mh_folder_manager_refile_file( + mh_folder_manager_client *mfmc, + char const *src_file, + char const *dst_folder); + +typedef struct { + bool done; + char *folder; + int msg; + bool unseen; +} mh_managed_folder_inc_page; + +/* + * Return NULL on error. + */ +mh_managed_folder_inc_page * +mh_folder_manager_inc_next(mh_folder_manager_inc_context *mfinc); + +/* Ask folder manager to pick messages specified by `msgs`. + * + * Folder manager writes picked messages into the special sequence 'all'. + * + * Ask the folder manager to set the unseen sequence if `need_unseen` is 1 and to delete it if 0. + * Any value other than 0 or 1 is an error. + */ +bool +mh_folder_manager_pick(mh_folder_manager_client *mfmc, char const *folder, struct msgs_array msgs, int need_unseen); + +bool +mh_folder_manager_rmf(mh_folder_manager_client *mfmc, char const *folder); + +bool +mh_folder_manager_rmm(mh_folder_manager_client *mfmc, char const *folder, struct msgs_array msgs); + +bool +mh_folder_manager_mark_add( + mh_folder_manager_client *mfmc, + char const *folder, + struct msgs_array msgs, + struct svector const *sequences); + +bool +mh_folder_manager_mark_del( + mh_folder_manager_client *mfmc, + char const *folder, + struct msgs_array msgs, + struct svector const *sequences); + +void +mh_managed_folder_set_err(mh_managed_folder *mmf, char const *error_message); diff --git a/h/oauth.h b/h/oauth.h index 942e375a..9432e679 100644 --- a/h/oauth.h +++ b/h/oauth.h @@ -105,11 +105,8 @@ struct mh_oauth_service_info { * If refreshing, writes freshened tokens to disk. * * Exits via adios on any error. - * - * Always returns OK for now, but in the future could return NOTOK on error. */ - -int +void mh_oauth_do_xoauth(const char *user, const char *svc, unsigned char **oauth_res, size_t *oauth_res_len, FILE *log); diff --git a/imap/folder-manager/Cargo.toml b/imap/folder-manager/Cargo.toml new file mode 100644 index 00000000..4422c652 --- /dev/null +++ b/imap/folder-manager/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "imap-folder-manager" +# TODO derive from nmh version +version = "0.0.0" +edition = "2018" + +[dependencies] +libc = "0.2.78" +log = { version = "0.4", features = ["std"] } +# nmh +managed-folders = { path = "../../managedfolders" } +nmh = { path = "../../sbr/rust" } +nmhimapstore = { path = "../store" } +nmhimaptypes = { path = "../types" } diff --git a/imap/folder-manager/src/cf.rs b/imap/folder-manager/src/cf.rs new file mode 100644 index 00000000..b6320811 --- /dev/null +++ b/imap/folder-manager/src/cf.rs @@ -0,0 +1,140 @@ +use std::num::NonZeroU32; +use std::path::{Path, PathBuf}; + +use nmh::{netsec, profile, Netsec}; + +use crate::mailbox_rule::MailboxRules; +use crate::regex::Regex; + +/// Short-lived configuration only needed for startup. +pub struct Init<'a> { + pub socket_path: &'a Path, + pub auth: Auth, +} + +impl<'a> Init<'a> { + pub fn new(prefixed: &'a profile::Prefixed) -> Result { + Ok(Self { + socket_path: socket_path(prefixed.get_or_err("socket")?)?, + auth: Auth::new(prefixed)?, + }) + } +} + +/// [crate::manager::Manager]'s configuration. +#[derive(Debug)] +pub struct Manager { + pub db_path: PathBuf, + pub mailbox_root: String, + pub max_fetch_messages_in_flight: NonZeroU32, + pub rules: crate::mailbox_rule::MailboxRules, +} + +impl Manager { + pub fn new(prefixed: &profile::Prefixed) -> Result { + Ok(Self { + db_path: Path::new(prefixed.get_or_err("db")?).to_owned(), + mailbox_root: prefixed.get("mailbox-root").unwrap_or("").trim().to_owned(), + // TODO Reasonable defaults? Or make the user pick? + max_fetch_messages_in_flight: match prefixed.get("max-fetch-messages-in-flight") { + Some(s) => s.trim().parse::().map_err(|_| { + "{}-max-fetch-messages-in-flight must be non-zero unsigned 32-bit integer" + })?, + None => NonZeroU32::new(100).expect("100 is non-zero"), + }, + rules: mailbox_rules(prefixed.iter()), + }) + } +} + +pub enum Auth { + PREAUTH(netsec::Builder, nmh::client::Connector), + SASL(netsec::Builder, TLS, nmh::client::Connector), +} + +impl Auth { + pub fn new(prefixed: &profile::Prefixed) -> Result { + let connector = nmh::client::Connector::new() + .server(prefixed.get_or_err("host")?)? + .service(prefixed.get_or_err("port")?)?; + let tls_type: TLS = prefixed.get_or_err("tls")?.parse()?; + let auth_type = prefixed.get_or_err("auth")?.trim(); + Ok(match auth_type { + "PREAUTH" => { + match tls_type { + TLS::Nah => {} + _ => todo!("PREAUTH only implemented for tests; good enough?"), + } + Self::PREAUTH(Netsec::builder().snoop(), connector) + } + "SASL" => Self::SASL( + { + let mut builder = Netsec::builder(); + if let Some(oauth_service) = prefixed.get("authservice") { + builder.sasl_oauth_service(oauth_service.trim())? + } + if let Some(mech) = prefixed.get("saslmech") { + builder.sasl_prefer_mechanism(mech.trim())? + } + builder + .sasl_service("imap")? + .sasl_userid(prefixed.get_or_err("user")?)? + .host(connector.get_server()) + .snoop() + }, + tls_type, + connector, + ), + _ => todo!("unknown auth parameter: {}", auth_type), + }) + } +} + +pub enum TLS { + Explicit, + Implicit, + Nah, + Yolo, + // TODO skipping certificate verification is orthogonal to explicit vs. implicit + ExplicitYolo, +} + +impl std::str::FromStr for TLS { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "explicit" => Ok(Self::Explicit), + "implicit" => Ok(Self::Implicit), + "don't protect my data i know what i'm doing" => Ok(Self::Nah), + "implicit but don't verify the cert i already did out of band" => Ok(Self::Yolo), + "explicit but don't verify the cert i already did out of band" => { + Ok(Self::ExplicitYolo) + } + _ => Err(format!("unknown TLS configuration {}", s)), + } + } +} + +fn socket_path(s: &str) -> Result<&Path, String> { + // TODO(rust-lang#62061): const std::mem::zeroed + let sun: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + let len = sun.sun_path.len() - 1; + if s.len() > len { + Err(format!("socket path {} > system limit of {} bytes", s, len)) + } else { + Ok(Path::new(s)) + } +} + +fn mailbox_rules(iter: profile::PrefixedIterator) -> MailboxRules { + let mut rules = MailboxRules::new(); + for (key, value) in iter { + if key == "mailbox-include" { + rules.include(Regex::compile(value)) + } + if key == "mailbox-exclude" { + rules.exclude(Regex::compile(value)) + } + } + rules +} diff --git a/imap/folder-manager/src/error.rs b/imap/folder-manager/src/error.rs new file mode 100644 index 00000000..a81fc55e --- /dev/null +++ b/imap/folder-manager/src/error.rs @@ -0,0 +1,128 @@ +//! Support for recovering from errors. +//! +//! We use methods consuming the `self` parameter for safety, but we want to +//! recover from some errors. Such methods can return +//! `Result<(Data, T), RecoverableError>`. + +pub struct RecoverableError { + recoverable: Option, + source: Box, +} + +impl RecoverableError { + /// Return an `Error` wrapping `source` and holding `recoverable`, which may be [RecoverableError::recover]ed. + pub fn recoverable(source: E, recoverable: T) -> Self { + Self { + recoverable: Some(recoverable), + source: Box::new(source), + } + } + + /// Return an `Error` wrapping `source` from which nothing may be [RecoverableError::recover]ed. + pub fn unrecoverable(source: E) -> Self { + Self { + recoverable: None, + source: Box::new(source), + } + } + + /// Return a tuple of the `(recovered, source)` parameters of [RecoverableError::recoverable] or + /// the `source` parameter of [RecoverableError::unrecoverable]. Callers who must return their + /// own [RecoverableError] may benefit from [RecoverMapper]. + pub fn recover(self) -> Result<(T, E), E> { + match self.recoverable { + None => Err(*self.source), + Some(recovered) => Ok((recovered, *self.source)), + } + } +} + +impl std::fmt::Debug + for RecoverableError +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}recoverable: {:?}", + if self.recoverable.is_none() { "un" } else { "" }, + self.source + ) + } +} + +impl std::fmt::Display + for RecoverableError +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.source.fmt(f) + } +} + +impl std::error::Error + for RecoverableError +{ + fn source(&self) -> Option<&(dyn 'static + std::error::Error)> { + Some(self.source.as_ref()) + } +} + +/// Helps consumers of [RecoverableError] of one recovered type map to their own recovered type. +/// +/// Only good for one use. +/// Either [RecoverMapper::map] or [RecoverMapper::ok] may be called, and only once. +/// +/// ``` +/// rm = RecoverMapper::new(&|foo, foo_holder| foo_holder.rebuild(foo), foo_holder); +/// // on error, map RecoverableError to RecoverableError +/// foo.fallible().map_err(|e| rm.map(e))?; +/// // success case +/// foo_holder = rm.ok(); +/// ``` +pub struct RecoverMapper<'a, From, To, Context> +where + From: std::fmt::Debug, + To: std::fmt::Debug, +{ + context: Option, + mapper: &'a dyn Fn(From, Context) -> To, + _p1: std::marker::PhantomData, + _p2: std::marker::PhantomData, +} + +impl<'a, From, To, Context> RecoverMapper<'a, From, To, Context> +where + From: std::fmt::Debug, + To: std::fmt::Debug, +{ + /// Create a `RecoverMapper` whose [RecoverMapper::map] method calls `mapper(e, context)` where + /// `e` is the parameter to [RecoverMapper::map] and `context` is the parameter here. + pub fn new(mapper: &'a dyn Fn(From, Context) -> To, context: Context) -> Self { + Self { + context: Some(context), + mapper, + _p1: std::marker::PhantomData, + _p2: std::marker::PhantomData, + } + } + + /// Map one RecoverableError to another using `mapper` supplied to [RecoverMapper::new]. + /// Panic if called twice, or after a call to [RecoverMapper::ok]. + pub fn map( + &mut self, + from: RecoverableError, + ) -> RecoverableError { + match from.recoverable { + None => RecoverableError::unrecoverable(*from.source), + Some(recovered) => RecoverableError::recoverable( + *from.source, + (self.mapper)(recovered, self.context.take().expect("RecoverMapper reuse")), + ), + } + } + + /// Take back the `context` passed to [RecoverMapper::new]. + /// Panic if called twice, or after a call to [RecoverMapper::map]. + pub fn ok(self) -> Context { + self.context.expect("RecoverMapper::ok called after error") + } +} diff --git a/imap/folder-manager/src/imap.rs b/imap/folder-manager/src/imap.rs new file mode 100644 index 00000000..6c55a94c --- /dev/null +++ b/imap/folder-manager/src/imap.rs @@ -0,0 +1,49 @@ +//! IMAP +//! +//! RFC 3501 defines these states: +//! - 3.1. `Not Authenticated State` +//! We start here. Use [auth::login] to move to... +//! - 3.2. `Authenticated State` +//! Modeled by [Session]. +//! - 3.3. `Selected State` +//! Modeled by [mailbox::Session]. +//! Entered via [Session::select]. +//! Return to `Authenticated State` with [mailbox::Session::close]. +//! - 3.4. `Logout State` +//! Entered when we say `LOGOUT` in `Drop` or server says `BYE`. Either way, we +//! close the connection in `Drop`. + +pub mod auth; +pub mod mailbox; +mod protocol; +mod session; + +pub use session::{Error, FetchResult, MessageReader, MessageResult, Session}; + +pub enum Flag { + /// `\Deleted` + Deleted, + /// `\Draft` + Draft, + /// `\Seen` + Seen, + // Custom(String) ? +} + +impl std::fmt::Display for Flag { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Deleted => "\\Deleted", + Self::Draft => "\\Draft", + Self::Seen => "\\Seen", + }) + } +} + +/// Returned by [Session::list]. +#[derive(Eq, Ord, PartialEq, PartialOrd)] +pub struct Mailbox { + pub name: mailbox::Name, + /// Whether [Session::select] and [mailbox::Session::select] will work with this mailbox. + pub selectable: bool, +} diff --git a/imap/folder-manager/src/imap/auth.rs b/imap/folder-manager/src/imap/auth.rs new file mode 100644 index 00000000..e18bc40e --- /dev/null +++ b/imap/folder-manager/src/imap/auth.rs @@ -0,0 +1,187 @@ +use nmh::{netsec, Error, Netsec}; + +use crate::cf; +use crate::imap::protocol::{ + read_greeting, read_response, Capabilities, Capability, Greeting, ReadResult, Response, + ResponseCode, ServerData, Status, +}; +use crate::imap::Session; + +type Result = std::result::Result; + +// TODO Instead have Netsec and netsec::Authenticator implement a trait? +macro_rules! maybe_read_capabilities_response { + ($name:expr, $netsec:expr) => {{ + let mut capabilities = None; + loop { + let response: &str = $netsec.read_line()?; + match read_response(response)? { + ReadResult::Response(response) => match response { + Response::ServerData(ServerData::Capability(c)) => capabilities = Some(c), + Response::TaggedStatus(_, status) => match status { + Status::Ok(code) => { + if let ResponseCode::Capability(c) = code { + capabilities = Some(c) + } + break; + } + other => todo!("only handling OK: {:?}", other), + }, + _ => {} + }, + _ => todo!("unexpected response to {}: {}", $name, response), + } + } + capabilities + }}; +} +macro_rules! request_capabilities_if_none { + ($opt_capabilities:expr, $netsec:expr) => { + match $opt_capabilities { + Some(capabilities) => capabilities, + None => { + $netsec.write_and_flush(b"1 CAPABILITY\r\n")?; + maybe_read_capabilities_response!("CAPABILITY", $netsec) + .ok_or_else(|| Error::new("server claims no capabilities"))? + } + } + }; +} + +// TODO allow config to override capabilities in case of broken server? +pub fn login(context: &nmh::Context, auth_type: cf::Auth) -> Result { + let (mut netsec, opt_capabilities) = match auth_type { + cf::Auth::PREAUTH(builder, connector) => preauth(builder, connector)?, + cf::Auth::SASL(builder, tls, connector) => sasl(context, builder, tls, connector)?, + }; + let capabilities = request_capabilities_if_none!(opt_capabilities, &mut netsec); + Session::new(netsec, capabilities) +} + +fn preauth( + builder: netsec::Builder, + connector: nmh::client::Connector, +) -> Result<(Netsec, Option)> { + let mut netsec: Netsec = builder.connect(connector)?.skip_tls().skip_sasl(); + let capabilities; + let greeting: &mut str = netsec.read_line()?; + match read_greeting(greeting)? { + Greeting::PreAuth(code) => match code { + ResponseCode::Capability(c) => capabilities = Some(c), + _ => capabilities = None, + }, + _ => todo!("PREAUTH requested but server greeting is {}", greeting), + } + Ok((netsec, capabilities)) +} + +fn sasl( + context: &nmh::Context, + mut builder: netsec::Builder, + tls: crate::cf::TLS, + connector: nmh::client::Connector, +) -> Result<(Netsec, Option)> { + let unauthenticated_capabilities; + let mut netsec_auth: netsec::sasl::Authenticator = match tls { + cf::TLS::Explicit | cf::TLS::ExplicitYolo => { + if let cf::TLS::ExplicitYolo = tls { + builder = builder.skip_certificate_checks() + } + let mut nc: netsec::tls::Connector = builder.connect(connector)?; + let pre_tls_capabilities = + request_capabilities_if_none!(read_ok_greeting(nc.read_line()?)?, &mut nc); + if !pre_tls_capabilities.has(Capability::StartTLS) { + return Err(Error::new("server doesn't offer STARTTLS")); + } + nc.write_and_flush(b"1 STARTTLS\r\n")?; + unauthenticated_capabilities = maybe_read_capabilities_response!("STARTTLS", nc); + nc.negotiate_tls()? + } + cf::TLS::Implicit | cf::TLS::Yolo => { + if let cf::TLS::Yolo = tls { + builder = builder.skip_certificate_checks() + } + let mut netsec_auth: netsec::sasl::Authenticator = + builder.connect(connector)?.negotiate_tls()?; + unauthenticated_capabilities = read_ok_greeting(netsec_auth.read_line()?)?; + netsec_auth + } + cf::TLS::Nah => { + let mut netsec_auth: netsec::sasl::Authenticator = + builder.connect(connector)?.skip_tls(); + unauthenticated_capabilities = read_ok_greeting(netsec_auth.read_line()?)?; + netsec_auth + } + }; + let unauthenticated_capabilities = + request_capabilities_if_none!(unauthenticated_capabilities, &mut netsec_auth); + if !unauthenticated_capabilities.has(Capability::SASLIR) { + return Err(Error::new( + "server doesn't offer SASL-IR; let us know and we'll support this configuration", + )); + } + let mut callback = Callback(None); + let netsec = netsec_auth + .server_supported_sasl_mechanisms(unauthenticated_capabilities.iter().filter_map(|cap| { + match cap { + Capability::Auth(mech) => Some(*mech), + _ => None, + } + })) + .negotiate_sasl(context, &mut callback)?; + Ok((netsec, callback.0)) +} + +struct Callback(Option); + +impl netsec::sasl::Callback for Callback { + fn start( + &mut self, + netsec: &mut Netsec, + selected_mechanism: &str, + initial_client_response: &[u8], + ) -> netsec::sasl::Result<(), Error> { + if initial_client_response.is_empty() { + todo!("empty sasl start initial_client_response not implemented") + } + let b64data: nmh::str::NString = nmh::base64::encode_raw(initial_client_response); + // TODO does anything NOT have SASL-IR capability? https://www.imapwiki.org/Specs says maybe Courier + Ok(netsec.write_with_base64_at_offset_then_crlf_then_flush( + &format!("1 AUTHENTICATE {} {}\r\n", selected_mechanism, b64data), + // TODO 16 is len("1 AUTHENTICATE ") + 16 + selected_mechanism.len() as std::os::raw::c_int, + )?) + } + + fn read(&mut self, _: &mut Netsec) -> netsec::sasl::Result, Error> { + todo!("NETSEC_SASL_READ") + } + + fn write(&mut self, _netsec: &mut Netsec, _message: &[u8]) -> netsec::sasl::Result<(), Error> { + todo!("NETSEC_SASL_WRITE") + // if !message.is_empty() { + // todo!("NETSEC_SASL_WRITE"); + // } + // netsec.write(b"\r\n")?; + // Ok(netsec.flush()?) + } + + fn finish(&mut self, netsec: &mut Netsec) -> netsec::sasl::Result<(), Error> { + self.0 = maybe_read_capabilities_response!("AUTHENTICATE", netsec); + Ok(()) + } + + fn cancel(&mut self) -> netsec::sasl::Result<(), Error> { + todo!("NETSEC_SASL_CANCEL not implemented") + } +} + +fn read_ok_greeting(greeting: &mut str) -> Result> { + Ok(match read_greeting(greeting)? { + Greeting::Ok(code) => match code { + ResponseCode::Capability(capabilities) => Some(capabilities), + _ => None, + }, + _ => todo!("unexpected greeting {}", greeting), + }) +} diff --git a/imap/folder-manager/src/imap/mailbox.rs b/imap/folder-manager/src/imap/mailbox.rs new file mode 100644 index 00000000..1350ff63 --- /dev/null +++ b/imap/folder-manager/src/imap/mailbox.rs @@ -0,0 +1,23 @@ +pub use super::session::mailbox::{Error, Selected, Session}; + +// TODO Support hierarchy separator other than '/'? Cyrus defaults to '.' +#[derive(Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct Name(String); + +impl Name { + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl std::fmt::Display for Name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl From for Name { + fn from(s: String) -> Self { + Name(s) + } +} diff --git a/imap/folder-manager/src/imap/protocol.rs b/imap/folder-manager/src/imap/protocol.rs new file mode 100644 index 00000000..1f33a8e7 --- /dev/null +++ b/imap/folder-manager/src/imap/protocol.rs @@ -0,0 +1,936 @@ +//! IMAP protocol handling used by the high-level [crate::imap::Session]. + +use log::{debug, trace, warn}; + +use nmh::Error; +use nmhimaptypes::{uid, Uid, UidValidity}; + +mod response; +pub use response::{ + Capabilities, Capability, Fetch, Greeting, Response, ResponseCode, ServerData, Status, +}; + +type Result = std::result::Result; + +// greeting = "*" SP ("OK" / "PREAUTH" / "BYE" SP resp-text) CRLF +pub fn read_greeting(line: &mut str) -> Result { + trace!("read_greeting({})", line); + // TODO de-duplicate with read_response / read_untagged + let (first_word, index_of_rest): (&str, usize) = { + let first_space: usize = line + .find(' ') + .ok_or_else(|| Error::new("protocol error: no space in response"))?; + (&line[..first_space], first_space + 1) + }; + match first_word { + "*" => {} + _ => { + return Err(Error::new(format!( + "protocol error: no leading * in {}", + line + ))) + } + } + let mut index = line[index_of_rest..] + .find(' ') + .ok_or_else(|| Error::new("protocol error: resp-text missing from resp-cond-auth"))?; + index += index_of_rest; + line[index_of_rest..index].make_ascii_uppercase(); + let word = &line[index_of_rest..index]; + let code: ResponseCode = read_resp_text(&line[index + 1..])?.0; + match word { + "OK" => Ok(Greeting::Ok(code)), + "PREAUTH" => Ok(Greeting::PreAuth(code)), + "BYE" => todo!("BYE in greeting"), + _ => todo!("protocol error: unexpected greeting {} in {}", word, line), + } +} + +pub fn read_response(line: &str) -> Result { + let (first_word, index_of_rest): (&str, usize) = { + let first_space: usize = line + .find(' ') + .ok_or_else(|| Error::new("protocol error: no space in response"))?; + (&line[..first_space], first_space + 1) + }; + match first_word { + "+" => Ok(ReadResult::CommandContinuationRequest), + "*" => read_untagged(&line[index_of_rest..]), + _ => Ok(ReadResult::Response(Response::TaggedStatus( + first_word.to_owned(), + read_resp_cond_state(&line[index_of_rest..])?, + ))), + } +} + +/// The result of any IMAP read operation, such as [read_response]. +pub enum ReadResult { + /// Server is awaiting client to send a continuation request. + CommandContinuationRequest, + /// Server has provided a response. + Response(Response), + // TODO Should Vec show up here?. Better would be to stash it inside the LineReader? + // we're not returning anything out of that in this case right now anyway. + /// Server has this many bytes for client to read. Caller must read them and then read a line and pass to `LineReader`. + FetchBytes(Vec, usize, LineReader), + /// Server has sent a partial response and caller must read this many bytes and pass bo `ByteReader` in order to continue. + NeedBytes(usize, ByteReader), + /// Server has sent a partial response and caller must read one line and pass bo `LineReader` in order to continue. + NeedLine(LineReader), +} + +impl std::fmt::Debug for ReadResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReadResult::CommandContinuationRequest => { + f.debug_tuple("CommandContinuationRequest").finish() + } + ReadResult::Response(r) => f + .debug_tuple("Response") + .field(&format!("{:?}", r)) + .finish(), + ReadResult::FetchBytes(_, n, reader) => f + .debug_tuple("FetchBytes") + .field(&"Vec") + .field(n) + .field(&format!("{:?}", reader)) + .finish(), + ReadResult::NeedBytes(n, reader) => f + .debug_tuple("NeedBytes") + .field(n) + .field(&format!("{:?}", reader)) + .finish(), + ReadResult::NeedLine(reader) => f + .debug_tuple("NeedLine") + .field(&format!("{:?}", reader)) + .finish(), + } + } +} + +pub struct ByteReader(Option); + +enum ByteReading { + MailboxList(MailboxListReader), +} + +impl ByteReader { + fn new(reading: ByteReading) -> Self { + Self(Some(reading)) + } + + pub fn read_bytes(mut self, bytes: &[u8]) -> Result { + match self.0.take().expect("ByteReader not reusable") { + ByteReading::MailboxList(reader) => reader.read_bytes(bytes), + } + } + + // TODO implement this for use when caller decides to LOGOUT rather than continue + // pub fn abort(mut self) { + // self.0 = None; + // } +} + +impl Drop for ByteReader { + fn drop(&mut self) { + assert!(self.0.is_none()); + } +} + +impl std::fmt::Debug for ByteReader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ByteReader").finish() + } +} + +pub struct LineReader(Option); + +enum LineReading { + Fetch(FetchReader), + MailboxList(MailboxListReader), +} + +impl LineReader { + fn new(reading: LineReading) -> Self { + Self(Some(reading)) + } + + pub fn read_line(mut self, line: &str) -> Result { + match self.0.take().expect("LineReader not reusable") { + LineReading::Fetch(reader) => reader.read_line(line), + LineReading::MailboxList(reader) => reader.read_line(line), + } + } + + // TODO implement this for use when caller decides to LOGOUT rather than continue + // pub fn abort(mut self) { + // self.0 = None; + // } +} + +impl Drop for LineReader { + fn drop(&mut self) { + assert!(self.0.is_none()); + } +} + +impl std::fmt::Debug for LineReader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("LineReader") + .field(match &self.0 { + None => &"None", + Some(LineReading::Fetch(_)) => &"Fetch", + Some(LineReading::MailboxList(_)) => &"MailboxList", + }) + .finish() + } +} + +// msg-att = "(" (msg-att-dynamic / msg-att-static) *(SP (msg-att-dynamic / msg-att-static)) ")" +struct FetchReader { + parens_open: u32, +} + +impl FetchReader { + fn start(line: &str) -> Result { + trace!("FetchReader::new({})", line); + if !(line.starts_with('(') || line.starts_with(')')) { + return Err(Error::new(format!( + "protocol error: msg-att doesn't start with '(' or ')': {}", + line + ))); + } + Self { parens_open: 0 }.read_line(line) + } + + fn read_line(mut self, line: &str) -> Result { + let mut results: Vec = vec![]; + let mut start = 0; + loop { + trace!("FetchReader::read_line loop looking at"); + crate::logging::log_string_parts(log::Level::Trace, line, start, line.len()); + let msg_att = &line[start..]; + if msg_att.is_empty() { + return Err(Error::new( + "protocol error: FETCH response line ends without ')'", + )); + } + match &msg_att[..1] { + "(" => { + self.parens_open += 1; + start += 1; + } + ")" => { + trace!("FetchReader::next Done"); + if self.parens_open > 1 { + return Err(Error::new(format!( + "protocol error: {} more parentheses opened than closed", + self.parens_open + ))); + } + return Ok(ReadResult::Response(Response::ServerData( + ServerData::Fetch(results), + ))); + } + " " => { + start += 1; + } + unexpected => { + return Err(Error::new(format!( + "protocol error: expected [ ()] not {}", + unexpected + ))) + } + } + crate::logging::log_string_parts(log::Level::Trace, line, start, line.len()); + // TODO why did i set this and then reset it + let msg_att: &str = &line[start..]; + let mut iterator = msg_att.match_indices(&[' ', ')'][..]); + let (end_of_word, matched_delimiter): (usize, &str) = + iterator.next().ok_or_else(|| { + Error::new("protocol error: missing next ' ' or ')' in FETCH response") + })?; + let word: &str = &msg_att[..end_of_word]; + trace!( + "FetchReader::next end_of_word={} delimiter={}: {}", + end_of_word, + matched_delimiter, + word + ); + match word { + // TODO we get one of these two + // * 1 FETCH (FLAGS (\Seen \Deleted \Draft) UID 1) + // * 1 FETCH (UID 1 FLAGS (\Seen)) + // * 1 FETCH (UID 1 BODY[] {181} + // TODO in response to UID STORE we get the same data twice! + // => 1 UID STORE 1 -FLAGS (\Seen) + // <= * 1 FETCH (FLAGS () UID 1) + // <= * 1 FETCH (UID 1 FLAGS ()) + "UID" => { + if matched_delimiter == ")" { + return Err(Error::new("protocol error: 'UID' not followed by a uid")); + } + let (end_of_uid, _) = iterator + .next() + .ok_or_else(|| Error::new("protocol error: missing next ' ' or ')'"))?; + start += end_of_uid; + let digits = &msg_att[end_of_word + 1..end_of_uid]; + results.push(Fetch::Uid(Uid::from(read_nz_number(digits)?))); + } + "FLAGS" => { + if matched_delimiter == ")" { + return Err(Error::new("protocol error: 'FLAGS' not followed by flags")); + } + let flags_start = end_of_word + 1; + let flag_start = flags_start + 1; + if msg_att.len() < flag_start { + return Err(Error::new(format!( + "protocol error: msg-att too short: {}", + msg_att + ))); + } + if &msg_att[flags_start..flag_start] != "(" { + return Err(Error::new("protocol error: flags not start with '('")); + } + self.parens_open += 1; + let mut seen = false; + loop { + let (flag_end, matched_delimiter): (usize, &str) = iterator + .next() + .ok_or_else(|| Error::new("protocol error: FLAGS end without ')'"))?; + trace!( + "FetchReader::read_line FLAGS loop matched_delim={} looking at", + matched_delimiter + ); + crate::logging::log_string_parts( + log::Level::Trace, + msg_att, + flag_start, + msg_att.len(), + ); + let flag: &str = &msg_att[flag_start..flag_end]; + // TODO case + if flag == "\\Seen" { + seen = true; + } + if matched_delimiter == ")" { + self.parens_open -= 1; + start += flag_end + 1; + results.push(Fetch::Flags(seen)); + break; + } + } + } + "BODY[]" => { + // RFC3501 doesn't seem to make any promise about ordering, so we might see the UID + // first and then have the bytes to save, or we might have to save the bytes first + // and then keep reading for the UID. + // That would be quite painful. + // All implementations I've encountered so far send UID before BODY. + // Let's see how far we can get without it? + let size_spec: &str = &msg_att[end_of_word + 1..]; + let (size, one_past_end): (usize, usize) = read_literal_size(size_spec)?; + if one_past_end != size_spec.len() { + return Err(Error::new(format!( + "protocol error: characters after size: {}", + &size_spec[one_past_end..] + ))); + } + if self.parens_open > 1 { + return Err(Error::new(format!( + "protocol error: {} parentheses opened when BODY[] received; should be 1", + self.parens_open + ))); + } + return Ok(ReadResult::FetchBytes( + results, + size, + LineReader::new(LineReading::Fetch(self)), + )); + } + "" => { + if matched_delimiter == ")" { + debug!("msg-att = ()? okay..."); + } else { + todo!("what is msg-att here: {}", msg_att); + } + } + r => todo!("what '{:?}'", r), + } + } + } +} + +// mailbox-list = "(" [mbx-list-flags] ")" SP (DQUOTE QUOTED-CHAR DQUOTE / nil) SP "INBOX" / astring +struct MailboxListReader { + flags: String, + // TODO bug-prone having mutually exclusive Options but it's so internal... + name: Option, + reader: Option, +} + +impl MailboxListReader { + fn start(line: &str) -> Result { + trace!("read_mailbox_list({})", line); + if !line.starts_with('(') { + return Err(Error::new( + "protocol error: mailbox-list doesn't start with '('", + )); + } + let mbx_list_flags_close: usize = line + .find(')') + .ok_or_else(|| Error::new("protocol error: mailbox-list missing ')'"))?; + // mbx_list_flags_close = 6 is minimum length: + // 0123456 + // ) "/" f + if line.len() < mbx_list_flags_close + 6 { + return Err(Error::new("protocol error: mailbox-list ends at ')'")); + } + // TODO handle separator. Also it might be /nil/i + let rest = if &line[mbx_list_flags_close + 2..mbx_list_flags_close + 3] == "\"" { + let (separator, rest) = unquote(&line[mbx_list_flags_close + 2..])?; + warn!("TODO handle mailbox separator {}", separator); + warn!( + "TODO after mailbox separator '{}'", + &line[rest + mbx_list_flags_close + 2..] + ); + rest + mbx_list_flags_close + 2 + } else { + todo!("NIL separator not yet supported"); + }; + if line.len() - rest < 2 { + return Err(Error::new( + "protocol error: mailbox-list missing mailbox name", + )); + } + if &line[rest..rest + 1] != " " { + return Err(Error::new( + "protocol error: mailbox-list missing space before mailbox name", + )); + } + let flags = line[1..mbx_list_flags_close].to_lowercase(); + trace!("mbx-list-flags is '{}'", flags); + Ok(match AStringReader::start(&line[rest + 1..])? { + AStringReaderResult::NeedBytes(n, reader) => ReadResult::NeedBytes( + n, + ByteReader::new(ByteReading::MailboxList(Self { + flags, + name: None, + reader: Some(reader), + })), + ), + AStringReaderResult::Done(value, _) => { + trace!("mailbox is '{}'", value); + ReadResult::Response(Response::ServerData(ServerData::List(value, flags))) + } + }) + } + + // TODO bug-prone having both but it's so internal... + fn read_bytes(self, bytes: &[u8]) -> Result { + Ok(ReadResult::NeedLine(LineReader::new( + LineReading::MailboxList(Self { + flags: self.flags, + name: Some(self.reader.expect("AStringReader to be here").read(bytes)?), + reader: None, + }), + ))) + } + fn read_line(self, line: &str) -> Result { + if !line.is_empty() { + return Err(Error::new(format!( + "protocol error: extraneous bytes after mailbox-list: {}", + line + ))); + } + Ok(ReadResult::Response(Response::ServerData( + ServerData::List(self.name.expect("mailbox name to be here"), self.flags), + ))) + } +} + +fn read_untagged(line: &str) -> Result { + trace!("read_untagged({})", line); + if line.is_empty() { + return Err(Error::new("protocol error: empty untagged response")); + } + let (word, rest) = match line.find(' ') { + Some(index) => (line[..index].to_ascii_uppercase(), Some(index + 1)), + None => (line.to_ascii_uppercase(), None), + }; + match word.as_str() { + "OK" => { + // TODO NO and BAD may appear here too! + let index_of_resp_text = rest.ok_or_else(|| { + Error::new("protocol error: resp-text missing from resp-cond-state") + })?; + let (code, _) = read_resp_text(&line[index_of_resp_text..])?; + Ok(ReadResult::Response(Response::UntaggedStatus(Status::Ok( + code, + )))) + } + // "LIST" SP mailbox-list + "LIST" => rest + .map(|index| MailboxListReader::start(&line[index..])) + .ok_or_else(|| Error::new("protocol error: LIST response missing mailbox-list"))?, + "ESEARCH" => { + let messages = match rest { + None => &line[..0], + Some(rest) => &line[rest..], + }; + trace!("captured messages from search: '{}'", messages); + Ok(ReadResult::Response(Response::ServerData( + ServerData::ESearch(read_esearch_response(messages)?), + ))) + } + "SEARCH" => { + let messages = match rest { + None => &line[..0], + Some(rest) => &line[rest..], + }; + trace!("captured messages from search: '{}'", messages); + Ok(ReadResult::Response(Response::ServerData( + ServerData::Search(read_search_response(messages)?), + ))) + } + "CAPABILITY" => { + let capabilities = match rest { + None => &line[..0], + Some(rest) => &line[rest..], + }; + Ok(ReadResult::Response(Response::ServerData( + ServerData::Capability(read_capability_data(capabilities)), + ))) + } + "BYE" => todo!("handle BYE: {}", line), + _ => { + match rest { + None => { + trace!("ignoring {}", word); + Ok(ReadResult::Response(Response::ServerData( + ServerData::Unknown, + ))) + } + Some(rest_index) => { + // We have some cases where `word` is a number and there's a second word (`word2`) we need to look at. + let (word2, word2_rest) = match line[rest_index..].find(' ') { + None => (line[rest_index..].to_ascii_uppercase(), None), + Some(index) => ( + line[rest_index..rest_index + index].to_ascii_uppercase(), + Some(rest_index + index + 1), + ), + }; + match word2.as_str() { + "EXISTS" => match word.parse::() { + Ok(exists) => Ok(ReadResult::Response(Response::ServerData( + ServerData::Exists(exists), + ))), + _ => Ok(ReadResult::Response(Response::ServerData( + ServerData::Unknown, + ))), + }, + // nz-number SP "FETCH" SP msg-att + // nz-number is the message sequence number: we don't care. + "FETCH" => word2_rest + .map(|rest_index| FetchReader::start(&line[rest_index..])) + .ok_or_else(|| { + Error::new("protocol error: FETCH response missing msg-att") + })?, + _ => { + trace!("ignoring {} and {}", word, word2); + Ok(ReadResult::Response(Response::ServerData( + ServerData::Unknown, + ))) + } + } + } + } + } + } +} + +fn unquote(quoted: &str) -> Result<(String, usize)> { + let mut result = String::new(); + let mut escaped = false; + // Assume first byte is " and skip that. + for (i, c) in quoted[1..].char_indices() { + if escaped { + escaped = false; + result.push(c); + } else if c == '\\' { + escaped = true; + } else if c == '"' { + return Ok((result, i + 2)); + } else { + result.push(c); + } + } + Err(Error::new("protocol error: str missing close quote")) +} + +// resp-text = ["[" resp-text-code "]" SP] text +fn read_resp_text(resp_text: &str) -> Result<(ResponseCode, &str)> { + if resp_text.is_empty() { + return Err(Error::new("protocol error: resp-text is empty")); + } + if &resp_text[0..1] != "[" { + return Ok((ResponseCode::Unknown, resp_text)); + } + let close_bracket: usize = resp_text + .find(']') + .ok_or_else(|| Error::new("protocol error: no close bracket in resp-text"))?; + let text = if close_bracket + 1 == resp_text.len() { + debug!("GMAIL QUIRK resp-text: {}", resp_text); + // GMail responds with + // * OK [HIGHESTMODSEQ 8869] + // RFC 3501 defines it as + // resp-text = ["[" resp-text-code "]" SP] text + // text = 1*TEXT-CHAR + // The text is not optional, and if the optional bracketed text MUST be followed by a space. + &resp_text[..0] + } else { + if resp_text.len() < close_bracket + 2 { + return Err(Error::new(format!( + "protocol error: resp-text too short: {}", + resp_text + ))); + } + if &resp_text[close_bracket + 1..close_bracket + 2] != " " { + return Err(Error::new( + "protocol error: after resp-text-code is something not a space", + )); + } + &resp_text[close_bracket + 2..] + }; + let (status, resp_text_code) = match resp_text[1..close_bracket].find(' ') { + Some(index) => { + if index + 2 >= close_bracket { + return Err(Error::new(format!( + "protocol error: resp-text too short: {}", + resp_text + ))); + } + ( + resp_text[1..index + 1].to_ascii_uppercase(), + &resp_text[index + 2..close_bracket], + ) + } + None => ( + resp_text[1..close_bracket].to_ascii_uppercase(), + &resp_text[..1], + ), + }; + Ok(match status.as_str() { + // resp-code-apnd = "APPENDUID" SP nz-number SP append-uid + "APPENDUID" => (read_appenduid(resp_text_code)?, text), + "CAPABILITY" => ( + ResponseCode::Capability(read_capability_data(resp_text_code)), + text, + ), + // resp-code-copy = "COPYUID" SP nz-number SP uid-set SP uid-set + "COPYUID" => (read_copyuid(resp_text_code)?, text), + // "UIDNEXT" SP nz-number + "UIDNEXT" => { + let result = ( + ResponseCode::UidNext(Uid::from(read_nz_number(resp_text_code)?)), + text, + ); + trace!("{:?}", result); + result + } + "UIDVALIDITY" => ( + ResponseCode::UidValidity(UidValidity::from(read_nz_number(resp_text_code)?)), + text, + ), + _ => { + trace!("Unknown response code {} {}", status, text); + (ResponseCode::Unknown, text) + } + }) +} + +fn read_resp_cond_state(resp_cond_state: &str) -> Result { + let index: usize = resp_cond_state.find(' ').ok_or_else(|| { + Error::new(format!( + "protocol error: can't find ' ' in resp-cond-state: {}", + resp_cond_state + )) + })?; + if index + 1 >= resp_cond_state.len() { + return Err(Error::new(format!( + "protocol error: resp-cond-state too short: {}", + resp_cond_state + ))); + } + match &resp_cond_state[..index] { + "OK" => { + let (code, _) = read_resp_text(&resp_cond_state[index + 1..])?; + Ok(Status::Ok(code)) + } + "NO" => { + let (code, text) = read_resp_text(&resp_cond_state[index + 1..])?; + Ok(Status::No(code, text.to_owned())) + } + "BAD" => { + let (code, text) = read_resp_text(&resp_cond_state[index + 1..])?; + Ok(Status::Bad(code, text.to_owned())) + } + trash => Err(Error::new(format!( + "protocol error: resp-cond-state must start with (OK | NO | BAD) not {}", + trash + ))), + } +} + +// atom-specials = "(" / ")" / "{" / SP / CTL / list-wildcards / +// quoted-specials / resp-specials +// list-wildcards = "%" / "*" +// quoted-specials = DQUOTE / "\" +// resp-specials = "]" +const ATOM_SPECIALS: &[char] = &['(', ')', '{', ' ', '%', '*', '"', '\\', ']']; + +// Read one 'astring', reading additional bytes from the server if it's a 'literal'. +// Returns (slice of the fully-read astring, slice of the astring parameter not processed herein). +// The full specification of 'astring' follows, but all we need care about it: +// - non-double-quoted-and-no-whitespace-word +// - "double-quoted text with backslash-escaping" +// - {N}\r\n where N is non-zero unsigned 32-bit number of bytes to read +// astring = 1*ASTRING-CHAR / string +// ASTRING-CHAR = ATOM-CHAR / resp-specials +// ATOM-CHAR = +// atom-specials = "(" / ")" / "{" / SP / CTL / list-wildcards / quoted-specials / resp-specials +// list-wildcards = "%" / "*" +// literal = "{" number "}" CRLF *CHAR8 +// ; Number represents the number of CHAR8s +// number = 1*DIGIT +// ; Unsigned 32-bit integer +// quoted = DQUOTE *QUOTED-CHAR DQUOTE +// QUOTED-CHAR = / "\" quoted-specials +// quoted-specials = DQUOTE / "\" +// resp-specials = "]" +// string = quoted / literal +enum AStringReaderResult { + NeedBytes(usize, AStringReader), + Done(String, usize), +} +struct AStringReader(usize); + +impl AStringReader { + pub fn start(line: &str) -> Result { + if line.is_empty() { + return Err(Error::new("protocol error: empty astring")); + } + Ok(match &line[..1] { + "{" => { + let (n, one_past_end) = read_literal_size(line)?; + if one_past_end != line.len() { + return Err(Error::new(format!( + "protocol error: extraneous bytes after {{N}}: {}", + &line[one_past_end..] + ))); + } + AStringReaderResult::NeedBytes(n, Self(n)) + } + "\"" => { + let (value, rest_index) = unquote(line)?; + AStringReaderResult::Done(value, rest_index) + } + _ => { + let end_of_atom = line.find(ATOM_SPECIALS).unwrap_or(line.len()); + // TODO end_of_atom not tested! Only use of AStringReader is MailboxListReader, + // whose astring comes at the end of the line so it doesn't care. + AStringReaderResult::Done(line[..end_of_atom].to_string(), end_of_atom) + } + }) + } + + pub fn read(self, bytes: &[u8]) -> Result { + if bytes.len() != self.0 { + Err(Error::new(format!( + "INTERNAL ERROR: AStringReader expected {} bytes, got {}", + self.0, + bytes.len() + ))) + } else { + Ok(std::str::from_utf8(bytes) + .map_err(|e| { + Error::describe( + e, + format_args!("protocol error: AStringReader requires UTF-8"), + ) + })? + .to_owned()) + } + } +} + +/// Returns (the literal size, one past the close brace). +fn read_literal_size(literal: &str) -> Result<(usize, usize)> { + let end: usize = literal.find('}').ok_or_else(|| { + Error::new(format!( + "protocol error: close brace not found in literal {}", + literal + )) + })?; + let size_str = &literal[1..end]; + trace!("read_literal_size({}[1..{}]) -> {}", literal, end, size_str); + Ok(( + size_str.parse().map_err(|e| { + Error::describe( + e, + format_args!("protocol error decoding literal size {}", size_str), + ) + })?, + end + 1, + )) +} + +// nz-number SP append-uid +fn read_appenduid(appenduid: &str) -> Result { + match appenduid.split(' ').collect::>().as_slice() { + [uidvalidity, uid] => Ok(ResponseCode::AppendUid( + UidValidity::from(read_nz_number(uidvalidity)?), + Uid::from(read_nz_number(uid)?), + )), + _ => Err(Error::new(format!( + "protocol error: APPENDUID response too small: {}", + appenduid + ))), + } +} + +// nz-number SP uid-set SP uid-set +fn read_copyuid(copyuid: &str) -> Result { + match copyuid.split(' ').collect::>().as_slice() { + [uid_validity, _, uid] => Ok(ResponseCode::CopyUid( + UidValidity::from(read_nz_number(uid_validity)?), + Uid::from(read_nz_number(uid)?), + )), + _ => Err(Error::new(format!( + "protocol error: COPYUID response too small: {}", + copyuid + ))), + } +} + +// nz-number = digit-nz *DIGIT +// ; Non-zero unsigned 32-bit integer +// ; (0 < n < 4,294,967,296) +fn read_nz_number(s: &str) -> Result { + s.parse() + .map_err(|e| Error::describe(e, format_args!("protocol error: nz-number"))) +} + +fn read_capability_data(s: &str) -> Capabilities { + let result = Capabilities::from( + nmh::hacks::split(s) + .map(read_capability) + .collect::>(), + ); + debug!("read {:?} from {}", result, s); + result +} + +fn read_capability(s: &str) -> Capability { + let upper: String = s.to_ascii_uppercase(); + match upper.split('=').collect::>().as_slice() { + ["AUTH", "PLAIN"] => return Capability::Auth(nmh::netsec::sasl::Mechanism::Plain), + ["AUTH", "XOAUTH2"] => return Capability::Auth(nmh::netsec::sasl::Mechanism::XOAuth2), + _ => {} + } + match upper.as_str() { + "ESEARCH" => Capability::ESearch, + "IMAP4REV1" => Capability::IMAP4Rev1, + "SASL-IR" => Capability::SASLIR, + "STARTTLS" => Capability::StartTLS, + "UIDPLUS" => Capability::UIDPlus, + _ => Capability::Unknown(upper), + } +} + +// mailbox-data = "SEARCH" *(SP nz-number) +// <= * SEARCH 2 3 4 5 7 8 9 10 11 +fn read_search_response(s: &str) -> Result { + let mut uids = uid::Set::new(); + for uid_result in nmh::hacks::split(s).map(read_nz_number) { + uids.insert(uid_result?.into()); + } + Ok(uids) +} + +// esearch-response = "ESEARCH" [search-correlator] [SP "UID"] *(SP search-return-data) +// <= * ESEARCH (TAG "A283") UID ALL 2,10:11 +fn read_esearch_response(s: &str) -> Result { + let uid_indicator = discard_search_correlator(s)?; + let return_data = discard_search_uid(uid_indicator)?; + if return_data.is_empty() { + // When no messages are returned, Dovecot legally omits search-return-data; Panda does not. + Ok(uid::Set::new()) + } else { + let sequence_set = discard_search_all(return_data)?; + read_sequence_set(sequence_set) + } +} + +// TODO multi-line literals are allowed or tag-string. We don't use tags that +// would REQUIRE multi-line literals, but nevertheless it is perfectly legal +// for a server to return them to us that way. +// search-correlator = SP "(" "TAG" SP tag-string ")" +// tag-string = string +fn discard_search_correlator(s: &str) -> Result<&str> { + // TODO this was supposed to start with a space. + match s.find('(') { + None => Ok(s), + Some(open) => match s.find(')') { + None => todo!( + "protocol error: search-correlator open paren without close paren: {}", + s + ), + Some(close) => Ok(&s[open + close + 1..]), + }, + } +} + +fn discard_search_uid(s: &str) -> Result<&str> { + // RFC 4731 3.1. New SEARCH/UID SEARCH Result Options says this is required since we UID ESEARCH: + // "An extended UID SEARCH command MUST cause an ESEARCH response with the UID indicator present." + if !s.starts_with(" UID") { + todo!("protocol error: search-response missing UID indicator") + } + Ok(&s[4..]) +} + +// search-return-data = search-modifier-name SP search-return-value +// search-return-data = "ALL" SP sequence-set +fn discard_search_all(s: &str) -> Result<&str> { + if !s.starts_with(" ALL ") { + todo!("protocol error: search-modifier-name ALL required") + } + Ok(&s[5..]) +} + +// sequence-set = (seq-number / seq-range) *("," sequence-set) +fn read_sequence_set(s: &str) -> Result { + let mut uids = uid::Set::new(); + for seq_set in s.split(',') { + match seq_set.split(':').collect::>().as_slice() { + [uid] => { + uids.insert(read_nz_number(uid)?.into()); + } + [beg, end] => { + let beg_u32: u32 = read_nz_number(beg)?.into(); + let end_u32: u32 = read_nz_number(end)?.into(); + for uid in beg_u32..end_u32 + 1 { + uids.insert(uid); + } + } + _ => { + todo!("protocol error: > 2 colons in {}", seq_set); + } + } + } + Ok(uids) +} + +#[cfg(test)] +mod tests; diff --git a/imap/folder-manager/src/imap/protocol/response.rs b/imap/folder-manager/src/imap/protocol/response.rs new file mode 100644 index 00000000..184d3fc1 --- /dev/null +++ b/imap/folder-manager/src/imap/protocol/response.rs @@ -0,0 +1,143 @@ +//! Models of IMAP response data used by the high-level [crate::imap::Session]. + +use std::collections::BTreeSet; + +use nmhimaptypes::{uid, Uid, UidValidity}; + +/// A server greeting returned by [super::read_greeting]. +#[derive(Debug)] +pub enum Greeting { + Ok(ResponseCode), + PreAuth(ResponseCode), +} + +/// RFC 3501 7. Server Responses says server responses are in three forms: +/// - status responses +/// - server data +/// - command continuation request +/// Status responses can be tagged or untagged. +/// Tagged status responses indicate the completion result (OK, NO, or BAD +/// status) of a client command, and have a tag matching the command. +#[derive(Debug)] +pub enum Response { + ServerData(ServerData), + UntaggedStatus(Status /*, parsed response bits */), + // (tag, Status) + TaggedStatus(String, Status), + //Bye(&str), +} + +// Server data are untagged. +pub enum ServerData { + Unknown, + /// the server's current capabilities, which may vary from state to state + Capability(Capabilities), + /// the number of messages existing in the mailbox the response pertains to + Exists(u32), + Fetch(Vec), + /// (mailbox name, flags) + List(String, String), + ESearch(uid::Set), + Search(uid::Set), +} + +impl std::fmt::Debug for ServerData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ServerData::Unknown => f.debug_tuple("Unknown").finish(), + ServerData::Capability(cap) => f.debug_tuple("Capability").field(cap).finish(), + ServerData::Exists(n) => f.debug_tuple("ServerData::Exists").field(n).finish(), + ServerData::Fetch(_) => f.debug_tuple("ServerData::Fetch").finish(), + ServerData::List(mailbox_name, flags) => f + .debug_tuple("List") + .field(mailbox_name) + .field(flags) + .finish(), + ServerData::ESearch(set) => f.debug_tuple("ESearch").field(set).finish(), + ServerData::Search(set) => f.debug_tuple("Search").field(set).finish(), + } + } +} + +/// A capability offered by a server. +#[derive(Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub enum Capability { + /// RFC 3501 - IMAP4rev1 + Auth(nmh::netsec::sasl::Mechanism), + /// RFC 4731 - SEARCH results in sequence-set syntax + ESearch, + /// RFC 3501 - IMAP4rev1 + IMAP4Rev1, + /// RFC 3501 - IMAP4rev1 + StartTLS, + /// RFC 4959 - SASL Initial Client Response + SASLIR, + /// RFC 4315 - UIDPLUS + UIDPlus, + Unknown(String), +} + +/// Part of a [crate::imap::FetchResult] operation. +#[derive(Debug)] +pub enum Fetch { + /// Whether this message has the `\Seen` flag (only flag we care about right now). + Flags(bool), + Uid(Uid), +} + +#[derive(Debug)] +pub enum Status { + Ok(ResponseCode), + No(ResponseCode, String), + Bad(ResponseCode, String), +} + +// TODO +// Status responses we care about: +// [ALERT text...] - display 'text...' to user +// [BADCHARSET ...] - internal error; crash and log +// [PARSE text...] - display 'text...' and skip this message (TODO can we test this?) +// [PERMANENTFLAGS (... \* ...)] - look for '\*' to for keywords +// [READ-ONLY] +// [READ-WRITE] +#[derive(Debug)] +pub enum ResponseCode { + /// used for both unknown response code and no response code at all because we don't need to distinguish those cases. + Unknown, + AppendUid(UidValidity, Uid), + Capability(Capabilities), + CopyUid(UidValidity, Uid), + UidNext(Uid), + UidValidity(UidValidity), +} + +/// Set of [Capability]s a server offers. +pub struct Capabilities(BTreeSet); + +impl Capabilities { + /// Return true if `capability` is in this set. + pub fn has(&self, capability: Capability) -> bool { + self.0.contains(&capability) + } + + pub fn iter(&self) -> std::collections::btree_set::Iter { + self.0.iter() + } +} + +impl From> for Capabilities { + fn from(set: BTreeSet) -> Self { + Self(set) + } +} + +impl std::fmt::Debug for Capabilities { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_list() + .entries(self.0.iter().filter(|c| { + // Only including known capabilities for less verbose debug output. + !matches!(c, Capability::Unknown(_)) + })) + .finish() + } +} diff --git a/imap/folder-manager/src/imap/protocol/tests.rs b/imap/folder-manager/src/imap/protocol/tests.rs new file mode 100644 index 00000000..8233daba --- /dev/null +++ b/imap/folder-manager/src/imap/protocol/tests.rs @@ -0,0 +1,201 @@ +use std::num::NonZeroU32; + +use super::*; + +mod capability { + use super::*; + + mod greeting { + use super::*; + + fn read_greeting_as_debug_string(s: &str) -> String { + let mut owned = s.to_owned(); + format!("{:?}", read_greeting(&mut owned).unwrap()) + } + + #[test] + fn panda_preauth() { + assert_eq!( + "PreAuth(Capability([IMAP4Rev1, UIDPlus]))", + read_greeting_as_debug_string("* PREAUTH [CAPABILITY IMAP4REV1 I18NLEVEL=1 LITERAL+ IDLE UIDPLUS NAMESPACE CHILDREN MAILBOX-REFERRALS BINARY UNSELECT WITHIN SORT THREAD=REFERENCES THREAD=ORDEREDSUBJECT MULTIAPPEND SCAN] Pre-authenticated") + ) + } + + #[test] + fn dovecot() { + assert_eq!( + "Ok(Capability([Auth(Plain), IMAP4Rev1, StartTLS, SASLIR]))", + read_greeting_as_debug_string("* OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ STARTTLS AUTH=PLAIN] Dovecot ready.") + ) + } + + #[test] + fn cyrus() { + assert_eq!( + "Ok(Capability([Auth(Plain), IMAP4Rev1, SASLIR]))", + read_greeting_as_debug_string("* OK [CAPABILITY IMAP4rev1 LITERAL+ ID ENABLE AUTH=SCRAM-SHA-1 AUTH=SCRAM-SHA-256 AUTH=DIGEST-MD5 AUTH=CRAM-MD5 AUTH=NTLM AUTH=PLAIN AUTH=LOGIN SASL-IR] Cyrus IMAP 3.2.3 server ready") + ) + } + + #[test] + fn gandi() { + assert_eq!( + "Ok(Capability([Auth(Plain), IMAP4Rev1, StartTLS, SASLIR]))", + read_greeting_as_debug_string("* OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ STARTTLS AUTH=PLAIN] Dovecot ready.") + ) + } + + #[test] + fn gmail_omits_capabilities() { + assert_eq!( + "Ok(Unknown)", + read_greeting_as_debug_string( + "* OK Gimap ready for requests from 70.237.130.101 z4mb18213485oce" + ) + ) + } + } + + mod auth_response { + use super::*; + + fn read_response_as_debug_string(s: &str) -> String { + match read_response(s).unwrap() { + ReadResult::Response(response) => format!("{:?}", response), + ReadResult::CommandContinuationRequest => { + panic!("don't want CommandContinuationRequest") + } + ReadResult::FetchBytes(_, _, _) => panic!("don't want FetchBytes"), + ReadResult::NeedBytes(_, _) => panic!("don't want NeedBytes"), + ReadResult::NeedLine(_) => panic!("don't want NeedLine"), + } + } + + #[test] + fn authenticate_dovecot() { + assert_eq!( + "TaggedStatus(\"1\", Ok(Capability([ESearch, IMAP4Rev1, SASLIR, UIDPlus])))", + read_response_as_debug_string("1 OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE SORT SORT=DISPLAY THREAD=REFERENCES THREAD=REFS THREAD=ORDEREDSUBJECT MULTIAPPEND URL-PARTIAL CATENATE UNSELECT CHILDREN NAMESPACE UIDPLUS LIST-EXTENDED I18NLEVEL=1 CONDSTORE QRESYNC ESEARCH ESORT SEARCHRES WITHIN CONTEXT=SEARCH LIST-STATUS BINARY MOVE SNIPPET=FUZZY PREVIEW=FUZZY STATUS=SIZE SAVEDATE LITERAL+ NOTIFY] Logged in") + ) + } + + #[test] + fn authenticate_cyrus() { + assert_eq!( + "TaggedStatus(\"1\", Ok(Capability([ESearch, IMAP4Rev1, UIDPlus])))", + read_response_as_debug_string("1 OK [CAPABILITY IMAP4rev1 LITERAL+ ID ENABLE ACL RIGHTS=kxten QUOTA MAILBOX-REFERRALS NAMESPACE UIDPLUS NO_ATOMIC_RENAME UNSELECT CHILDREN MULTIAPPEND BINARY CATENATE CONDSTORE ESEARCH SEARCH=FUZZY SORT SORT=MODSEQ SORT=DISPLAY SORT=UID THREAD=ORDEREDSUBJECT THREAD=REFERENCES THREAD=REFS ANNOTATE-EXPERIMENT-1 METADATA LIST-EXTENDED LIST-STATUS LIST-MYRIGHTS LIST-METADATA WITHIN QRESYNC SCAN XLIST XMOVE MOVE SPECIAL-USE CREATE-SPECIAL-USE DIGEST=SHA1 X-REPLICATION STATUS=SIZE OBJECTID SAVEDATE X-CREATEDMODSEQ PREVIEW=FUZZY URLAUTH URLAUTH=BINARY LOGINDISABLED COMPRESS=DEFLATE X-QUOTA=STORAGE X-QUOTA=MESSAGE X-QUOTA=X-ANNOTATION-STORAGE X-QUOTA=X-NUM-FOLDERS IDLE] Success (no protection)") + ) + } + + #[test] + fn authenticate_gandi() { + assert_eq!( + "TaggedStatus(\"1\", Ok(Capability([ESearch, IMAP4Rev1, SASLIR, UIDPlus])))", + read_response_as_debug_string("1 OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE SORT SORT=DISPLAY THREAD=REFERENCES THREAD=REFS THREAD=ORDEREDSUBJECT MULTIAPPEND URL-PARTIAL CATENATE UNSELECT CHILDREN NAMESPACE UIDPLUS LIST-EXTENDED I18NLEVEL=1 CONDSTORE QRESYNC ESEARCH ESORT SEARCHRES WITHIN CONTEXT=SEARCH LIST-STATUS BINARY MOVE SNIPPET=FUZZY PREVIEW=FUZZY PREVIEW STATUS=SIZE SAVEDATE LITERAL+ NOTIFY QUOTA] Logged in") + ) + } + + #[test] + fn capability_unauthenticated_gmail() { + assert_eq!( + "ServerData(Capability([Auth(Plain), Auth(XOAuth2), IMAP4Rev1, SASLIR]))", + read_response_as_debug_string("* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 XYZZY SASL-IR AUTH=XOAUTH2 AUTH=PLAIN AUTH=PLAIN-CLIENTTOKEN AUTH=OAUTHBEARER AUTH=XOAUTH") + ) + } + + #[test] + fn capability_authenticated_gmail() { + assert_eq!( + "ServerData(Capability([ESearch, IMAP4Rev1, UIDPlus]))", + read_response_as_debug_string("* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS COMPRESS=DEFLATE ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT LIST-EXTENDED LIST-STATUS LITERAL- SPECIAL-USE APPENDLIMIT=35651584") + ) + } + } +} + +mod unquote { + use super::*; + + #[test] + fn simple() { + let quoted = r#""simple" rest"#; + let (unquoted, rest) = unquote(quoted).unwrap(); + assert_eq!((unquoted, "ed[rest..]), ("simple".to_string(), " rest")); + } + + #[test] + fn less_simple() { + let quoted = r#""not \\ \"so simple" rest"#; + let (unquoted, rest) = unquote(quoted).unwrap(); + assert_eq!( + (unquoted, "ed[rest..]), + (r#"not \ "so simple"#.to_string(), " rest") + ); + } + + #[test] + fn starts_with_wrong_character() { + // TODO This shouldn't work but this is an internal function and we only call it when we detect a double-quote so... + let quoted = " oops\""; + let (unquoted, rest) = unquote(quoted).unwrap(); + assert_eq!((unquoted, "ed[rest..]), ("oops".to_string(), "")); + } + + #[test] + #[should_panic(expected = "byte index 1 is out of bounds")] + fn empty() { + unquote("").unwrap(); + } + + #[test] + fn missing_closing_quote() { + assert_eq!( + unquote("\"foo").unwrap_err().to_string(), + "protocol error: str missing close quote" + ); + } +} + +mod read_resp_text { + use super::*; + + #[test] + fn copyuid() { + let (code, text) = read_resp_text("[COPYUID 1613513882 3 2] UID COPY completed").unwrap(); + assert_eq!( + code, + ResponseCode::CopyUid( + UidValidity::from(NonZeroU32::new(1613513882).unwrap()), + Uid::from(NonZeroU32::new(2).unwrap()) + ) + ); + assert_eq!(text, "UID COPY completed"); + } + + // TODO should this be ProtocolError? + #[test] + fn missing_leading_bracket() { + let (code, text) = read_resp_text("COPYUID 1613513882 3 2] UID COPY completed").unwrap(); + assert_eq!(code, ResponseCode::Unknown); + assert_eq!(text, "COPYUID 1613513882 3 2] UID COPY completed"); + } +} + +impl std::cmp::PartialEq for ResponseCode { + fn eq(&self, other: &Self) -> bool { + if std::mem::discriminant(self) != std::mem::discriminant(other) { + return false; + } + match self { + Self::Unknown => true, + Self::CopyUid(uv, uid) => { + if let Self::CopyUid(other_uv, other_uid) = other { + uv == other_uv && uid == other_uid + } else { + unreachable!("already checked discriminant") + } + } + _ => todo!("minimal test implementation"), + } + } +} diff --git a/imap/folder-manager/src/imap/session.rs b/imap/folder-manager/src/imap/session.rs new file mode 100644 index 00000000..243988f9 --- /dev/null +++ b/imap/folder-manager/src/imap/session.rs @@ -0,0 +1,333 @@ +//! See [crate::imap] for overview. + +use log::trace; + +use nmh::Netsec; +use nmhimaptypes::{Uid, UidValidity}; + +use crate::imap; +use imap::protocol::{ + read_response, ByteReader, Capabilities, Capability, ReadResult, Response, ResponseCode, + ServerData, Status, +}; +use imap::{Flag, Mailbox}; + +mod fetch; +pub mod mailbox; + +pub use fetch::{FetchResult, MessageReader, MessageResult}; + +pub type Error = crate::error::RecoverableError; + +/// Manages the "authenticated" state of an IMAP session. +/// See [crate::imap] for overview. +/// Some errors are recoverable; for other errors, this sends a "BYE" command to the server and +/// closes the connection without reading a response. +/// To close the connection, simply [Drop] this. +pub struct Session { + netsec: Netsec, + buf: Vec, + esearch: bool, +} + +impl Session { + /// Exists for [crate::imap::auth::login]; use that. + pub fn new(netsec: Netsec, capabilities: Capabilities) -> Result { + if !capabilities.has(Capability::UIDPlus) { + Err(nmh::Error::new( + "server doesn't offer required capability UIDPLUS", + )) + } else { + Ok(Self { + netsec, + buf: vec![0; 8192], + esearch: capabilities.has(Capability::ESearch), + }) + } + } + + /// Delete the named `mailbox`. + pub fn delete(mut self, mailbox: &imap::mailbox::Name) -> Result { + // TODO derive tag from request-in-flight counter? We don't need these tags, and it wasn't + // clear to my eyes whether it's actually legal to reuse a tag like this. So far I've + // gotten away with it, but best not to make assumptions. + self.write_and_flush(&format!("1 DELETE {}\r\n", mailbox.as_str()))?; + loop { + match self.read_response()? { + Response::TaggedStatus(_, Status::Ok(_)) => { + break; + } + Response::TaggedStatus(_, _) => todo!("non-OK status"), + ignored => { + trace!("ignoring {:?}", ignored); + } + } + } + Ok(self) + } + + /// Append a new message to the named `mailbox`, with specified `contents` and `flags`. + /// Return the message's UID and the mailbox's UIDVALIDITY, which may have changed. + pub fn append( + mut self, + mailbox: &imap::mailbox::Name, + flags: &[Flag], + contents: &str, + ) -> Result<((UidValidity, Uid), Self), Error> { + let flags_str = if flags.is_empty() { + String::new() + } else { + format!(" ({})", to_string_join(flags, " ")) + }; + self.write_and_flush(&format!( + "1 APPEND {}{} {{{}}}\r\n", + mailbox, + flags_str, + contents.len() + ))?; + match read_response(self.read_line()?)? { + ReadResult::CommandContinuationRequest => {} + // It's not clear from RFC3501, but I don't think anything else can come before the continuation request. + other => todo!("expecting continue-req not {:?}", other), + } + self.write(contents)?; + self.write_and_flush("\r\n")?; + loop { + match self.read_response()? { + // TODO Bail out if UIDVALIDITY changed; tell user to fix with inc. + Response::TaggedStatus( + _, + Status::Ok(ResponseCode::AppendUid(uidvalidity, uid)), + ) => { + break Ok(((uidvalidity, uid), self)); + } + Response::TaggedStatus(_, Status::Ok(_)) => { + todo!("APPENDUID response too small"); + } + ignored => { + trace!("ignoring {:?}", ignored); + } + } + } + } + + /// Create the named `mailbox`. + pub fn create(mut self, mailbox: &str) -> Result { + self.write_and_flush(&format!("1 CREATE \"{}\"\r\n", mailbox))?; + let status: Status = loop { + match self.read_response()? { + Response::TaggedStatus(_tag, status) => break status, + ignored => { + trace!("ignoring {:?}", ignored); + } + } + }; + match status { + Status::No(_, message) => Err(Error::recoverable(nmh::Error::new(message), self)), + // TODO does BAD always mean our bug or can it be user error? + Status::Bad(code, message) => todo!("BAD {:?} {}", code, message), + Status::Ok(_) => Ok(self), + } + } + + /// Select the named `mailbox`, entering the Selected state. See [crate::imap] for overview. + pub fn select(mut self, mailbox: &imap::mailbox::Name) -> Result { + self.write_and_flush(&format!("1 SELECT {}\r\n", quote(mailbox.as_str())))?; + let mut message_count = 0; + let mut uid_next: Option = None; + let mut uid_validity: Option = None; + loop { + match self.read_response()? { + Response::TaggedStatus(_, Status::No(_, message)) => { + return Err(Error::recoverable(nmh::Error::new(message), self)); + } + Response::ServerData(ServerData::Exists(value)) => { + message_count = value; + } + Response::UntaggedStatus(Status::Ok(ResponseCode::UidNext(value))) => { + uid_next = Some(value); + } + Response::UntaggedStatus(Status::Ok(ResponseCode::UidValidity(value))) => { + uid_validity = Some(value); + } + Response::TaggedStatus(_, Status::Ok(_)) => { + let uid_next = uid_next.ok_or_else(|| { + Error::unrecoverable(nmh::Error::new( + "expected UIDNEXT missing from response", + )) + })?; + let uid_validity = uid_validity.ok_or_else(|| { + Error::unrecoverable(nmh::Error::new( + "expected UIDVALIDITY missing from response", + )) + })?; + return Ok(mailbox::Selected { + message_count, + uid_next, + uid_validity, + mailbox_session: mailbox::Session::new( + { + let session_uidnext: u32 = uid_next.into(); + session_uidnext - 1 + }, + self, + ), + }); + } + ignored => { + trace!("ignoring {:?}", ignored); + } + } + } + } + + /// Return the list of mailboxes under `mailbox_root`, which may be empty. + /// Returned in server order, which may or may not be sorted. + pub fn list(mut self, mailbox_root: &str) -> Result<(Vec, Self), Error> { + self.write_and_flush(&format!("1 LIST \"\" {}*\r\n", mailbox_root))?; + let mut mailboxes = Vec::::new(); + loop { + match self.read_response()? { + Response::TaggedStatus(_, Status::No(code, message)) => { + log::error!("NO {:?} {}", code, message); + return Err(Error::recoverable( + // TODO pretty this up; can `code` be interpreted? + nmh::Error::new(format!("NO response from LIST: {:?} {}", code, message)), + self, + )); + } + Response::TaggedStatus(_, Status::Bad(code, message)) => { + // TODO does BAD always mean our bug or can it be user error? + return Err(Error::unrecoverable(nmh::Error::new(format!( + "BAD response from LIST: {:?} {}", + code, message + )))); + } + Response::TaggedStatus(_, Status::Ok(_)) => { + break; + } + Response::ServerData(ServerData::List(mailbox_name, flags)) => { + mailboxes.push(Mailbox { + name: imap::mailbox::Name::from(mailbox_name), + selectable: !flags.contains("\\noselect"), + }); + } + ignored => { + trace!("ignoring {:?}", ignored); + } + } + } + Ok((mailboxes, self)) + } + + fn close(mut self) -> Result { + self.write_and_flush("close CLOSE\r\n")?; + loop { + match self.read_response()? { + Response::TaggedStatus(_, Status::Ok(_)) => { + break; + } + Response::TaggedStatus(_, status) => { + todo!("only handling OK from CLOSE not {:?}", status) + } + ignored => { + trace!("ignoring {:?}", ignored); + } + } + } + Ok(self) + } + + fn feed_bytes(&mut self, n: usize, reader: ByteReader) -> Result { + if n > self.buf.len() { + // We have 8K buffer and this is used not for reading messages (which may be large) but for + // reading other literals such as mailbox names (which should be small). If there's some + // reason to support reading >8K here, imap::protocol::ByteReader can be made to loop. + todo!("cowardly refusing to read {} bytes", n); + } + let mut bytes_read = 0; + while bytes_read < n { + bytes_read += self + .netsec + .read(&mut self.buf[bytes_read..n - bytes_read])?; + } + reader.read_bytes(&self.buf[..n]) + } + + fn read_response(&mut self) -> Result { + let mut result: ReadResult = read_response(self.read_line()?)?; + loop { + match result { + ReadResult::CommandContinuationRequest => { + return Err(nmh::Error::new("unexpected continue-req")) + } + ReadResult::FetchBytes(_, _, _) => { + return Err(nmh::Error::new("unexpected FETCH BODY[] response")) + } + ReadResult::NeedBytes(n, reader) => result = self.feed_bytes(n, reader)?, + ReadResult::NeedLine(reader) => result = reader.read_line(self.read_line()?)?, + ReadResult::Response(response) => return Ok(response), + } + } + } + + fn read_line(&mut self) -> Result<&mut str, nmh::Error> { + self.netsec.read_line() + } + + fn write_and_flush(&mut self, s: &str) -> Result<(), nmh::Error> { + self.write(s)?; + self.netsec.flush() + } + + fn write(&mut self, s: &str) -> Result<(), nmh::Error> { + self.netsec.write(s.as_bytes()) + } +} + +impl std::fmt::Debug for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Session").finish() + } +} + +impl Drop for Session { + fn drop(&mut self) { + self.netsec + .write("logout LOGOUT\r\n".as_bytes()) + .unwrap_or(()); + self.netsec.flush().unwrap_or(()); + } +} + +fn quote(s: &str) -> String { + let mut result = String::new(); + result.push('"'); + for (_, c) in s.char_indices() { + if c == '"' { + result += "\\\""; + } else { + result.push(c); + } + } + result.push('"'); + result +} + +impl From for Error { + fn from(e: nmh::Error) -> Self { + Self::unrecoverable(e) + } +} + +// TODO odd home for this +fn to_string_join(values: &[T], s: &str) -> String { + values + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(s) +} + +#[cfg(test)] +mod tests; diff --git a/imap/folder-manager/src/imap/session/fetch.rs b/imap/folder-manager/src/imap/session/fetch.rs new file mode 100644 index 00000000..9cff1fe9 --- /dev/null +++ b/imap/folder-manager/src/imap/session/fetch.rs @@ -0,0 +1,256 @@ +use log::trace; + +use nmh::Error; +use nmhimaptypes::Uid; + +use crate::imap; +use imap::mailbox; +use imap::protocol::{read_response, Fetch, LineReader, ReadResult, Response, ServerData, Status}; + +/// This is the center of the message fetching system. Use it to consume FETCH respones, receiving +/// message payloads with [MessageReader], like so: +/// +/// ``` +/// let session: mailbox::Session; +/// let mut f: FetchResult = session.fetch(...); +/// let session: mailbox::Session = loop { +/// match f { +/// Done(session) => break session, +/// Message(uid, seen, mut reader) => { +/// let writer = open(uid); +/// f = loop { +/// match reader.read(...) { +/// Done(f) => break f, +/// Reading(bytes, next_reader) => { +/// writer.write_all(bytes); +/// reader = next_reader; +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` +pub enum FetchResult<'a> { + Message(Uid, bool, MessageReader<'a>), + Done(mailbox::Session), +} + +/// See [FetchResult] for overview. +pub struct MessageReader<'session> { + session: FetchSession<'session>, + bytes_remaining: usize, + line_reader: LineReader, +} + +impl<'session> MessageReader<'session> { + /// Return [MessageResult::Reading] when message bytes have been received from the server, + /// [MessageResult::Done] with the [FetchResult] for the next message, if any remain. + pub fn read<'buffer>( + mut self, + buf: &'buffer mut [u8], + ) -> Result, Error> { + Ok(if self.bytes_remaining == 0 { + MessageResult::Done(self.session.finish_untagged_response(self.line_reader)?) + } else { + let bytes_to_read = std::cmp::min(buf.len(), self.bytes_remaining); + let bytes_read = self + .session + .session + .netsec + .read(&mut buf[..bytes_to_read])?; + self.bytes_remaining -= bytes_read; + MessageResult::Reading(&buf[..bytes_read], self) + }) + } +} + +pub enum MessageResult<'session, 'buffer> { + Reading(&'buffer [u8], MessageReader<'session>), + Done(FetchResult<'session>), +} + +/// Internal state of [FetchResult]. +pub(super) struct FetchSession<'a> { + session: imap::Session, + highest_uid: u32, + uids_iter: &'a mut dyn std::iter::Iterator, + requests_in_flight: usize, +} + +impl<'a> FetchSession<'a> { + pub fn start( + session: imap::Session, + highest_uid: u32, + uids_iter: &'a mut dyn std::iter::Iterator, + max_fetch_messages_in_flight: u32, + ) -> Result, Error> { + let mut fetch = Self { + session, + highest_uid, + uids_iter, + requests_in_flight: 0, + }; + trace!("fetch start"); + for _ in 0..max_fetch_messages_in_flight { + match fetch.uids_iter.next() { + None => break, + Some(uid) => fetch.fetch_body(*uid)?, + } + } + fetch.next() + } + + fn next(mut self) -> Result, Error> { + trace!("requests_in_flight={}", self.requests_in_flight); + while self.requests_in_flight > 0 { + let mut result: ReadResult = read_response(self.session.read_line()?)?; + let response: Response = loop { + match result { + ReadResult::Response(r) => break r, + ReadResult::CommandContinuationRequest => { + return Err(Error::new("unexpected continue-req")) + } + ReadResult::FetchBytes(fetch_parts, bytes_to_read, line_reader) => { + return self.receive_body(fetch_parts, bytes_to_read, line_reader); + } + ReadResult::NeedBytes(n, reader) => { + result = self.session.feed_bytes(n, reader)? + } + ReadResult::NeedLine(reader) => { + result = reader.read_line(self.session.read_line()?)? + } + } + }; + match response { + Response::TaggedStatus(_, Status::No(code, message)) => { + todo!("NO {:?} {}", code, message) + } + Response::TaggedStatus(_, Status::Bad(code, message)) => { + todo!("BAD {:?} {}", code, message) + } + Response::TaggedStatus(_, Status::Ok(_)) => { + trace!("FETCH OK requests_in_flight={}", self.requests_in_flight); + self.handle_tagged_response()? + } + ignored => { + trace!("ignoring {:?}", ignored); + } + } + } + Ok(FetchResult::Done(mailbox::Session::new( + self.highest_uid, + self.session, + ))) + } + + fn receive_body( + self, + fetch_parts: Vec, + bytes_remaining: usize, + line_reader: LineReader, + ) -> Result, Error> { + trace!( + "receive body fetch_parts={:?} bytes_to_read={} line_reader={:?}", + fetch_parts, + bytes_remaining, + line_reader + ); + let mut opt_uid: Option = None; + let mut opt_seen: Option = None; + for fetch_part in fetch_parts { + match fetch_part { + Fetch::Uid(uid) => match opt_uid { + Some(_) => todo!("UID twice!"), + _ => { + opt_uid = Some(uid); + } + }, + Fetch::Flags(seen) => match opt_seen { + Some(_) => todo!("FLAGS twice!"), + _ => { + opt_seen = Some(seen); + } + }, + } + } + let uid = match opt_uid { + None => todo!("assume UID comes before BODY"), + Some(uid) => uid, + }; + let seen = match opt_seen { + None => todo!("assume FLAGS comes before BODY"), + Some(seen) => seen, + }; + Ok(FetchResult::Message( + uid, + seen, + MessageReader { + session: self, + bytes_remaining, + line_reader, + }, + )) + } + + /// Finish reading the untagged "* FETCH" response; we're just ignoring anything else that comes in here. + fn finish_untagged_response( + mut self, + line_reader: LineReader, + ) -> Result, Error> { + let response: Response = { + let mut result: ReadResult = line_reader.read_line(self.session.read_line()?)?; + loop { + match result { + ReadResult::CommandContinuationRequest => { + return Err(Error::new("unexpected continue-req")) + } + ReadResult::FetchBytes(_, _, _) => { + return Err(Error::new("unexpected second FETCH BODY[] response")) + } + ReadResult::NeedBytes(n, reader) => { + result = self.session.feed_bytes(n, reader)?; + } + ReadResult::NeedLine(reader) => { + result = reader.read_line(self.session.read_line()?)?; + } + ReadResult::Response(r) => break r, + } + } + }; + match response { + Response::ServerData(ServerData::Fetch(fetch_parts)) => { + if !fetch_parts.is_empty() { + todo!("extra parts in untagged FETCH response {:?}", fetch_parts) + } + } + response => todo!( + "supposed to be seeing the end of the FETCH response here not {:?}", + response + ), + } + self.next() + } + + fn handle_tagged_response(&mut self) -> Result<(), Error> { + self.requests_in_flight -= 1; + if let Some(uid) = self.uids_iter.next() { + self.fetch_body(*uid)? + } + Ok(()) + } + + fn fetch_body(&mut self, uid: u32) -> Result<(), Error> { + assert_ne!(0, uid, "refusing to FETCH zero UID"); + self.requests_in_flight += 1; + trace!( + "FETCH BODY uid={} requests_in_flight={}", + uid, + self.requests_in_flight + ); + self.session.write_and_flush(&format!( + "f{} UID FETCH {} (UID FLAGS BODY.PEEK[])\r\n", + uid, uid + )) + } +} diff --git a/imap/folder-manager/src/imap/session/mailbox.rs b/imap/folder-manager/src/imap/session/mailbox.rs new file mode 100644 index 00000000..6ebc8b78 --- /dev/null +++ b/imap/folder-manager/src/imap/session/mailbox.rs @@ -0,0 +1,263 @@ +use std::num::NonZeroU32; + +use log::trace; + +use nmhimaptypes::{uid, Uid, UidValidity}; + +use crate::error::RecoverMapper; +use crate::imap; +use imap::protocol::{Response, ResponseCode, ServerData, Status}; +use imap::{mailbox, Flag, Mailbox}; + +pub type Error = crate::error::RecoverableError; + +/// Returned by [imap::Session::select]. +#[derive(Debug)] +pub struct Selected { + /// Number of messages in the mailbox at SELECT time; may be zero. + pub message_count: u32, + /// Projected UID of next message to be appended to the mailbox. + pub uid_next: Uid, + /// UIDVALIDITY of mailbox at SELECT time. + pub uid_validity: UidValidity, + pub mailbox_session: Session, +} + +/// See [imap::Session::select]. +// TODO Commands from imap::Session should be supported here too: +// 6.4. Client Commands - Selected State +// In addition to the universal commands: +// - CAPABILITY +// - NOOP +// - LOGOUT +// and the authenticated state commands: +// - CREATE +// - DELETE +// - RENAME +// - LIST +// - APPEND +// the following commands are valid in the selected state: +// - CLOSE +// - EXPUNGE +// - SEARCH +// - FETCH +// - STORE +// - COPY +#[derive(Debug)] +pub struct Session { + highest_uid: u32, + session: imap::Session, +} + +impl Session { + /// Same as [imap::Session::list]. + pub fn list(self, mailbox_root: &str) -> Result<(Vec, Self)> { + Ok({ + let (session, mut rm) = self.recover_mapper(); + let (mailboxes, session) = session.list(mailbox_root).map_err(|e| rm.map(e))?; + ( + mailboxes, + Self { + highest_uid: rm.ok(), + session, + }, + ) + }) + } + + /// Return the set of all UIDs in the mailbox. + pub fn search_all(self) -> Result<(uid::Set, Self)> { + search(self, "ALL") + } + + /// Return set of UIDs of messages lacking [Flag::Seen]. + pub fn search_unseen(self) -> Result<(uid::Set, Self)> { + search(self, "NOT SEEN") + } + + /// Close this session and open one for the named `mailbox`. + pub fn select(self, mailbox: &mailbox::Name) -> Result { + let (session, mut rm) = self.recover_mapper(); + session.select(mailbox).map_err(|e| rm.map(e)) + } + + /// Close this session, returning to the authenticated state. + pub fn close(self) -> Result { + // TODO Can we test this failing? RFC 3501 says CLOSE may return BAD but it isn't clear why. + Ok(self.session.close()?) + } + + // => UID COPY 2:4 dest + // <= 1 OK [COPYUID 1613513882 3 2] UID COPY completed + /// Copy the message in this mailbox identified by `uid` to the mailbox named `dst_mailbox`. + /// Return the new message's UID and the destination mailbox's UIDVALIDITY, which may have changed. + pub fn copy( + mut self, + uid: Uid, + dst_mailbox: &mailbox::Name, + ) -> Result<((UidValidity, Uid), Self)> { + self.session + .write_and_flush(&format!("1 UID COPY {} {}\r\n", uid, dst_mailbox))?; + loop { + match self.session.read_response()? { + // TODO Bail out (unrecoverable) if UIDVALIDITY changed; tell user to fix with inc. + Response::TaggedStatus(_, Status::Ok(ResponseCode::CopyUid(uidvalidity, uid))) => { + break Ok(((uidvalidity, uid), self)); + } + Response::TaggedStatus(_, Status::Ok(_)) => { + todo!("COPYUID response too small") + } + Response::TaggedStatus(_, _) => todo!("non-OK status"), + ignored => { + trace!("ignoring {:?}", ignored); + } + } + } + } + + // TODO `uids` should be &[Uid] or &uid::Set + // TODO returning silly unit to ease usage of error recovery macros; make them smarter + /// Add `flags` on messages listed in `uids`. May not be persisted until + /// this session is closed by [Session::close] or [Session::select]. + pub fn add_flags(mut self, uids: &str, flags: &[Flag]) -> Result<((), Self)> { + self.session.write_and_flush(&format!( + "1 UID STORE {} +FLAGS.SILENT ({})\r\n", + uids, + super::to_string_join(flags, " ") + ))?; + loop { + match self.session.read_response()? { + Response::TaggedStatus(_, Status::Ok(_)) => break Ok(((), self)), + Response::TaggedStatus(_, _) => todo!("non-OK status"), + ignored => { + trace!("ignoring {:?}", ignored); + } + } + } + } + + // TODO `uids` should be &[Uid] or &uid::Set + // TODO returning silly unit to ease usage of error recovery macros; make them smarter + /// Delete `flags` on messages listed in `uids`. May not be persisted + /// until this session is closed by [Session::close] or [Session::select]. + pub fn del_flags(mut self, uids: &str, flags: &[Flag]) -> Result<((), Self)> { + self.session.write_and_flush(&format!( + "1 UID STORE {} -FLAGS.SILENT ({})\r\n", + uids, + super::to_string_join(flags, " ") + ))?; + loop { + match self.session.read_response()? { + Response::TaggedStatus(_, Status::Ok(_)) => break Ok(((), self)), + Response::TaggedStatus(_, _) => todo!("non-OK status"), + ignored => { + trace!("ignoring {:?}", ignored); + } + } + } + } + + // TODO currently unrecoverable because we might have an error with further response coming down + // that would need processing and it's easier just to abandon that connection. If we really cared, + // we could recover in the case where no further requests are in flight but is it worth the complexity... + /// Send up to `max_fetch_messages_in_flight` message FETCH requests, and keep that many FETCH + /// requests in flight at a time, as long as `uids_iter` does not run out. + /// See [imap::FetchResult] for information on fetching messages. + pub fn fetch<'a>( + self, + max_fetch_messages_in_flight: NonZeroU32, + uids_iter: &'a mut dyn std::iter::Iterator, + ) -> std::result::Result, nmh::Error> { + super::fetch::FetchSession::start( + self.session, + self.highest_uid, + uids_iter, + max_fetch_messages_in_flight.into(), + ) + } + + pub(super) fn new(highest_uid: u32, session: imap::Session) -> Self { + Self { + highest_uid, + session, + } + } + + fn recover_mapper<'a>(self) -> (imap::Session, RecoverMapper<'a, imap::Session, Self, u32>) { + ( + self.session, + RecoverMapper::new( + &|session, highest_uid| Self { + highest_uid, + session, + }, + self.highest_uid, + ), + ) + } +} + +type Result = std::result::Result; + +fn search(mut mailbox_session: Session, search: &str) -> Result<(uid::Set, Session)> { + mailbox_session + .session + .write_and_flush(&format!( + "1 UID SEARCH{} {}\r\n", + if mailbox_session.session.esearch { + " RETURN ()" + } else { + "" + }, + search + )) + .map_err(Error::unrecoverable)?; + let mut result = uid::Set::new(); + let status: Status = { + // https://www.imapwiki.org/ClientImplementation/Search says we need to handle + // the SEARCH results being split across multiple untagged responses. + loop { + match mailbox_session + .session + .read_response() + .map_err(Error::unrecoverable)? + { + Response::ServerData(ServerData::ESearch(uids)) => { + result.append(uids); + } + Response::ServerData(ServerData::Search(uids)) => { + result.append(uids); + } + // <= $tag OK UID SEARCH completed + Response::TaggedStatus(_, status) => break status, + ignored => { + log::trace!("ignoring {:?}", ignored); + } + } + } + }; + + match status { + Status::No(code, message) => { + // TODO pretty this up; can `code` be interpreted? + Err(Error::recoverable( + nmh::Error::new(format!("{:?} {}", code, message)), + mailbox_session, + )) + } + Status::Bad(code, message) => { + // TODO does BAD always mean our bug or can it be user error? + Err(Error::unrecoverable(nmh::Error::new(format!( + "{:?} {}", + code, message + )))) + } + Status::Ok(_) => Ok((result, mailbox_session)), + } +} + +impl From for Error { + fn from(e: nmh::Error) -> Self { + Self::unrecoverable(e) + } +} diff --git a/imap/folder-manager/src/imap/session/tests.rs b/imap/folder-manager/src/imap/session/tests.rs new file mode 100644 index 00000000..6324198e --- /dev/null +++ b/imap/folder-manager/src/imap/session/tests.rs @@ -0,0 +1,8 @@ +use super::*; + +#[test] +fn embedded_quote() { + let unquoted = r#"a "b" c"#; + let quoted = r#""a \"b\" c""#; + assert_eq!(quoted, quote(unquoted)); +} diff --git a/imap/folder-manager/src/logging.rs b/imap/folder-manager/src/logging.rs new file mode 100644 index 00000000..e32457a3 --- /dev/null +++ b/imap/folder-manager/src/logging.rs @@ -0,0 +1,181 @@ +use std::io::Write; +use std::os::unix::ffi::OsStrExt; + +// TODO make this a macro so we capture the line/file where this is called? +/// Log at `level` parts of `s`. +/// +/// First logs all of `s` and then logs only the parts between `start` +/// and `one_past_end`, like so: +/// +/// ```text +/// (UID 6 FLAGS (\Seen) BODY[] {91} +/// ' FLAGS (\Seen) BODY[] {91}' +/// ``` +pub fn log_string_parts(level: log::Level, s: &str, start: usize, one_past_end: usize) { + if log::log_enabled!(level) { + log::log!(level, " {}", s); + log::log!( + level, + "{}'{}{}'", + std::str::from_utf8(&vec![32; start]).expect("32 (SPC) is valid UTF-8"), + &s[start..one_past_end], + std::str::from_utf8(&vec![32; s.len() - one_past_end]) + .expect("32 (SPC) is valid UTF-8"), + ); + } +} + +/// Log at [log::Level::Error] `e`, and its [std::error::Error::source] if any, and its... and so on. +pub fn error(mut e: &dyn std::error::Error) { + let mut i = 0; + log::error!("{}: {:?}", i, e); + loop { + i += 1; + match e.source() { + Some(source) => { + e = source; + log::error!("{}: {:?}", i, e); + } + None => break, + } + } +} + +/// Redirect the standard error (file descriptor 2) to the file named `path`. +pub fn redirect_stderr_to(path: std::path::PathBuf) -> Result<(), String> { + let fd = { + let cs = std::ffi::CString::new(path.as_os_str().as_bytes()) + .expect("Path not to contain null byte"); + let cp = cs.as_bytes_with_nul().as_ptr() as *const libc::c_char; + unsafe { libc::open(cp, libc::O_APPEND | libc::O_CREAT | libc::O_WRONLY, 0o600) } + }; + if fd < 0 { + return Err(format!( + "open({}): {}", + path.display(), + std::io::Error::last_os_error() + )); + } + if unsafe { libc::dup2(fd, 2) } < 0 { + return Err(format!( + "dup2({}, stderr): {}", + path.display(), + std::io::Error::last_os_error() + )); + } + if unsafe { libc::close(fd) } < 0 { + return Err(format!( + "close({}): {}", + path.display(), + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +/// Install a [log::Log] implementation ([Logger]) based on the `mh-profile(5)` and which +/// Panics if it cannot persist logs to configured files. +pub fn install(prefixed: &nmh::profile::Prefixed) -> Result<(), String> { + let default = prefixed + .get("log-level") + .map(|level_s| level_s.parse::()) + .unwrap_or(Ok(log::LevelFilter::Off)) + .map_err(|e| e.to_string())?; + let mut max = default; + let mut module_to_level = std::collections::HashMap::new(); + if let Some(modules) = prefixed.get("log-modules") { + for module in nmh::hacks::split(modules) { + if let Some(level_s) = prefixed.get(&format!("log-module-{}", module)) { + let level = level_s + .parse::() + .map_err(|e| e.to_string())?; + max = std::cmp::max(level, max); + module_to_level.insert(module.to_owned(), level); + } + } + } + + let mut level_to_path = std::collections::HashMap::new(); + let mut last: Option = None; + for level in &[ + log::Level::Trace, + log::Level::Debug, + log::Level::Info, + log::Level::Warn, + log::Level::Error, + ] { + // TODO need to support relative and absolute in all paths read from profile + match prefixed.get(&format!("log-{}-to", level)) { + Some(path_s) => { + let buf = std::path::Path::new(path_s).to_owned(); + level_to_path.insert(*level, buf.clone()); + last = Some(buf) + } + _ => { + if let Some(path) = &last { + level_to_path.insert(*level, path.to_owned()); + } + } + } + } + + if default > log::LevelFilter::Off || !module_to_level.is_empty() { + if level_to_path.is_empty() { + return Err("logging requested but no log files specified".to_owned()); + } + log::set_boxed_logger(Box::new(Logger { + default, + module_to_level, + level_to_path, + pid: std::process::id(), + })) + .map_err(|e| e.to_string())?; + log::set_max_level(max); + } + + Ok(()) +} + +struct Logger { + default: log::LevelFilter, + module_to_level: std::collections::HashMap, + level_to_path: std::collections::HashMap, + pid: u32, +} + +impl log::Log for Logger { + fn enabled(&self, m: &log::Metadata) -> bool { + m.level() <= self.default + || self + .module_to_level + .get(m.target()) + .map(|level| m.level() <= *level) + .unwrap_or(false) + } + + fn log(&self, r: &log::Record) { + if !self.enabled(r.metadata()) { + return; + } + if let Some(path) = &self.level_to_path.get(&r.metadata().level()) { + let mut file: std::fs::File = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + file.write_fmt(format_args!( + "{} {} {} {}:{} {}\n", + unsafe { nmh::dtimenow() }.to_str().unwrap(), + self.pid, + r.level(), + r.file().unwrap_or("UNKNOWN"), + r.line().unwrap_or(0), + r.args() + )) + .unwrap(); + file.sync_all().unwrap() + } + } + + fn flush(&self) {} +} diff --git a/imap/folder-manager/src/mailbox_rule.rs b/imap/folder-manager/src/mailbox_rule.rs new file mode 100644 index 00000000..11622118 --- /dev/null +++ b/imap/folder-manager/src/mailbox_rule.rs @@ -0,0 +1,59 @@ +use log::trace; + +use crate::regex::Regex; + +#[derive(Debug)] +pub struct MailboxRules(Vec); + +/// These rules determine whether to include or exclude mailboxes from `inc(1)`. +impl MailboxRules { + pub fn new() -> Self { + Self(vec![]) + } + + /// Mark mailboxes whose names match `pattern` for inclusion. + pub fn include(&mut self, pattern: Regex) { + self.0.push(Rule { + pattern, + include: true, + }) + } + + /// Mark mailboxes whose names match `pattern` for exclusion. + pub fn exclude(&mut self, pattern: Regex) { + self.0.push(Rule { + pattern, + include: false, + }) + } + + /// Return whether the mailbox named `mailbox` should be be included. + pub fn included(&self, mailbox: &str) -> bool { + let mut include = true; + for rule in &self.0 { + if rule.pattern.matches(mailbox) { + trace!( + "MailboxRules::included was {} now {}", + include, + rule.include + ); + include = rule.include; + } else { + trace!("MailboxRules::included leaving it {}", include); + } + } + trace!( + "MailboxRules::included({}, {}) -> {}", + mailbox, + self.0.len(), + include + ); + include + } +} + +#[derive(Debug)] +struct Rule { + pattern: Regex, + include: bool, +} diff --git a/imap/folder-manager/src/main.rs b/imap/folder-manager/src/main.rs new file mode 100644 index 00000000..f510e012 --- /dev/null +++ b/imap/folder-manager/src/main.rs @@ -0,0 +1,40 @@ +mod cf; +mod error; +mod logging; +mod mailbox_rule; +mod regex; +mod run; + +// pub just to hush cargo doc ¯\_(ツ)_/¯ +pub mod imap; +pub mod manager; + +fn main() { + if std::mem::size_of::() != std::mem::size_of::() { + todo!("i32 != c_int not yet supported"); + } + + // Create a new session and close stdin. + unsafe { + libc::setsid(); + if libc::close(0) < 0 { + libc::perror("close(0)".as_ptr() as *const libc::c_char); + std::process::exit(2) + } + } + // Leaving stderr open until we have accepted a connection through which we can instead report errors. + // Leaving stdout open so we can tell the calling program we're listening. + + let (context, name) = parse_args(std::env::args()); + if let Err(e) = run::configure_and_run(&context, name) { + eprintln!("{}", e); + std::process::exit(2) + } +} + +fn parse_args(mut args: std::env::Args) -> (nmh::Context, String) { + ( + nmh::init(std::ffi::CString::new(args.next().unwrap()).unwrap()), + args.next().unwrap(), + ) +} diff --git a/imap/folder-manager/src/manager.rs b/imap/folder-manager/src/manager.rs new file mode 100644 index 00000000..17227707 --- /dev/null +++ b/imap/folder-manager/src/manager.rs @@ -0,0 +1,663 @@ +//! Where everything is tied together and IMAP mailboxes become MH folders. +//! +//! [Manager] delegates much of its work to various sub-modules, and most of +//! what you see in here is for their usage. +//! +//! # Error recovery +//! The following macros are available to facilitate recovering from errors. +//! - [recover_authenticated] +//! - [recover_selected] + +use std::io::Read as _; + +use log::trace; + +use managed_folders as nmf; +use nmh::{folder, message}; +use nmhimapstore::{ + FolderPathMapper, FolderStore, ReadOnlyTransaction, ReadTransaction, WriteTransaction, +}; +use nmhimaptypes::{Uid, UidValidity}; + +use crate::imap; +use imap::{mailbox, Flag, Session}; + +mod command; +mod mapper; +mod pick; + +pub type Error = crate::error::RecoverableError; + +pub use mapper::FolderMailboxMapper; + +/// The best manager of folders and IMAP mailboxes nmh has ever seen. +#[derive(Debug)] +pub struct Manager { + fmm: FolderMailboxMapper, + fpm: FolderPathMapper, + state: State, + cf: crate::cf::Manager, + buf: Vec, +} + +/// Use this to recover from errors in the authenticated state. When passed a [Result] of: +/// - [Ok] - passes it along +/// - [imap::Error] with a recoverable [imap::Session] - reconstructs the [Manager] with the session +/// - [imap::Error] but not recoverable - passes the error along +/// +/// Parameters +/// - `$mparts` is [ManagerParts] whose [ManagerParts::recover_authenticated] to use to recover +/// [imap::Session] from [imap::Error] into the [Manager] of an [Error]. +/// - `$expression` evaluates to [imap::Error] +#[macro_export] +macro_rules! recover_authenticated { + ($mparts:ident, $expression:expr) => {{ + let mut rm = $mparts.recover_authenticated(); + ($expression.map_err(|e| rm.map(e))?, rm.ok()) + }}; +} + +/// Use this to recover from errors in the selected state. When passed a [Result] of: +/// - [Ok] - passes it along +/// - [imap::mailbox::Error] with a recoverable [imap::mailbox::Session] - reconstructs the [Manager] with the session +/// - [imap::mailbox::Error] but not recoverable - passes the error along +/// +/// Parameters +/// - `$mparts` is [ManagerParts] whose [ManagerParts::recover_selected] to use along with `$selected` to +/// recover [imap::Session] from [imap::Error] into the [Manager] of an [Error]. +/// - `$mailbox_name` is the name of the mailbox to go into [State::Selected]; used with `$mparts` +/// - `$selected` is the [SelectedParts] from the [imap::mailbox::Selected]; used with `$mparts` +/// - `$expression` evaluates to [imap::mailbox::Error] +#[macro_export] +macro_rules! recover_selected { + ($mparts:ident, $mailbox_name:ident, $selected:ident, $expression:expr) => {{ + let mut rm = $mparts.recover_selected($mailbox_name, $selected); + let (result, mailbox_session) = $expression.map_err(|e| rm.map(e))?; + let (mparts, mailbox_name, selected_parts) = rm.ok(); + ( + result, + mailbox_name, + selected_parts.construct(mailbox_session), + mparts, + ) + }}; +} + +impl Manager { + pub fn new( + fmm: FolderMailboxMapper, + fpm: FolderPathMapper, + session: imap::Session, + cf: crate::cf::Manager, + ) -> Self { + Self { + fmm, + fpm, + state: State::Authenticated(session), + cf, + buf: vec![0; 8192], + } + } + + pub fn run( + mut self, + command: nmf::Command, + ) -> Result<(Self, nmf::FolderManagerProtocol), String> { + Ok(match command { + nmf::Command::Quit => todo!(), + + nmf::Command::Inc(mut response, command) => { + match command::inc::inc(self, &mut response, &command.folder) { + Err(recoverable) => { + crate::logging::error(&recoverable); + let message = + format!("failed to inc folder {}: {}", command.folder, recoverable); + match recoverable.recover() { + Ok((recovered, _)) => (recovered, response.error(&message)?), + Err(e) => { + response.error(&message)?; + return Err(e.to_string()); + } + } + } + Ok(new_self) => (new_self, response.ok()?), + } + } + + nmf::Command::MakeDraft(response, command) => { + let (msgnum, new_self) = self.make_draft(command).unwrap(); + (new_self, response.ok(msgnum)?) + } + + // All the rest use EmptyResponse. + nmf::Command::No(response) => (self, response.ok()?), + nmf::Command::Folder(_, _) => todo!(), + nmf::Command::MakeFolder(response, command) => { + match self.make_folder(&command.folder) { + Err(recoverable) => { + crate::logging::error(&recoverable); + let message = format!( + "failed to create folder {}: {}", + command.folder, recoverable + ); + match recoverable.recover() { + Ok((recovered, _)) => (recovered, response.error(&message)?), + Err(e) => { + response.error(&message)?; + return Err(e.to_string()); + } + } + } + Ok(new_self) => (new_self, response.ok()?), + } + } + nmf::Command::MarkAdd(response, command) => { + self = command::mark::add(self, command).unwrap(); + (self, response.ok()?) + } + nmf::Command::MarkDel(response, command) => { + self = command::mark::delete(self, command).unwrap(); + (self, response.ok()?) + } + nmf::Command::Pick(response, command) => { + self = command::pick::pick(self, command).unwrap(); + (self, response.ok()?) + } + nmf::Command::RefileFile(response, command) => { + (self.refile_file(command).unwrap(), response.ok()?) + } + nmf::Command::RefileMove(response, command) => { + (self.refile_move(command).unwrap(), response.ok()?) + } + nmf::Command::Rmf(response, command) => (self.rmf(command).unwrap(), response.ok()?), + nmf::Command::Rmm(response, command) => (self.rmm(command).unwrap(), response.ok()?), + nmf::Command::UpdateDraft(response, command) => { + (self.update_draft(command).unwrap(), response.ok()?) + } + }) + } + + /// Destruct into session `State` and everything else - used for error recovery. + pub fn destruct(self) -> (State, ManagerParts) { + ( + self.state, + ManagerParts { + fmm: self.fmm, + fpm: self.fpm, + cf: self.cf, + buf: self.buf, + }, + ) + } + + // TODO the rest of these methods are some of the oldest and most in need of work + // TODO move these out + + fn make_draft( + mut self, + command: nmf::command::Folder, + ) -> Result<(message::Number, Self), Error> { + // TODO if drafts folder doesn't exist: + // <= * OK [TRYCREATE] Must create mailbox before append + // <= 1 NO [TRYCREATE] Must create mailbox before append + // TODO folder is nmh folder! map to imap mailbox name + let mailbox: mailbox::Name = self.fmm.folder_name_to_mailbox(&command.folder)?; + let (state, mparts) = self.destruct(); + let session = match state { + State::Authenticated(session) => session, + State::Selected(_, selected) => match selected.mailbox_session.close() { + Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))), + Ok(session) => { + trace!("comp CLOSEd into Authenticated state for APPEND (TODO don't)"); + session + } + }, + }; + let (((uidvalidity, uid), session), mparts) = recover_authenticated!( + mparts, + session.append( + &mailbox, + &[Flag::Draft, Flag::Seen,], + // TODO put something else here. It's not that important; the + // user is composing a draft and we'll upload that as soon as + // they're done. But if they should walk away, it would be good + // not to leave a weird message like this behind for them to be + // surprised by. Maybe we shouldn't be appending a placeholder + // message at all, but should instead be able to reserve a + // pending message number which doesn't yet have a UID. + "From:me\r\nTo:you\r\n\r\n\r\n", + ) + ); + self = mparts.authenticated(session); + + let store: FolderStore = store::open(&self.fpm, &command.folder, uidvalidity)?; + let mut txn: WriteTransaction = store.write_transaction()?; + let msgnum: message::Number = txn.store_message(uid)?; + txn.commit()?; + Ok((msgnum, self)) + } + + fn make_folder(self, folder_name: &folder::Name) -> Result { + let mailbox: mailbox::Name = self.fmm.folder_name_to_mailbox(folder_name)?; + let (state, mparts) = self.destruct(); + let session = match state { + State::Authenticated(session) => session, + State::Selected(_, selected) => match selected.mailbox_session.close() { + Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))), + Ok(session) => { + trace!("CLOSEd into Authenticated state for CREATE (TODO don't)"); + session + } + }, + }; + let (session, mparts) = recover_authenticated!(mparts, session.create(mailbox.as_str())); + let (selected, mparts): (mailbox::Selected, ManagerParts) = + recover_authenticated!(mparts, session.select(&mailbox)); + FolderStore::create( + &mparts.fpm, + folder_name, + selected.uid_next, + selected.uid_validity, + )?; + // TODO don't CLOSE + Ok(mparts.authenticated(selected.mailbox_session.close().map_err(nmh::Error::wrap)?)) + } + + fn delete_message( + mut self, + folder: &folder::Name, + msgnum: message::Number, + ) -> Result { + // 0. Open mailbox + let (state, mparts) = self.destruct(); + let session = match state { + State::Authenticated(session) => session, + State::Selected(_, selected) => match selected.mailbox_session.close() { + Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))), + Ok(session) => { + trace!("rmm CLOSEd into Authenticated state for STORE (TODO check first)"); + session + } + }, + }; + + let mailbox_name = mparts.fmm.folder_name_to_mailbox(folder)?; + let (selected, mparts): (mailbox::Selected, ManagerParts) = + recover_authenticated!(mparts, session.select(&mailbox_name)); + + // 1. Resolve message ranges to UID ranges + let store: FolderStore = store::open(&mparts.fpm, folder, selected.uid_validity)?; + let uid: Uid = store + .read_transaction()? + .lookup_uid(msgnum)? + .ok_or_else(|| { + todo!("recoverable error: user-specified message does not exist"); + }) + .unwrap(); + + // 2. STORE + EXPUNGE on deleting all UIDs + let (mailbox_session, selected_parts) = SelectedParts::destruct(selected); + let (_, _, selected, mparts) = recover_selected!( + mparts, + mailbox_name, + selected_parts, + mailbox_session.add_flags(&uid.to_string(), &[Flag::Deleted]) + ); + // TODO EXPUNGE rather than CLOSE + self = mparts.authenticated(selected.mailbox_session.close().map_err(nmh::Error::wrap)?); + + // 3. Delete all msgnum/UID pairs from db in one transaction + store.delete_message(msgnum, uid)?; + + Ok(self) + } + + fn copy_message( + mut self, + src_folder: &folder::Name, + src: message::Number, + dst_folder: &folder::Name, + ) -> Result { + let (state, mparts) = self.destruct(); + let session = match state { + State::Authenticated(session) => session, + State::Selected(_, selected) => match selected.mailbox_session.close() { + Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))), + Ok(session) => { + trace!("rmm CLOSEd into Authenticated state for COPY (TODO check first)"); + session + } + }, + }; + let src_mailbox_name: mailbox::Name = mparts.fmm.folder_name_to_mailbox(src_folder)?; + let (src_selected, mparts): (mailbox::Selected, ManagerParts) = + recover_authenticated!(mparts, session.select(&src_mailbox_name)); + + let src_uid: Uid = { + let src_store: FolderStore = + store::open(&mparts.fpm, src_folder, src_selected.uid_validity)?; + let txn: ReadOnlyTransaction = src_store.read_transaction()?; + match txn.lookup_uid(src)? { + None => { + return Err(Error::recoverable( + nmh::Error::new(format!("message {} doesn't exist", src)), + mparts.selected(src_mailbox_name, src_selected), + )); + } + Some(v) => v, + } + }; + + // 1. COPY + let dst_mailbox_name: mailbox::Name = mparts.fmm.folder_name_to_mailbox(dst_folder)?; + let (src_mailbox_session, src_selected_parts) = SelectedParts::destruct(src_selected); + let ((dst_uidvalidity, dst_uid), _, src_selected, mparts) = recover_selected!( + mparts, + src_mailbox_name, + src_selected_parts, + src_mailbox_session.copy(src_uid, &dst_mailbox_name) + ); + self = mparts.authenticated( + // TODO need to CLOSE? + src_selected + .mailbox_session + .close() + .map_err(nmh::Error::wrap)?, + ); + + // 2. Find latest msgnum from target folder msg2uid table + // TODO check UIDVALIDITY later when we get it + // TODO move to FolderStore + let dst_store: FolderStore = store::open(&self.fpm, dst_folder, dst_uidvalidity)?; + let mut dst_txn: WriteTransaction = dst_store.write_transaction()?; + // TODO don't want highest UID; worth having msgnum-only form? + let dst_msgnum: message::Number = match dst_txn.highest()? { + None => message::Number::one(), + Some((_, mut msgnum)) => { + msgnum.increment(); + msgnum + } + }; + + // 3. Link msg file into target folder + // TODO std::fs::remove_file(dst_msg_path) + std::fs::hard_link( + self.fpm.msg_path(src_folder, src), + self.fpm.msg_path(dst_folder, dst_msgnum), + ) + .unwrap(); + + // TODO check dst_store UIDVALIDITY against that returned by mailbox::Session::copy. + + // 4. Update tables + // Tested by test-live:'mark unseen after refile'. + dst_txn.set_uid_msgnum(dst_uid, dst_msgnum)?; + + // TODO make WriteTransaction & PendingDelete panic in Drop when not committed + dst_txn.commit()?; + + Ok(self) + } + + fn rmf(mut self, command: nmf::command::Folder) -> Result { + // 1. Attempt mailbox deletion first so we don't delete local cache pointlessly. + let session = match self.state { + State::Authenticated(session) => session, + State::Selected(_, selected) => match selected.mailbox_session.close() { + Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))), + Ok(session) => { + trace!("rmf CLOSEd into Authenticated state for DELETE (TODO don't)"); + session + } + }, + }; + self.state = State::Authenticated( + session + .delete(&self.fmm.folder_name_to_mailbox(&command.folder)?) + .map_err(nmh::Error::wrap)?, + ); + // 2. Delete local files. If any foreign objects in folder, leave them. + nmhimapstore::delete(&self.fpm, &command.folder)?; + Ok(self) + } + + fn rmm(self, command: nmf::command::Rmm) -> Result { + // TODO message range parsing for removing more than one message + let msgnum: message::Number = command.msgs[0].parse()?; + self.delete_message(&command.folder, msgnum) + } + + fn refile_file(self, command: nmf::command::RefileFile) -> Result { + // 1. read file + let contents: String = { + let mut file = std::fs::File::open(command.file_path).unwrap(); + let mut contents = String::new(); + file.read_to_string(&mut contents).unwrap(); + contents + }; + + // 2. APPEND to mailbox. + let (state, mparts) = self.destruct(); + let session = match state { + State::Authenticated(session) => session, + State::Selected(_, selected) => match selected.mailbox_session.close() { + Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))), + Ok(session) => { + trace!("refile CLOSEd into Authenticated state for APPEND (TODO don't)"); + session + } + }, + }; + let mailbox_name: mailbox::Name = mparts.fmm.folder_name_to_mailbox(&command.dst_folder)?; + let (((dst_uidvalidity, dst_uid), session), mparts) = recover_authenticated!( + mparts, + session.append( + &mailbox_name, + &[Flag::Seen], + &contents.lines().collect::>().join("\r\n"), + ) + ); + + // 3. Append to folder. + let mut writer: nmhimapstore::MessageWriter = + store::open(&mparts.fpm, &command.dst_folder, dst_uidvalidity)? + .append(&mparts.fpm, &command.dst_folder)? + .append(dst_uid)?; + writer.write_all(contents.as_bytes())?; + let (_, new_appender) = writer.close()?; + new_appender.close(); + + Ok(mparts.authenticated(session)) + } + + fn refile_move(self, command: nmf::command::RefileMove) -> Result { + // TODO message range parsing for refiling more than one message + let msgnum: message::Number = command.msgs[0].parse()?; + self.copy_message(&command.src_folder, msgnum, &command.dst_folder)? + .delete_message(&command.src_folder, msgnum) + } + + fn update_draft(self, command: nmf::command::UpdateDraft) -> Result { + // TODO Return error to client rather than let Number::from panic. + let contents: String = + std::fs::read_to_string(self.fpm.msg_path(&command.folder, command.msgnum)) + .unwrap() + .lines() + .collect::>() + .join("\r\n"); + + // 1. let new_uid = APPEND new contents + let mailbox: mailbox::Name = self.fmm.folder_name_to_mailbox(&command.folder)?; + let (state, mparts) = self.destruct(); + let session = match state { + State::Authenticated(session) => session, + State::Selected(_, selected) => match selected.mailbox_session.close() { + Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))), + Ok(session) => { + trace!("update-draft CLOSEd into Authenticated state for APPEND (TODO don't)"); + session + } + }, + }; + + let (((uidvalidity, new_uid), session), mparts) = recover_authenticated!( + mparts, + session.append(&mailbox, &[Flag::Draft, Flag::Seen,], &contents) + ); + + // If we crash here, the next inc will pull in the updated draft as a + // new message. Locally, old and new will be identical, though the + // old draft won't match what's on the server. + // TODO test this + + // 2. let old_uid = lookup(msg) + // TODO check UIDVALIDITY later when we get it + let store: FolderStore = store::open(&mparts.fpm, &command.folder, uidvalidity)?; + let mut txn: WriteTransaction = store.write_transaction()?; + + // 3. Update msg->uid & uid->msg tables for new_uid + let old_uid = txn.replace_uid_msgnum(new_uid, command.msgnum)?; + txn.commit()?; + + // If we crash here, the next inc will pull in a copy of the OLD draft as a new message. + // TODO test this + + // 4. Delete the old message. + let (selected, mparts): (mailbox::Selected, ManagerParts) = + recover_authenticated!(mparts, session.select(&mailbox)); + let (mailbox_session, selected_parts) = SelectedParts::destruct(selected); + let (_, _, selected, mparts) = recover_selected!( + mparts, + mailbox, + selected_parts, + mailbox_session.add_flags(&old_uid.to_string(), &[Flag::Deleted]) + ); + // TODO EXPUNGE rather than CLOSE + Ok(mparts.authenticated(selected.mailbox_session.close().map_err(nmh::Error::wrap)?)) + } +} + +/// [mailbox::Selected] without [mailbox::Session] - used for error recovery. +pub struct SelectedParts { + message_count: u32, + uid_next: Uid, + uid_validity: UidValidity, +} +impl SelectedParts { + /// Destruct `selected` into session and everything else. + fn destruct(selected: mailbox::Selected) -> (mailbox::Session, Self) { + ( + selected.mailbox_session, + Self { + message_count: selected.message_count, + uid_next: selected.uid_next, + uid_validity: selected.uid_validity, + }, + ) + } + + /// Put it back together. + fn construct(self, mailbox_session: mailbox::Session) -> mailbox::Selected { + mailbox::Selected { + mailbox_session, + message_count: self.message_count, + uid_next: self.uid_next, + uid_validity: self.uid_validity, + } + } +} + +/// [Manager] without [State] - used for error recovery. Get one from [Manager::destruct]. +pub struct ManagerParts { + fmm: FolderMailboxMapper, + fpm: FolderPathMapper, + cf: crate::cf::Manager, + buf: Vec, +} + +impl ManagerParts { + /// Return `RecoverMapper` for use in recovering [Session]. See [recover_authenticated]. + pub fn recover_authenticated<'a>( + self, + ) -> crate::error::RecoverMapper<'a, Session, Manager, Self> { + crate::error::RecoverMapper::new(&|session, mparts| mparts.authenticated(session), self) + } + + /// Return `RecoverMapper` for use in recovering [mailbox::Session]. See [recover_selected]. + pub fn recover_selected<'a>( + self, + mailbox_name: mailbox::Name, + selected_parts: SelectedParts, + ) -> crate::error::RecoverMapper< + 'a, + mailbox::Session, + Manager, + (Self, mailbox::Name, SelectedParts), + > { + crate::error::RecoverMapper::new( + &|mailbox_session, (mparts, name, selected)| { + mparts.selected(name, selected.construct(mailbox_session)) + }, + (self, mailbox_name, selected_parts), + ) + } + + /// Return `Manager` in the authenticated state. + fn authenticated(self, session: Session) -> Manager { + self.construct(State::Authenticated(session)) + } + + /// Return `Manager` in the selected state. + fn selected(self, name: mailbox::Name, selected: mailbox::Selected) -> Manager { + self.construct(State::Selected(name, selected)) + } + + fn construct(self, state: State) -> Manager { + Manager { + fmm: self.fmm, + fpm: self.fpm, + cf: self.cf, + buf: self.buf, + state, + } + } +} + +/// See [imap] regarding authenticated and selected state. +#[derive(Debug)] +pub enum State { + Authenticated(Session), + // TODO Expand Selected with a little cache mapping message arguments to the result, so we + // don't have to hit the server repeatedly when the user runs the same pick over and over, or + // something like `show unseen:next` over and over, etc. + Selected(mailbox::Name, mailbox::Selected), +} + +/// internal [FolderStore] wrappers +mod store { + use super::*; + + pub fn open( + fpm: &FolderPathMapper, + folder_name: &folder::Name, + uid_validity: UidValidity, + ) -> Result { + FolderStore::open(fpm, folder_name, uid_validity) + .map_err(|e| nmh::Error::describe(e, format_args!("open {}", folder_name))) + } + + pub fn open_or_create( + fpm: &FolderPathMapper, + folder_name: &folder::Name, + uid_validity: UidValidity, + ) -> Result { + FolderStore::open_or_create(fpm, folder_name, uid_validity) + .map_err(|e| nmh::Error::describe(e, format_args!("open {}", folder_name))) + } +} + +impl From for Error { + fn from(e: nmh::Error) -> Self { + Self::unrecoverable(e) + } +} diff --git a/imap/folder-manager/src/manager/command.rs b/imap/folder-manager/src/manager/command.rs new file mode 100644 index 00000000..8a08fa22 --- /dev/null +++ b/imap/folder-manager/src/manager/command.rs @@ -0,0 +1,5 @@ +//! Folder manager command implementations live here. + +pub mod inc; +pub mod mark; +pub mod pick; diff --git a/imap/folder-manager/src/manager/command/inc.rs b/imap/folder-manager/src/manager/command/inc.rs new file mode 100644 index 00000000..9845f8d8 --- /dev/null +++ b/imap/folder-manager/src/manager/command/inc.rs @@ -0,0 +1,278 @@ +//! [inc] implements the `inc` command. + +use log::{debug, trace}; + +use managed_folders as nmf; +use nmh::folder; +use nmhimapstore::{FolderPathMapper, FolderStore, ReadTransaction}; +use nmhimaptypes::{uid, Uid}; + +use crate::imap::{mailbox, FetchResult, Mailbox, MessageResult}; +use crate::manager::{store, Error, Manager, SelectedParts, State}; +use crate::recover_selected; + +/// Incorporate new mail and delete messages gone from server. +/// If `folder` maps to the root of the mailbox hierarchy we synchronize, inc it and all child mailboxes. +/// Else, inc only the specified mailbox. +pub fn inc( + mut manager: Manager, + response: &mut nmf::IncResponse, + folder: &folder::Name, +) -> Result { + let mailbox: mailbox::Name = manager.fmm.folder_name_to_mailbox(folder)?; + trace!( + "inc({}) root={} mb={}", + folder, + manager.fmm.mailbox_root, + mailbox + ); + if mailbox.as_str() != manager.fmm.mailbox_root { + trace!("inc requested specific mailbox {}", mailbox); + manager = inc_mailbox(manager, response, mailbox)?; + return Ok(manager); + } + let mut mailboxes: Vec; + match manager.state { + State::Authenticated(session) => { + let (value, session) = match session.list(&manager.fmm.mailbox_root) { + Err(e) => match e.recover() { + Err(e) => return Err(Error::unrecoverable(e)), + Ok((session, e)) => { + manager.state = State::Authenticated(session); + return Err(Error::recoverable(e, manager)); + } + }, + Ok(v) => v, + }; + manager.state = State::Authenticated(session); + mailboxes = value + } + State::Selected(mailbox_name, mut selected) => { + match selected.mailbox_session.list(&manager.fmm.mailbox_root) { + Err(recoverable) => match recoverable.recover() { + Err(e) => return Err(Error::unrecoverable(e)), + Ok((mailbox_session, e)) => { + selected.mailbox_session = mailbox_session; + manager.state = State::Selected(mailbox_name, selected); + return Err(Error::recoverable(e, manager)); + } + }, + Ok((value, mailbox_session)) => { + selected.mailbox_session = mailbox_session; + manager.state = State::Selected(mailbox_name, selected); + mailboxes = value + } + } + } + } + mailboxes.sort(); + for mailbox in mailboxes { + if !mailbox.selectable { + trace!("inc {} not selectable", mailbox.name); + // TODO if this is the mailbox named by `folder`, report error here. + continue; + } + if !manager.cf.rules.included(mailbox.name.as_str()) { + trace!("inc {} excluded by user", mailbox.name); + continue; + } + trace!("inc {}", mailbox.name); + manager = inc_mailbox(manager, response, mailbox.name)?; + } + Ok(manager) +} + +fn inc_mailbox( + manager: Manager, + response: &mut nmf::IncResponse, + mailbox_name: mailbox::Name, +) -> Result { + let (state, mparts) = manager.destruct(); + let selected: mailbox::Selected = match state { + State::Authenticated(session) => match session.select(&mailbox_name) { + Err(recoverable) => match recoverable.recover() { + Err(e) => return Err(Error::unrecoverable(e)), + Ok((session, e)) => { + return Err(Error::recoverable(e, mparts.authenticated(session))); + } + }, + Ok(selected) => { + trace!("inc SELECT {} from Authenticated", mailbox_name); + selected + } + }, + State::Selected(selected_name, mut selected) => { + if selected_name == mailbox_name { + trace!("inc {} already Selected", mailbox_name); + selected + } else { + match selected.mailbox_session.select(&mailbox_name) { + Err(recoverable) => match recoverable.recover() { + Err(e) => return Err(Error::unrecoverable(e)), + Ok((session, e)) => { + selected.mailbox_session = session; + return Err(Error::recoverable( + e, + mparts.selected(selected_name, selected), + )); + } + }, + Ok(selected) => { + trace!( + "inc SELECT {} from Selected({})", + mailbox_name, + selected_name + ); + selected + } + } + } + } + }; + + let folder_name: folder::Name = mparts.fmm.mailbox_name_to_folder(mailbox_name.as_str())?; + let mut store: FolderStore = + store::open_or_create(&mparts.fpm, &folder_name, selected.uid_validity)?; + + // SEARCH all messages into uid_set. + let (mailbox_session, selected_parts) = SelectedParts::destruct(selected); + let (uid_set, mailbox_name, mut selected, mut mparts) = recover_selected!( + mparts, + mailbox_name, + selected_parts, + mailbox_session.search_all() + ); + + if selected.message_count > 0 { + // TODO(rust-lang#71126): (selected, store) = fetch(... + let result = fetch( + store, + response, + selected, + &mut mparts.buf, + &uid_set, + &mparts.cf, + &mparts.fpm, + &folder_name, + )?; + selected = result.0; + store = result.1; + } + // TODO make deletion optional? maybe have some option like inc -pick-delete that picks the + // messages to be deleted so you can scan, show, maybe even copy before deleting? + delete(uid_set, store, selected.uid_next).map_err(|e| { + nmh::Error::describe( + e, + format_args!("inc looking for messages to delete in {}", folder_name), + ) + })?; + Ok(mparts.selected(mailbox_name, selected)) +} + +/// Use [mailbox::Session::fetch] to fetch messages into `store`. +fn fetch( + mut store: FolderStore, + response: &mut nmf::IncResponse, + mut selected: mailbox::Selected, + buf: &mut [u8], + uid_set: &uid::Set, + cf: &crate::cf::Manager, + fpm: &FolderPathMapper, + folder_name: &folder::Name, +) -> Result<(mailbox::Selected, FolderStore), nmh::Error> { + let mut appender = store + .append(fpm, folder_name) + .map_err(|e| nmh::Error::describe(e, format_args!("inc open {}", folder_name)))?; + // This is not the same as appender.highest_uid as a + // UID greater than that may have been allocated and then deleted before we + // got here, and we don't want to waste resources attempting to + // FETCH nothing from such a folder on every sync. + if let Some(uidnext) = appender.uidnext { + if uidnext == selected.uid_next { + // covered by test-inc:'inc after inc finds no new mail' + debug!("UIDNEXT hasn't changed from {}", uidnext); + store = appender.close(); + return Ok((selected, store)); + } + } + + let highest_uid_or_zero = appender.highest_uid.map(u32::from).unwrap_or(0); + let mut uids_to_fetch = uid_set + .iter() + .skip_while(|uid| **uid <= highest_uid_or_zero); + + let mut result: FetchResult = selected + .mailbox_session + .fetch(cf.max_fetch_messages_in_flight, &mut uids_to_fetch)?; + let mailbox_session: mailbox::Session = loop { + match result { + FetchResult::Message(uid, seen, mut message_reader) => { + trace!("inc receiving uid={} seen={}", uid, seen,); + let mut writer = appender.append(uid).map_err(|e| { + nmh::Error::describe(e, format_args!("inc append message to {}", folder_name)) + })?; + result = loop { + match message_reader.read(buf) { + Err(e) => { + return Err(e); + } + Ok(MessageResult::Reading(bytes, new_reader)) => { + writer.write_all(bytes).map_err(|e| { + nmh::Error::describe( + e, + format_args!("inc write to message file in {}", folder_name), + ) + })?; + message_reader = new_reader; + } + Ok(MessageResult::Done(r)) => break r, + } + }; + let (msgnum, new_appender) = writer.close().map_err(|e| { + nmh::Error::describe( + e, + format_args!("inc close message file in {}", folder_name), + ) + })?; + appender = new_appender; + response + .message(folder_name, msgnum, !seen) + .map_err(|e| nmh::Error::new(e.to_string()))?; + } + FetchResult::Done(session) => break session, + } + }; + // TODO Don't write uidnext unless it changed. + store = appender.close(); + let uidnext = selected.uid_next; + store.write_uidnext(uidnext).map_err(|e| { + nmh::Error::describe( + e, + format_args!("inc store UIDNEXT={} in {}", uidnext, folder_name), + ) + })?; + selected.mailbox_session = mailbox_session; + Ok((selected, store)) +} + +/// Delete `uid_set` from `store`, as they are gone from the server. +fn delete(uid_set: uid::Set, store: FolderStore, uidnext: Uid) -> Result<(), nmh::Error> { + // TODO write_transaction and add a delete_message to it? + let txn = store.read_transaction()?; + let uid2msg_iter = txn.uid_to_message_number_cursor()?; + for result in uid2msg_iter { + let (uid, msgnum) = result?; + let uid_u32: u32 = uid.into(); + if uid_u32 >= uidnext.into() { + break; + } + if uid_set.contains(uid_u32) { + trace!("inc NOT deleting msg={} uid={}", msgnum, uid); + } else { + trace!("inc deleting msg={} uid={}", msgnum, uid); + // TODO add WriteTransaction::delete ? + store.delete_message(msgnum, uid)?; + } + } + Ok(()) +} diff --git a/imap/folder-manager/src/manager/command/mark.rs b/imap/folder-manager/src/manager/command/mark.rs new file mode 100644 index 00000000..4268baab --- /dev/null +++ b/imap/folder-manager/src/manager/command/mark.rs @@ -0,0 +1,200 @@ +//! [add::add] implements `mark -add` and [delete::delete] `mark -del` + +use managed_folders as nmf; +use nmf::command; +use nmh::{folder, message}; + +use crate::imap::{mailbox, Flag}; +use crate::manager::pick::{ + check_for_all_or_unseen, pick_messages, write_sequences, LocalPicker, RemotePicker, +}; +use crate::manager::{Error, Manager, ManagerParts, SelectedParts, State}; +use crate::{recover_authenticated, recover_selected}; + +mod add; +pub use add::add; + +mod delete; +pub use delete::delete; + +// TODO Read Unseen-Sequence. + +fn mark( + mark_server: MarkServer, + mark_local: MarkLocal, + manager: Manager, + mut command: command::Mark, +) -> Result +where + MarkServer: + Fn(mailbox::Session, &str, &[Flag]) -> Result<((), mailbox::Session), mailbox::Error>, + MarkLocal: Fn( + std::slice::Iter<(&str, &str)>, + message::Set, + message::Set, + Vec<&str>, + ) -> Result, nmh::Error>, +{ + // TODO Support other sequences; map them to flags? Optionally? + let need_unseen = check_for_all_or_unseen(&mut command.msgs); + if need_unseen || command.sequences.contains(&"unseen") { + mark_with_unseen( + mark_server, + mark_local, + manager, + command.folder, + command.msgs, + command.sequences, + ) + } else { + Ok(mark_without_unseen( + mark_local, + manager, + command.folder, + command.msgs, + command.sequences, + )?) + } +} + +fn mark_with_unseen( + mark_server: MarkServer, + mark_local: MarkLocal, + mut manager: Manager, + folder_name: folder::Name, + specs: Vec<&str>, + mark_sequences: Vec<&str>, +) -> Result +where + MarkServer: + Fn(mailbox::Session, &str, &[Flag]) -> Result<((), mailbox::Session), mailbox::Error>, + MarkLocal: Fn( + std::slice::Iter<(&str, &str)>, + message::Set, + message::Set, + Vec<&str>, + ) -> Result, nmh::Error>, +{ + let (state, mparts) = manager.destruct(); + let session = match state { + State::Authenticated(session) => session, + State::Selected(_, selected) => match selected.mailbox_session.close() { + Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))), + Ok(session) => { + log::trace!("rmm CLOSEd into Authenticated state for STORE (TODO check first)"); + session + } + }, + }; + + let mailbox_name: mailbox::Name = mparts.fmm.folder_name_to_mailbox(&folder_name)?; + let (selected, mparts): (mailbox::Selected, ManagerParts) = + recover_authenticated!(mparts, session.select(&mailbox_name)); + + let picker = RemotePicker::new(&mparts.fpm, selected.uid_validity, &folder_name)?; + + // SEARCH UNSEEN messages into unseen_uids. + let (mailbox_session, selected_parts) = SelectedParts::destruct(selected); + let (unseen_uids, mailbox_name, selected, mparts) = recover_selected!( + mparts, + mailbox_name, + selected_parts, + mailbox_session.search_unseen() + ); + + let picker_txn = picker.txn()?; + let mut picked_messages = message::Set::new(); + let mut unseen_messages: message::Set = picker_txn.to_message_numbers(unseen_uids)?; + let mut uids_to_add = Vec::::new(); + let sequences = picker.parse_sequences()?; + pick_messages( + specs, + &sequences, + // TODO Rather than clone it, move our unseen_messages in and then take it back out later? + unseen_messages.clone(), + picker_txn.cursor()?, + |(msgnum, uid)| { + let msgnum_i32: i32 = msgnum.into(); + picked_messages.insert(msgnum_i32); + // TODO move this check out + if mark_sequences.contains(&"unseen") { + unseen_messages.remove(msgnum_i32); + uids_to_add.push(uid); + } + }, + )?; + + if uids_to_add.is_empty() { + manager = mparts.selected(mailbox_name, selected); + } else { + // TODO Should this be a UidSet, and this below its Display impl? + // TODO use UID ranges + let s: String = uids_to_add + .iter() + .map(|u| u.to_string()) + .collect::>() + .join(","); + let (mailbox_session, selected_parts) = SelectedParts::destruct(selected); + let (_, _, selected, mparts) = recover_selected!( + mparts, + mailbox_name, + selected_parts, + mark_server(mailbox_session, &s, &[Flag::Seen]) + ); + // CLOSE to persist the flag changes. + manager = mparts.authenticated(selected.mailbox_session.close().map_err(nmh::Error::wrap)?); + } + + write_sequences( + picker.seq_path(), + mark_local( + sequences.iter(), + picked_messages, + unseen_messages, + mark_sequences, + )?, + )?; + Ok(manager) +} + +fn mark_without_unseen( + mark_sequences: F, + manager: Manager, + folder_name: folder::Name, + specs: Vec<&str>, + sequences_to_mark: Vec<&str>, +) -> Result +where + F: Fn( + std::slice::Iter<(&str, &str)>, + message::Set, + message::Set, + Vec<&str>, + ) -> Result, nmh::Error>, +{ + let picker = LocalPicker::new(&manager.fpm, &folder_name)?; + let sequences: nmf::Components = picker.parse_sequences()?; + + let mut picked_messages = message::Set::new(); + pick_messages( + specs, + &sequences, + message::Set::new(), + picker.txn()?.cursor()?, + |(msgnum, _)| { + picked_messages.insert(i32::from(msgnum)); + }, + )?; + + write_sequences( + picker.seq_path(), + mark_sequences( + sequences.iter(), + picked_messages, + message::Set::new(), + sequences_to_mark, + )?, + )?; + + Ok(manager) +} diff --git a/imap/folder-manager/src/manager/command/mark/add.rs b/imap/folder-manager/src/manager/command/mark/add.rs new file mode 100644 index 00000000..48e220f0 --- /dev/null +++ b/imap/folder-manager/src/manager/command/mark/add.rs @@ -0,0 +1,53 @@ +//! Implements `mark -add`. + +use crate::manager::pick::parse_msgs; +use crate::manager::{Error, Manager}; + +pub fn add(manager: Manager, command: managed_folders::command::Mark) -> Result { + super::mark( + |mailbox_session, s, flags| mailbox_session.del_flags(s, flags), + update_sequences_adding_marked, + manager, + command, + ) +} + +fn update_sequences_adding_marked( + sequences: std::slice::Iter<(&str, &str)>, + picked_messages: nmh::message::Set, + unseen_messages: nmh::message::Set, + mut mark_sequences: Vec<&str>, +) -> Result, nmh::Error> { + // TODO make nmh-picked configurable. if user already configured Previous-Sequence, use that? + let mut result = vec![("nmh-picked".to_string(), picked_messages.to_string())]; + if !unseen_messages.is_empty() { + result.push(("unseen".to_string(), unseen_messages.to_string())); + } + for (name, value) in sequences { + if *name == "nmh-picked" || *name == "unseen" { + continue; + } + let mut marked = None; + for i in 0..mark_sequences.len() { + if mark_sequences[i] == *name { + marked = Some(i); + let mut ranges = parse_msgs(value)?; + ranges.append(&mut picked_messages.clone()); + if !ranges.is_empty() { + result.push((name.to_string(), ranges.to_string())); + } + break; + } + } + match marked { + None => result.push((name.to_string(), value.to_string())), + Some(i) => { + mark_sequences.remove(i); + } + } + } + for new_sequence in mark_sequences.iter() { + result.push((new_sequence.to_string(), picked_messages.to_string())) + } + Ok(result) +} diff --git a/imap/folder-manager/src/manager/command/mark/delete.rs b/imap/folder-manager/src/manager/command/mark/delete.rs new file mode 100644 index 00000000..8b337db5 --- /dev/null +++ b/imap/folder-manager/src/manager/command/mark/delete.rs @@ -0,0 +1,47 @@ +//! Implements `mark -del`. + +use crate::manager::pick::parse_msgs; +use crate::manager::{Error, Manager}; + +pub fn delete(manager: Manager, command: managed_folders::command::Mark) -> Result { + super::mark( + |mailbox_session, s, flags| mailbox_session.add_flags(s, flags), + update_sequences_deleting_marked, + manager, + command, + ) +} + +fn update_sequences_deleting_marked( + sequences: std::slice::Iter<(&str, &str)>, + picked_messages: nmh::message::Set, + unseen_messages: nmh::message::Set, + mark_sequences: Vec<&str>, +) -> Result, nmh::Error> { + // TODO make nmh-picked configurable. if user already configured Previous-Sequence, use that? + let mut result = vec![("nmh-picked".to_string(), picked_messages.to_string())]; + if !unseen_messages.is_empty() { + result.push(("unseen".to_string(), unseen_messages.to_string())); + } + for (name, value) in sequences { + if *name == "nmh-picked" || *name == "unseen" { + continue; + } + let mut marked = false; + for mark_seq in &mark_sequences { + if name == mark_seq { + let mut ranges = parse_msgs(value)?; + ranges = ranges.clone_without(&picked_messages); + if !ranges.is_empty() { + result.push((name.to_string(), ranges.to_string())); + } + marked = true; + break; + } + } + if !marked { + result.push((name.to_string(), value.to_string())); + } + } + Ok(result) +} diff --git a/imap/folder-manager/src/manager/command/pick.rs b/imap/folder-manager/src/manager/command/pick.rs new file mode 100644 index 00000000..283761c8 --- /dev/null +++ b/imap/folder-manager/src/manager/command/pick.rs @@ -0,0 +1,130 @@ +//! [pick] implements the message picking. + +use managed_folders as nmf; +use nmf::command; +use nmh::{folder, message}; + +use crate::imap::mailbox; +use crate::manager::pick::{ + check_for_all_or_unseen, pick_messages, write_sequences, LocalPicker, RemotePicker, +}; +use crate::manager::{Error, Manager, ManagerParts, SelectedParts, State}; +use crate::{recover_authenticated, recover_selected}; + +// TODO Read Unseen-Sequence. + +pub fn pick(manager: Manager, mut command: command::Pick) -> Result { + let need_unseen = check_for_all_or_unseen(&mut command.msgs); + if need_unseen || command.need_unseen { + pick_with_unseen(manager, command.folder, command.msgs) + } else { + Ok(pick_without_unseen(manager, command.folder, command.msgs)?) + } +} + +fn pick_without_unseen( + manager: Manager, + folder_name: folder::Name, + specs: Vec<&str>, +) -> Result { + let picker = LocalPicker::new(&manager.fpm, &folder_name)?; + let sequences: nmf::Components = picker.parse_sequences()?; + + let mut picked_messages = message::Set::new(); + pick_messages( + specs, + &sequences, + message::Set::new(), + picker.txn()?.cursor()?, + |(msgnum, _)| { + picked_messages.insert(i32::from(msgnum)); + }, + )?; + + write_sequences( + picker.seq_path(), + update_sequences(sequences.iter(), picked_messages, message::Set::new()), + )?; + + Ok(manager) +} + +// TODO dedup with mark_with_unseen +fn pick_with_unseen( + mut manager: Manager, + folder_name: folder::Name, + specs: Vec<&str>, +) -> Result { + // TODO Support other sequences; map them to flags? Optionally? + // 0. SEARCH UNSEEN if need_unseen + let (state, mparts) = manager.destruct(); + let session = match state { + State::Authenticated(session) => session, + State::Selected(_, selected) => match selected.mailbox_session.close() { + Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))), + Ok(session) => { + log::trace!("rmm CLOSEd into Authenticated state for SEARCH (TODO check first)"); + session + } + }, + }; + + let mailbox_name: mailbox::Name = mparts.fmm.folder_name_to_mailbox(&folder_name)?; + let (selected, mparts): (mailbox::Selected, ManagerParts) = + recover_authenticated!(mparts, session.select(&mailbox_name)); + + let picker = RemotePicker::new(&mparts.fpm, selected.uid_validity, &folder_name)?; + + // SEARCH UNSEEN messages into unseen_uids. + let (mailbox_session, selected_parts) = SelectedParts::destruct(selected); + let (unseen_uids, _, selected, mparts) = recover_selected!( + mparts, + mailbox_name, + selected_parts, + mailbox_session.search_unseen() + ); + + let mut picked_messages = message::Set::new(); + let sequences = picker.parse_sequences()?; + let picker_txn = picker.txn()?; + let unseen_messages: message::Set = picker_txn.to_message_numbers(unseen_uids)?; + pick_messages( + specs, + &sequences, + // TODO Rather than clone it, move our unseen_messages in and then take it back out later? + unseen_messages.clone(), + picker_txn.cursor()?, + |(msgnum, _)| { + picked_messages.insert(i32::from(msgnum)); + }, + )?; + + // CLOSE to persist the flag changes. + manager = mparts.authenticated(selected.mailbox_session.close().map_err(nmh::Error::wrap)?); + + write_sequences( + picker.seq_path(), + update_sequences(sequences.iter(), picked_messages, unseen_messages), + )?; + + Ok(manager) +} + +fn update_sequences<'a>( + sequences: std::slice::Iter<(&'a str, &'a str)>, + picked_messages: message::Set, + unseen_messages: message::Set, +) -> Vec<(String, String)> { + // TODO make nmh-picked configurable. if user already configured Previous-Sequence, use that? + let mut result = vec![("nmh-picked".to_string(), picked_messages.to_string())]; + if !unseen_messages.is_empty() { + result.push(("unseen".to_string(), unseen_messages.to_string())); + } + for (name, value) in sequences { + if *name == "nmh-picked" || *name == "unseen" { + continue; + } + result.push((name.to_string(), value.to_string())); + } + result +} diff --git a/imap/folder-manager/src/manager/mapper.rs b/imap/folder-manager/src/manager/mapper.rs new file mode 100644 index 00000000..4f9b61d4 --- /dev/null +++ b/imap/folder-manager/src/manager/mapper.rs @@ -0,0 +1,41 @@ +//! [FolderMailboxMapper] maps between folder paths and mailbox names. + +// TODO This assumes mailbox hierarchy separator is '/' which is not true. +// We have to check the LIST "" % response to find it. +// This is also just messy. What do we really need? +#[derive(Debug)] +pub struct FolderMailboxMapper { + name: String, + // TODO + pub mailbox_root: String, +} + +impl FolderMailboxMapper { + pub fn new(name: String, mailbox_root: String) -> Self { + Self { name, mailbox_root } + } + + pub fn folder_name_to_mailbox( + &self, + folder: &nmh::folder::Name, + ) -> Result { + let stripped = match folder.as_path().strip_prefix(&self.name) { + Ok(value) => value, + Err(e) => todo!("handle wrong folder {}", e), + }; + match stripped.to_str() { + Some(value) => Ok(crate::imap::mailbox::Name::from(format!( + "{}{}", + self.mailbox_root, value + ))), + None => todo!("mailbox name conversion"), + } + } + + pub fn mailbox_name_to_folder(&self, mailbox: &str) -> Result { + nmh::folder::Name::from_utf8( + std::path::Path::new(&self.name) + .join(mailbox.strip_prefix(&self.mailbox_root).unwrap_or(mailbox)), + ) + } +} diff --git a/imap/folder-manager/src/manager/pick.rs b/imap/folder-manager/src/manager/pick.rs new file mode 100644 index 00000000..dcd4c2a3 --- /dev/null +++ b/imap/folder-manager/src/manager/pick.rs @@ -0,0 +1,329 @@ +//! Local and remote message picking +//! +//! The names here could use some work but hopefully it all makes sense. Various commands take +//! "message specifications" which describe the set of messages on which to operate. See the +//! `mh-sequence(5)` man page for details. +//! +//! Most of these specifications can be resolved locally. Only one case requires contacting the +//! server: resolving the unseen sequence. +//! +//! To avoid hitting the server unnecessarily we have [LocalPicker] for local-only message +//! resolution, and [RemotePicker] for local and remote (maybe we should call it +//! `LocalAndRemotePicker` or something). +//! +//! To start with, use [check_for_all_or_unseen] to determine whether assistance from the server is +//! required. This function also helps special-case "all" and "first-last" specifications. If, +//! after passing a [Vec] to [check_for_all_or_unseen], element 0 is "all", the caller knows the +//! operation will be purely local. +//! +//! Next, use [LocalPicker] or [RemotePicker] with [pick_messages] to pick local messages. +//! With [RemotePicker], the caller also gets the [Uid]s. +//! +//! [parse_msgs] and [write_sequences] are used to update sequences files. + +use std::num::{NonZeroI32, NonZeroU32}; + +use managed_folders as nmf; +use nmh::{folder, message}; +use nmhimapstore::{ + FolderPathMapper, FolderStore, MessageCursor, MessageNumber2UidCursor, MessageNumberCursor, + MessageNumberTransaction, MessageNumberView, ReadOnlyTransaction, ReadTransaction, +}; +use nmhimaptypes::{uid, Uid, UidValidity}; + +use crate::manager::store; +use nmh::Error; + +// TODO Unseen-Sequence +/// Set `msgs[0]` to `"all"` (leaving all other elements untouched) if any element is "all" or +/// "first-last" and return true if the Unseen-Sequence is referenced. +pub fn check_for_all_or_unseen(msgs: &mut Vec<&str>) -> bool { + let mut need_unseen = false; + for i in 0..msgs.len() { + let spec = msgs[i]; + if spec == "all" || spec == "first-last" { + msgs[0] = "all"; + return false; + } + if spec == "unseen" { + need_unseen = true; + } + } + need_unseen +} + +pub struct LocalPicker { + msg_only: MessageNumberView, + seq_path: std::path::PathBuf, + seq_contents: String, +} + +impl LocalPicker { + pub fn new(fpm: &FolderPathMapper, folder_name: &folder::Name) -> Result { + let seq_path = fpm.seq_path(folder_name); + // TODO locking + let seq_contents: String = file_contents_or_empty(&seq_path)?; + Ok(Self { + msg_only: MessageNumberView::open(fpm, folder_name) + .map_err(|e| Error::describe(e, format_args!("{}", folder_name)))?, + seq_path, + seq_contents, + }) + } + + pub fn parse_sequences(&self) -> Result { + nmf::parse_components(&self.seq_contents) + .map_err(|e| Error::describe(e, format_args!("{}", self.seq_path.display()))) + } + + pub fn seq_path(&self) -> &std::path::Path { + &self.seq_path + } + + pub fn txn(&self) -> Result { + Ok(LocalPickerTransaction { + txn: self.msg_only.read_transaction()?, + }) + } +} + +pub struct LocalPickerTransaction<'a> { + txn: MessageNumberTransaction<'a>, +} + +impl<'a> LocalPickerTransaction<'a> { + pub fn cursor(&self) -> Result { + self.txn.cursor() + } +} + +pub struct RemotePicker { + store: FolderStore, + seq_path: std::path::PathBuf, + seq_contents: String, +} + +impl RemotePicker { + pub fn new( + fpm: &FolderPathMapper, + uid_validity: UidValidity, + folder_name: &folder::Name, + ) -> Result { + let seq_path = fpm.seq_path(folder_name); + // TODO locking + let seq_contents: String = file_contents_or_empty(&seq_path)?; + Ok(Self { + store: store::open(fpm, folder_name, uid_validity)?, + seq_path, + seq_contents, + }) + } + + pub fn parse_sequences(&self) -> Result { + nmf::parse_components(&self.seq_contents) + .map_err(|e| Error::describe(e, format_args!("{}", self.seq_path.display()))) + } + + pub fn seq_path(&self) -> &std::path::Path { + &self.seq_path + } + + pub fn txn(&self) -> Result { + Ok(RemotePickerTransaction { + txn: self.store.read_transaction()?, + }) + } +} + +pub struct RemotePickerTransaction<'a> { + txn: ReadOnlyTransaction<'a>, +} + +impl<'a> RemotePickerTransaction<'a> { + pub fn to_message_numbers(&self, uids: uid::Set) -> Result { + let mut msgnums = message::Set::new(); + for uid in uids.iter() { + // TODO is this faster or would it be faster to iterate + // lowest-highest unseen UID and pick what matches? + msgnums.insert( + self.txn + .lookup_message_number(Uid::from( + NonZeroU32::new(*uid) + .ok_or_else(|| Error::new(format!("Uid <= 0: {}", uid)))?, + ))? + .ok_or_else(|| { + // TODO Add test for this and for the fix, don't even do the db lookup. + // UID increases monotonically, so any UID not appearing in local db + // MUST be greater than the highest UID we've seen before. We can check + // that and skip the lookup. + todo!("UID {} in search results we haven't inc'd yet", uid) + }) + .unwrap() + .into(), + ); + } + Ok(msgnums) + } + + pub fn cursor(&self) -> Result { + self.txn.message_number_to_uid_cursor() + } +} + +pub fn parse_msgs(msgs: &str) -> Result { + let mut ranges = message::Set::new(); + for spec in msgs.split_ascii_whitespace() { + let pieces: Vec<&str> = spec.split('-').collect(); + if pieces.len() == 1 { + // no '-' + match pieces[0].parse() { + Err(_) => todo!( + "check for cur etc. else return error 'bad message list {}'", + spec + ), + Ok(msgnum) => { + ranges.insert(msgnum); + } + } + } else if pieces.len() == 2 { + // one '-' + let beg: i32 = match pieces[0].parse() { + Err(_) => todo!( + "check for cur etc. else return error 'bad message list {}'", + spec + ), + Ok(value) => value, + }; + let end: i32 = match pieces[1].parse() { + Err(_) => todo!( + "check for cur etc. else return error 'bad message list {}'", + spec + ), + Ok(value) => value, + }; + for msgnum in beg..end + 1 { + ranges.insert(msgnum); + } + } else { + todo!("you can't have two dashes in here") + } + } + Ok(ranges) +} + +/// Call `func` with picked messages. +/// +/// `specs` is a list of specifications according to `mh-sequenc(5)`. +/// Any references to sequences therein are resolved in `sequences`, or +/// `unseen_messages` for the unseen sequence. +/// +/// If `cursor` is a [MessageNumber2UidCursor], `func` also receives corresponding UIDs. +pub fn pick_messages, F: FnMut((message::Number, T))>( + specs: Vec<&str>, + sequences: &nmf::Components, + unseen_messages: message::Set, + cursor: C, + mut func: F, +) -> Result<(), Error> { + let ranges_to_pick: message::Set = specs_to_ranges(specs, sequences, unseen_messages)?; + if let Some((beg, end)) = ranges_to_pick.ends() { + let mut item: Option<(message::Number, T)> = cursor + .get(message::Number::from(NonZeroI32::new(beg).ok_or_else( + || Error::new(format!("message number <= 0: {}", beg)), + )?))?; + loop { + match item { + None => return Ok(()), + Some(pair) => { + let msgnum: i32 = pair.0.into(); + if msgnum > end { + break; + } + if ranges_to_pick.contains(msgnum) { + func(pair); + } + } + } + item = cursor.next()?; + } + } + Ok(()) +} + +pub fn write_sequences( + seq_path: &std::path::Path, + sequences: Vec<(String, String)>, +) -> Result<(), Error> { + write_sequences_unwrapped(seq_path, sequences) + .map_err(|e| Error::describe(e, format_args!("write sequences to {}", seq_path.display()))) +} + +fn write_sequences_unwrapped( + seq_path: &std::path::Path, + sequences: Vec<(String, String)>, +) -> std::io::Result<()> { + // TODO locking + let mut file = std::fs::File::create(seq_path)?; + use std::io::Write; + for (name, value) in sequences { + file.write_all(format!("{}: {}\n", name, value).as_bytes())? + } + file.sync_all() +} + +// TODO not a great home for this +fn file_contents_or_empty(path: &std::path::Path) -> Result { + match std::fs::read_to_string(&path) { + Err(e) => match e.kind() { + std::io::ErrorKind::NotFound => Ok("".to_owned()), + _ => Err(Error::describe(e, format_args!("read {}", path.display()))), + }, + Ok(contents) => Ok(contents), + } +} + +/// Return one [message::Set] from the user-provided vector of message specifications. +/// If any of the specifications reference sequences, look them up in `sequences`, except for the +/// Unseen-Sequence, for which `unseen_messages` is used. +fn specs_to_ranges( + specs: Vec<&str>, + sequences: &nmf::Components, + unseen_messages: message::Set, +) -> Result { + let mut unseen_messages = Some(unseen_messages); + let mut ranges_to_pick = message::Set::new(); + for spec in &specs { + if *spec == "all" { + ranges_to_pick = message::Set::all(); + break; + } + if spec.contains('=') || spec.contains(':') { + todo!("= and : not yet supported") + } + // may be '1', '1-2', or 'seq' + // TODO while sequences cannot occur in ranges, special words like 'first', 'cur', 'prev', 'next' can + let expanded_specs: &str = if spec.contains('-') { + spec + } else { + match spec.parse::() { + Err(_) => { + if *spec == "unseen" { + if let Some(mut taken) = unseen_messages.take() { + ranges_to_pick.append(&mut taken) + } + // TODO this is poorly structured + "" + } else { + sequences.get(spec).unwrap_or( + // TODO no such sequence; return "bad message list {spec}"? + "", + ) + } + } + Ok(_) => spec, + } + }; + ranges_to_pick.append(&mut parse_msgs(expanded_specs)?); + } + Ok(ranges_to_pick) +} diff --git a/imap/folder-manager/src/regex.rs b/imap/folder-manager/src/regex.rs new file mode 100644 index 00000000..e0d4471b --- /dev/null +++ b/imap/folder-manager/src/regex.rs @@ -0,0 +1,52 @@ +//! This is a bare-bones regex.h wrapper. It simply panics if anything goes +//! wrong. At some point we should polish this. + +use std::ffi::CStr; +use std::mem::MaybeUninit; +use std::os::raw::{c_char, c_int}; + +use libc::{regcomp, regex_t, regexec, regfree, regmatch_t, REG_NOMATCH, REG_NOSUB}; + +#[derive(Debug)] +pub struct Regex(MaybeUninit); + +impl Regex { + pub fn compile(pattern: &CStr) -> Self { + let mut this = Self(MaybeUninit::uninit()); + let preg: *mut regex_t = this.0.as_mut_ptr(); + let pat_cstr: *const c_char = pattern.as_ptr(); + let cflags: c_int = REG_NOSUB; + if unsafe { regcomp(preg, pat_cstr, cflags) } != 0 { + todo!("regcomp error") + } + this + } + + pub fn matches(&self, s: &str) -> bool { + let cstring = match std::ffi::CString::new(s) { + // This only returns error on embedded null byte and it's fair to call that not a match. + Err(_) => return false, + Ok(value) => value, + }; + let preg: *const regex_t = self.0.as_ptr(); + let string: *const c_char = cstring.as_c_str().as_ptr(); + let nmatch = 0; + let pmatch: *mut regmatch_t = std::ptr::null_mut(); + let eflags = 0; + match unsafe { regexec(preg, string, nmatch, pmatch, eflags) } { + 0 => true, + REG_NOMATCH => false, + _ => todo!("regexec error"), + } + } +} + +impl Drop for Regex { + fn drop(&mut self) { + let preg: *mut regex_t = self.0.as_mut_ptr(); + unsafe { regfree(preg) } + } +} + +#[cfg(test)] +mod tests; diff --git a/imap/folder-manager/src/regex/tests.rs b/imap/folder-manager/src/regex/tests.rs new file mode 100644 index 00000000..d8145db1 --- /dev/null +++ b/imap/folder-manager/src/regex/tests.rs @@ -0,0 +1,15 @@ +use std::ffi::CString; + +use super::*; + +#[test] +fn matched() { + let re = Regex::compile(CString::new(".").unwrap().as_c_str()); + assert!(re.matches(".")); +} + +#[test] +fn not() { + let re = Regex::compile(CString::new("nah").unwrap().as_c_str()); + assert!(!re.matches(".")); +} diff --git a/imap/folder-manager/src/run.rs b/imap/folder-manager/src/run.rs new file mode 100644 index 00000000..cfe339a5 --- /dev/null +++ b/imap/folder-manager/src/run.rs @@ -0,0 +1,127 @@ +use managed_folders as nmf; + +use crate::cf; +use crate::imap; +use crate::logging; +use crate::manager::Manager; + +pub fn configure_and_run(context: &nmh::Context, name: String) -> Result<(), String> { + let profile = context.profile(); + let prefix = format!("{}-", name); + let prefixed = profile.prefixed(&prefix); + let mut message_buffer = vec![0; 8192]; + match configure(&mut message_buffer, context, &profile, name, &prefixed)? { + None => Ok(()), + Some((listener, manager)) => run(listener, &mut message_buffer, manager), + } +} + +fn configure( + message_buffer: &mut [u8], + context: &nmh::Context, + profile: &nmh::Profile, + name: String, + prefixed: &nmh::profile::Prefixed, +) -> Result, String> { + // Install logging as soon as possible. + logging::install(prefixed)?; + + // Fully process configuration before attempting any work. + let init = cf::Init::new(prefixed)?; + let cf = cf::Manager::new(prefixed)?; + + std::fs::create_dir_all(&cf.db_path) + .map_err(|e| format!("{}: {}", cf.db_path.display(), e))?; + + // As soon as possible, we bind the listening socket and tell the calling nmh we are ready. + let listener = std::os::unix::net::UnixListener::bind(init.socket_path) + .map_err(|e| format!("{}: {}", init.socket_path.display(), e))?; + // Tell caller that we are ready. + if unsafe { + let lf = '\n'; + libc::write(1, &lf as *const char as *const libc::c_void, 1) + } < 0 + { + return Err(format!( + "write(1, \"\\n\", 1): {}", + std::io::Error::last_os_error() + )); + } + if unsafe { libc::close(1) } < 0 { + return Err(format!("close(1): {}", std::io::Error::last_os_error())); + } + + let (stream, _sockaddr) = listener.accept().map_err(log_and_convert)?; + + // Now that we're listening, we have a place to send errors, so setup the stderr log. + logging::redirect_stderr_to(cf.db_path.join("stderr"))?; + + let mail_path: &std::path::Path = profile.path()?; + match make_manager(context, name, cf, init, mail_path) { + Err(e) => { + nmf::close_with_error(stream, &e).map_err(log_and_convert)?; + Err(e) + } + Ok(manager) => match handle(message_buffer, stream, manager)? { + None => Ok(None), + Some(manager) => Ok(Some((listener, manager))), + }, + } +} + +fn make_manager( + context: &nmh::Context, + name: String, + cf: cf::Manager, + init: cf::Init, + mail_path: &std::path::Path, +) -> Result { + Ok(Manager::new( + crate::manager::FolderMailboxMapper::new(name, cf.mailbox_root.to_string()), + nmhimapstore::FolderPathMapper::new(mail_path.to_path_buf(), cf.db_path.to_path_buf()), + imap::auth::login(context, init.auth).map_err(log_and_convert)?, + cf, + )) +} + +fn run( + listener: std::os::unix::net::UnixListener, + message_buffer: &mut [u8], + mut manager: Manager, +) -> Result<(), String> { + loop { + let (stream, _sockaddr) = listener.accept().map_err(log_and_convert)?; + match handle(message_buffer, stream, manager)? { + None => return Ok(()), + Some(m) => manager = m, + } + // TODO actually i'm pretty sure that hang was my bug which i fixed + // TODO m_getfld2 hangs :( + // temporary hack is to reconnect for every command + } +} + +fn handle( + message_buffer: &mut [u8], + stream: std::os::unix::net::UnixStream, + manager: Manager, +) -> Result, String> { + let reader = nmf::FolderManagerProtocol::new(stream); + let (message, reader): (nmf::Message, nmf::FolderManagerProtocol) = reader + .read_message(message_buffer) + .map_err(|e| e.to_string())?; + let command = reader.read_command(&message).map_err(|e| e.to_string())?; + Ok(match command { + nmf::Command::Quit => None, + _ => { + let (new_manager, reader) = manager.run(command)?; + reader.close().map_err(log_and_convert)?; + Some(new_manager) + } + }) +} + +fn log_and_convert(e: T) -> String { + logging::error(&e); + e.to_string() +} diff --git a/imap/mhimap/Cargo.toml b/imap/mhimap/Cargo.toml new file mode 100644 index 00000000..eeea4692 --- /dev/null +++ b/imap/mhimap/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "mhimap" +# TODO derive from nmh version +version = "0.0.0" +edition = "2018" + +[dependencies] +nmh = { path = "../../sbr/rust" } +nmhimapstore = { path = "../store" } diff --git a/imap/mhimap/src/main.rs b/imap/mhimap/src/main.rs new file mode 100644 index 00000000..64dbe2f1 --- /dev/null +++ b/imap/mhimap/src/main.rs @@ -0,0 +1,92 @@ +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; + +fn main() -> std::io::Result<()> { + // TODO mh-style switches + let mut args = std::env::args(); + let (context, name): (nmh::Context, String) = parse_args(&mut args); + let profile: nmh::Profile = context.profile(); + let prefix = format!("{}-", name); + let prefixed: nmh::profile::Prefixed = profile.prefixed(&prefix); + + let command = args.next().unwrap(); + match command.as_str() { + "start" => { + let mut stream: UnixStream = connect(&name, &prefixed)?; + stream.write_all(b"len:13\nop:no\n")?; + stream.flush()?; + let mut response = String::new(); + stream.read_to_string(&mut response)?; + println!("{}", response); + } + "noop" | "shutdown" => { + // TODO separate shutdown; starting it up just to kill it is daft... + let mut stream: UnixStream = connect(&name, &prefixed)?; + if command == "shutdown" { + stream.write_all(b"len:15\nop:quit\n")?; + } else if command == "noop" { + stream.write_all(b"len:13\nop:no\n")?; + } + let mut response = String::new(); + stream.read_to_string(&mut response)?; + println!("{}", response); + } + "check" => { + check(PathBuf::from(args.next().unwrap()), &profile, &prefixed); + } + _ => todo!("only check, noop, shutdown"), + } + Ok(()) +} + +fn parse_args(args: &mut std::env::Args) -> (nmh::Context, String) { + ( + nmh::init(std::ffi::CString::new(args.next().unwrap()).unwrap()), + args.next().unwrap(), + ) +} + +fn connect(name: &str, prefixed: &nmh::profile::Prefixed) -> std::io::Result { + // 1. Try to connect to running manager. + let socket_path = Path::new(prefixed.get_or_err("socket").unwrap()); + match UnixStream::connect(socket_path) { + Err(e) => { + match e.kind() { + std::io::ErrorKind::NotFound => {} + // 2. If not ENOENT, unlink the stale socket name. + _ => std::fs::remove_file(&socket_path)?, + } + // 3. make pipe + // 4. fork + // TODO if $name-manager is a relative path, look in libexecdir + let run_path = Path::new(prefixed.get_or_err("manager").unwrap()); + // 5.child. folder-manager; prints to stdout if successfully listening + let command = std::process::Command::new(run_path) + .arg(name) + .stdout(std::process::Stdio::piped()) + .spawn()?; + let mut stdout: std::process::ChildStdout = command.stdout.unwrap(); + // 5.parent read from pipe to child + // 6.parent if error, die + let mut buf = [0_u8; 1]; + if stdout.read(&mut buf)? != 1 { + todo!("imap-folder-manager didn't report ready") + } + // 7.parent else, time to connect again! this time, die if error + UnixStream::connect(socket_path) + } + ok => ok, + } +} + +fn check(folder_path: PathBuf, profile: &nmh::Profile, prefixed: &nmh::profile::Prefixed) { + assert!(folder_path.is_relative()); + + let db_path = Path::new(prefixed.get("db").unwrap().trim()).to_path_buf(); + let fpm = nmhimapstore::FolderPathMapper::new( + Path::new(profile.get("Path").unwrap().trim()).to_path_buf(), + db_path, + ); + nmhimapstore::check(&nmh::folder::Name::from_utf8(folder_path).unwrap(), fpm).unwrap() +} diff --git a/imap/store/Cargo.toml b/imap/store/Cargo.toml new file mode 100644 index 00000000..136fb63a --- /dev/null +++ b/imap/store/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "nmhimapstore" +# TODO derive from nmh version +version = "0.0.0" +edition = "2018" + +[dependencies] +lmdb-rkv = "0.14" +lmdb-rkv-sys = "0.11" +log = "0.4" +# nmh +nmh = { path = "../../sbr/rust" } +nmhimaptypes = { path = "../types" } diff --git a/imap/store/src/append.rs b/imap/store/src/append.rs new file mode 100644 index 00000000..10304acb --- /dev/null +++ b/imap/store/src/append.rs @@ -0,0 +1,118 @@ +use std::io::Write as _; + +use nmh::{folder, message}; +use nmhimaptypes::Uid; + +use crate::{Error, FolderPathMapper, FolderStore, ReadTransaction, Result, WriteTransaction}; + +/// For appending messages into a folder. +/// Start with [FolderStore::append]. +pub struct FolderAppender<'a> { + pub highest_uid: Option, + pub uidnext: Option, + + next_msgnum: message::Number, + store: FolderStore, + + fpm: &'a FolderPathMapper, + folder_name: &'a folder::Name, +} + +impl<'a> FolderAppender<'a> { + /// Open a [MessageWriter] to begin writing a a new message in the folder. Until successful + /// [MessageWriter::close], the message is not stored. Any message file left behind after + /// failure to append a message will be overwritten by the next message append. + pub fn append(mut self, uid: Uid) -> Result> { + let msgnum = self.next_msgnum; + self.next_msgnum.increment(); + let path: std::path::PathBuf = self.fpm.msg_path(self.folder_name, msgnum); + MessageWriter { + msgnum, + uid, + appender: self, + path, + file: None, + } + .open() + } + + pub fn close(self) -> FolderStore { + self.store + } + + pub(super) fn new( + store: FolderStore, + fpm: &'a FolderPathMapper, + folder_name: &'a folder::Name, + ) -> Result { + let highest_uid: Option; + let mut next_msgnum: message::Number; + match store.read_transaction()?.highest()? { + Some((uid, msgnum)) => { + highest_uid = Some(uid); + next_msgnum = msgnum; + next_msgnum.increment(); + } + None => { + highest_uid = None; + next_msgnum = message::Number::one(); + } + } + let uidnext: Option = crate::read_nonzero_u32(&store.uidnext_path)?.map(Uid::from); + Ok(Self { + highest_uid, + fpm, + folder_name, + next_msgnum, + store, + uidnext, + }) + } +} + +/// For writing to new messages in a folder. +/// Start with [FolderAppender::append]. +pub struct MessageWriter<'a> { + appender: FolderAppender<'a>, + path: std::path::PathBuf, + file: Option, + msgnum: message::Number, + uid: Uid, +} + +impl<'a> MessageWriter<'a> { + /// Sink bytes into the message file. + pub fn write_all(&mut self, bytes: &[u8]) -> Result<()> { + self.file + .as_ref() + .expect("opened") + .write_all(bytes) + .map_err(|e| Error::describe(e, format_args!("{:?}", &self.path))) + } + + /// See [FolderAppender::append]. + pub fn close(self) -> Result<(message::Number, FolderAppender<'a>)> { + self.file + .as_ref() + .expect("opened") + .sync_all() + .map_err(|e| Error::describe(e, format_args!("{:?}", &self.path)))?; + let mut txn: WriteTransaction = self.appender.store.write_transaction()?; + txn.set_uid_msgnum(self.uid, self.msgnum)?; + txn.commit()?; + Ok((self.msgnum, self.appender)) + } + + pub fn abort(self) -> Result<()> { + // TODO attempt to unlink file, warn but ignore if fail; we'll clobber it later + Ok(()) + } + + fn open(mut self) -> Result { + self.file = Some( + std::fs::File::create(&self.path) + .map_err(|e| Error::describe(e, format_args!("{:?}", &self.path)))?, + ); + Ok(self) + } +} diff --git a/imap/store/src/check.rs b/imap/store/src/check.rs new file mode 100644 index 00000000..374640fe --- /dev/null +++ b/imap/store/src/check.rs @@ -0,0 +1,62 @@ +use nmh::{folder, message}; +use nmhimaptypes::Uid; + +use crate::{ReadOnlyTransaction, ReadTransaction}; + +/// Check folder integrity. +/// Spams stderr with report and panics on error. TODO: stop that +pub fn check(folder_name: &folder::Name, fpm: crate::FolderPathMapper) -> crate::Result<()> { + let mut errors = vec![]; + let store = crate::FolderStore::do_open(fpm.folder_path(folder_name), fpm.db_path(folder_name)) + .unwrap(); + let txn: ReadOnlyTransaction = store.read_transaction()?; + for result in txn.message_number_to_uid_cursor()? { + let (msgnum, uid): (message::Number, Uid) = result?; + // Lookup uid and ensure that results in msgnum. + match txn.lookup_message_number(uid)? { + None => errors.push(format!("msg {} missing from uid table", msgnum)), + Some(msgnum_from_uid) => { + if msgnum_from_uid != msgnum { + errors.push(format!( + "msg table has {}={}, uid has {}={}", + msgnum, uid, uid, msgnum_from_uid + )) + } + } + } + // Ensure msgnum file exists and is not empty. + match std::fs::metadata(fpm.msg_path(folder_name, msgnum)) { + Err(e) => match e.kind() { + std::io::ErrorKind::NotFound => { + errors.push(format!("message file {} missing", msgnum)) + } + _ => todo!("stat({:?}): {}", msgnum, e), + }, + Ok(st) => { + if st.len() == 0 { + errors.push(format!("message file {} empty", msgnum)) + } + } + } + } + for result in txn.uid_to_message_number_cursor()? { + let (uid, msgnum): (Uid, message::Number) = result?; + // Lookup msgnum and ensure that results in uid. + match txn.lookup_uid(msgnum)? { + None => errors.push(format!("uid {} missing from msg table", uid)), + Some(uid_from_msgnum) => { + if uid_from_msgnum != uid { + errors.push(format!( + "uid table has {}={}, msg has {}={}", + uid, msgnum, msgnum, uid_from_msgnum + )) + } + } + } + } + if errors.len() > 0 { + Err(crate::Error::new(errors.join("\n"))) + } else { + Ok(()) + } +} diff --git a/imap/store/src/lib.rs b/imap/store/src/lib.rs new file mode 100644 index 00000000..57959af6 --- /dev/null +++ b/imap/store/src/lib.rs @@ -0,0 +1,335 @@ +//! Storing IMAP mailboxes in nmh folders. +//! +//! Start with [MessageNumberView] for a read-only, message-number-only view of an existing folder. +//! Everything else is in [FolderStore], opening of which requires a server connection in order to +//! ensure UIDVALIDITY is not violated. + +// trait imports +use std::convert::TryInto as _; +use std::io::Write as _; + +use std::path::{Path, PathBuf}; + +use nmh::{folder, message, Error}; +use nmhimaptypes::{Uid, UidValidity}; + +mod append; +mod check; +mod mapper; +mod transaction; + +pub use append::{FolderAppender, MessageWriter}; +pub use check::check; +pub use mapper::FolderPathMapper; +pub use transaction::{ + MessageCursor, MessageNumber2UidCursor, MessageNumberCursor, MessageNumberTransaction, + ReadOnlyTransaction, ReadTransaction, WriteTransaction, +}; + +/// Offers the full capabilities of the folder store, including mapping between local message +/// numbers and server UIDs. Because that mapping is unstable, UIDVALIDITY is required to open +/// this. If the server's value differs from the local value, the folder cannot be opened. +/// In cases where only message numbers are needed and a server connection is undesired, +/// use [MessageNumberView] instead. +pub struct FolderStore { + lmdb_path: PathBuf, + folder_path: PathBuf, + environment: lmdb::Environment, + msg2uid: lmdb::Database, + uid2msg: lmdb::Database, + uidnext_path: PathBuf, +} + +impl FolderStore { + pub fn create( + fpm: &FolderPathMapper, + folder_name: &folder::Name, + uid_next: Uid, + uid_validity: UidValidity, + ) -> Result { + let folder_path: PathBuf = fpm.folder_path(folder_name); + let db_path: PathBuf = fpm.db_path(folder_name); + if db_path.exists() { + return Err(Error::new(format!("{:?} exists", db_path))); + } + Self::do_create(folder_path, db_path, uid_next.into(), uid_validity) + } + + pub fn open_or_create( + fpm: &FolderPathMapper, + folder_name: &folder::Name, + uidvalidity: UidValidity, + ) -> Result { + let db_path: PathBuf = fpm.db_path(folder_name); + if db_path.exists() { + Self::open(fpm, folder_name, uidvalidity) + } else { + let folder_path = fpm.folder_path(folder_name); + Self::do_create(folder_path, db_path, 0, uidvalidity) + } + } + + pub fn open( + fpm: &FolderPathMapper, + folder_name: &folder::Name, + uidvalidity: UidValidity, + ) -> Result { + let folder_path: PathBuf = fpm.folder_path(folder_name); + let db_path: PathBuf = fpm.db_path(folder_name); + if read_uidvalidity(&db_path)? != uidvalidity { + todo!("UIDVALIDITY changed"); + } + Self::do_open(folder_path, db_path) + } + + /// Returns [nmh::Error] when message number or UID are not in the database. + /// Caller is expected to have looked it up first. + pub fn delete_message(&self, msgnum: message::Number, uid: Uid) -> Result<()> { + let msg_bytes: [u8; 4] = msgnum_to_bytes(msgnum); + let uid_bytes: [u8; 4] = uid_to_bytes(uid); + let mut txn = self.write_transaction()?; + txn.txn + .del(self.msg2uid, &msg_bytes, None) + .map_err(|e| Error::describe(e, format_args!("Transaction::del")))?; + txn.txn + .del(self.uid2msg, &uid_bytes, None) + .map_err(|e| Error::describe(e, format_args!("Transaction::del")))?; + txn.commit()?; + + let path: PathBuf = self.folder_path.join(msgnum.to_string()); + if let Err(e) = std::fs::remove_file(&path) { + match e.kind() { + std::io::ErrorKind::NotFound => {} + _ => return Err(Error::describe(e, format_args!("unlink({:?})", path))), + } + } + Ok(()) + } + + /// Open a [FolderAppender] for appending messages to the folder. + pub fn append<'a>( + self, + fpm: &'a FolderPathMapper, + folder_name: &'a folder::Name, + ) -> Result> { + FolderAppender::new(self, fpm, folder_name) + } + + // TODO move self into ReadOnlyTransaction so opening two transactions fails at compilation rather than runtime + pub fn read_transaction(&self) -> Result { + Ok(ReadOnlyTransaction { + txn: self.environment.begin_ro_txn().map_err(|e| { + Error::describe( + e, + format_args!("begin read-only transaction in {:?}", self.lmdb_path), + ) + })?, + msg2uid: self.msg2uid, + uid2msg: self.uid2msg, + lmdb_path: &self.lmdb_path, + }) + } + + pub fn write_transaction(&self) -> Result { + Ok(WriteTransaction { + txn: self + .environment + .begin_rw_txn() + .map_err(|e| Error::describe(e, format_args!("{:?}", self.lmdb_path)))?, + msg2uid: self.msg2uid, + uid2msg: self.uid2msg, + lmdb_path: &self.lmdb_path, + }) + } + + pub fn write_uidnext(&self, uidnext: Uid) -> Result<()> { + write_file(&self.uidnext_path, &uidnext.to_string()) + } + + fn do_open>(folder_path: PathBuf, db_path: P) -> Result { + let lmdb_path = db_path.as_ref().join("db"); + let environment: lmdb::Environment = open_environment(&lmdb_path)?; + let msg2uid: lmdb::Database = open_db(&environment, "m2u")?; + let uid2msg: lmdb::Database = open_db(&environment, "u2m")?; + Ok(Self { + lmdb_path, + folder_path, + environment, + msg2uid, + uid2msg, + uidnext_path: db_path.as_ref().join("uidnext"), + }) + } + + fn do_create( + folder_path: PathBuf, + db_path: PathBuf, + uid_next: u32, + uid_validity: UidValidity, + ) -> Result { + let lmdb_path = db_path.join("db"); + std::fs::create_dir_all(&lmdb_path) + .map_err(|e| Error::describe(e, format_args!("{:?}", lmdb_path)))?; + std::fs::create_dir_all(&folder_path) + .map_err(|e| Error::describe(e, format_args!("{:?}", folder_path)))?; + + let uidnext_path = db_path.join("uidnext"); + write_file(&uidnext_path, &uid_next.to_string())?; + write_uidvalidity(&db_path, uid_validity)?; + + let environment: lmdb::Environment = open_environment(&lmdb_path)?; + let msg2uid: lmdb::Database = create_db(&environment, "m2u")?; + let uid2msg: lmdb::Database = create_db(&environment, "u2m")?; + + Ok(Self { + lmdb_path, + folder_path, + environment, + msg2uid, + uid2msg, + uidnext_path, + }) + } +} + +/// Delete a folder. +pub fn delete(fpm: &FolderPathMapper, folder_name: &folder::Name) -> Result<()> { + let folder_path: PathBuf = fpm.folder_path(folder_name); + let db_path: PathBuf = fpm.db_path(folder_name); + { + let store = FolderStore::do_open(folder_path.clone(), &db_path)?; + let txn = store.read_transaction()?; + for result in txn.message_number_to_uid_cursor()? { + let msgnum: message::Number = result?.0; + let m = store.folder_path.join(Path::new(&msgnum.to_string())); + if let Err(error) = std::fs::remove_file(&m) { + match error.kind() { + std::io::ErrorKind::NotFound => {} + _ => todo!("{:?}", error), + } + } + } + } + // TODO ignore ENOENT + let seq = fpm.seq_path(folder_name); + if let Err(error) = std::fs::remove_file(&seq) { + match error.kind() { + std::io::ErrorKind::NotFound => {} + _ => todo!("{:?}", error), + } + }; + // TODO report failure back to rmf which already has messaging for other files in the folder. + // and keep going on ENOENT + std::fs::remove_dir(folder_path).unwrap(); + // Nobody better be in the db path so be relentless. + std::fs::remove_dir_all(&db_path).unwrap(); + + Ok(()) +} + +// TODO Use FolderPathMapper to prevent opening FolderStore and then this? +/// Offers a subset of [FolderStore] operations allowing access only to local +/// message numbers. Useful when a server connection is undesired. +pub struct MessageNumberView(FolderStore); + +impl MessageNumberView { + pub fn open(fpm: &FolderPathMapper, folder_name: &folder::Name) -> Result { + let folder_path: PathBuf = fpm.folder_path(folder_name); + let db_path: PathBuf = fpm.db_path(folder_name); + Ok(Self(FolderStore::do_open(folder_path, db_path)?)) + } + + pub fn read_transaction(&self) -> Result { + Ok(MessageNumberTransaction { + txn: self.0.environment.begin_ro_txn().map_err(|e| { + Error::describe( + e, + format_args!("begin read-only transaction in {:?}", self.0.lmdb_path), + ) + })?, + msg2uid: self.0.msg2uid, + }) + } +} + +type Result = std::result::Result; + +fn write_uidvalidity(db_path: &Path, uidvalidity: UidValidity) -> Result<()> { + write_file(&db_path.join("uidvalidity"), &uidvalidity.to_string()) +} + +fn write_file(path: &Path, contents: &str) -> Result<()> { + let mut file = std::fs::File::create(&path) + .map_err(|e| Error::describe(e, format_args!("{}", path.display())))?; + file.write_all(contents.as_bytes()) + .map_err(|e| Error::describe(e, format_args!("{}", path.display())))?; + file.sync_all() + .map_err(|e| Error::describe(e, format_args!("{}", path.display()))) +} + +fn read_uidvalidity(db_path: &Path) -> Result { + read_nonzero_u32(&db_path.join("uidvalidity"))? + .ok_or_else(|| { + Error::new(format!( + "{} has illegal UIDVALIDITY zero", + db_path.display() + )) + }) + .map(UidValidity::from) +} + +fn read_nonzero_u32(path: &Path) -> Result> { + Ok(std::num::NonZeroU32::new( + std::fs::read_to_string(&path) + .map_err(|e| Error::describe(e, format_args!("{}", path.display())))? + .parse::() + .map_err(|e| Error::describe(e, format_args!("{}", path.display())))?, + )) +} + +fn open_environment(path: &Path) -> Result { + lmdb::Environment::new() + .set_max_dbs(2) + .set_map_size(1073741824) // TODO + .open_with_permissions(path, 0o600) + .map_err(|e| Error::describe(e, format_args!("{}", path.display()))) +} + +fn open_db(environment: &lmdb::Environment, name: &str) -> Result { + environment + .open_db(Some(name)) + .map_err(|e| Error::describe(e, format_args!("{}", name))) +} + +fn create_db(environment: &lmdb::Environment, name: &str) -> Result { + environment + .create_db(Some(name), lmdb::DatabaseFlags::INTEGER_KEY) + .map_err(|e| Error::describe(e, format_args!("{}", name))) +} + +fn msgnum_to_bytes(msgnum: message::Number) -> [u8; 4] { + i32::from(msgnum).to_ne_bytes() +} + +fn uid_to_bytes(uid: Uid) -> [u8; 4] { + u32::from(uid).to_ne_bytes() +} + +fn bytes_to_uid(uid_bytes: &[u8]) -> Uid { + std::num::NonZeroU32::new(u32::from_ne_bytes(uid_bytes.try_into().expect("4 bytes"))) + .expect("UID > 0") + .into() +} + +fn bytes_to_msgnum(msg_bytes: &[u8]) -> message::Number { + std::num::NonZeroI32::new(bytes_to_i32(msg_bytes)) + .expect("message number > 0") + .into() +} + +fn bytes_to_i32(msg_bytes: &[u8]) -> i32 { + i32::from_ne_bytes(msg_bytes.try_into().expect("4 bytes")) +} + +#[cfg(test)] +mod tests; diff --git a/imap/store/src/mapper.rs b/imap/store/src/mapper.rs new file mode 100644 index 00000000..9601ca4d --- /dev/null +++ b/imap/store/src/mapper.rs @@ -0,0 +1,36 @@ +use nmh::{folder, message}; + +#[derive(Debug)] +pub struct FolderPathMapper { + // E.g. ~/Mail + mail_path: std::path::PathBuf, + // E.g. ~/Mail/managed/imap + //folder_path: std::path::PathBuf, + // E.g. ~/.mhimapdata + db_path: std::path::PathBuf, +} + +impl FolderPathMapper { + pub fn new(mail_path: std::path::PathBuf, db_path: std::path::PathBuf) -> Self { + Self { mail_path, db_path } + } + + pub fn seq_path(&self, folder: &folder::Name) -> std::path::PathBuf { + // TODO .mh_sequences name is configurable + self.mail_path.join(folder.as_path()).join(".mh_sequences") + } + + pub fn msg_path(&self, folder: &folder::Name, msgnum: message::Number) -> std::path::PathBuf { + self.mail_path + .join(folder.as_path()) + .join(msgnum.to_string()) + } + + pub(super) fn folder_path(&self, folder: &folder::Name) -> std::path::PathBuf { + self.mail_path.join(folder.as_path()) + } + + pub(super) fn db_path(&self, folder: &folder::Name) -> std::path::PathBuf { + self.db_path.join(folder.as_path()) + } +} diff --git a/imap/store/src/tests.rs b/imap/store/src/tests.rs new file mode 100644 index 00000000..5eb3f34f --- /dev/null +++ b/imap/store/src/tests.rs @@ -0,0 +1,94 @@ +use nmh::message; +use nmhimaptypes::Uid; + +use super::*; + +#[test] +fn msgnum_to_bytes_1() { + assert_eq!(ONE_BYTES, msgnum_to_bytes(message::Number::one())); +} + +#[test] +fn uid_to_bytes_1() { + assert_eq!(ONE_BYTES, uid_to_bytes(uid())); +} + +mod bytes_to { + use super::*; + + mod uid { + use super::*; + + #[test] + fn one() { + assert_eq!(uid(), bytes_to_uid(&ONE_BYTES)); + } + + #[test] + #[should_panic(expected = "4 bytes: TryFromSliceError(())")] + fn zero_bytes() { + bytes_to_uid(&[]); + } + + #[test] + #[should_panic(expected = "4 bytes: TryFromSliceError(())")] + fn too_few_bytes() { + bytes_to_uid(&[1]); + } + + #[test] + #[should_panic(expected = "4 bytes: TryFromSliceError(())")] + fn too_many_bytes() { + bytes_to_uid(&[1, 0, 0, 0, 0]); + } + + #[test] + #[should_panic(expected = "UID > 0")] + fn zero() { + bytes_to_uid(&[0, 0, 0, 0]); + } + } + + mod msgnum { + use super::*; + + #[test] + fn one() { + assert_eq!(message::Number::one(), bytes_to_msgnum(&ONE_BYTES)); + } + + #[test] + #[should_panic(expected = "4 bytes: TryFromSliceError(())")] + fn zero_bytes() { + bytes_to_msgnum(&[]); + } + + #[test] + #[should_panic(expected = "4 bytes: TryFromSliceError(())")] + fn too_few_bytes() { + bytes_to_msgnum(&[1]); + } + + #[test] + #[should_panic(expected = "4 bytes: TryFromSliceError(())")] + fn too_many_bytes() { + bytes_to_msgnum(&[1, 0, 0, 0, 0]); + } + + #[test] + #[should_panic(expected = "message number > 0")] + fn zero() { + bytes_to_msgnum(&[0, 0, 0, 0]); + } + } +} + +#[cfg(target_endian = "little")] +const ONE_BYTES: [u8; 4] = [1, 0, 0, 0]; +// XXX UNTESTED +#[cfg(target_endian = "big")] +const ONE_BYTES: [u8; 4] = [0, 0, 0, 1]; + +fn uid() -> Uid { + Uid::from(std::num::NonZeroU32::new(1).unwrap()) +} diff --git a/imap/store/src/transaction.rs b/imap/store/src/transaction.rs new file mode 100644 index 00000000..898549de --- /dev/null +++ b/imap/store/src/transaction.rs @@ -0,0 +1,290 @@ +use std::num::NonZeroI32; + +// trait imports +use lmdb::Cursor as _; +use lmdb::Transaction as _; + +use nmh::message; +use nmhimaptypes::Uid; + +use crate::{ + bytes_to_i32, bytes_to_msgnum, bytes_to_uid, msgnum_to_bytes, uid_to_bytes, Error, Result, +}; + +mod cursor; +pub use cursor::{ + MessageCursor, MessageNumber2UidCursor, MessageNumberCursor, Uid2MessageNumberCursor, +}; + +pub struct MessageNumberTransaction<'a> { + pub(super) txn: lmdb::RoTransaction<'a>, + pub(super) msg2uid: lmdb::Database, +} + +impl<'a> MessageNumberTransaction<'a> { + pub fn cursor(&self) -> Result { + Ok(MessageNumberCursor::new( + self.txn + .open_ro_cursor(self.msg2uid) + .map_err(|e| Error::describe(e, format_args!("open cursor in msg2uid database")))?, + )) + } +} + +/// Read operations available to perform on read or write transactions. +pub trait ReadTransaction<'a, T: lmdb::Transaction> { + // TODO copy_message only wants highest msgnum; do we need a variant? + /// Return the highest [Uid] and [message::Number] in the folder. These probably do not correspond to each other. + fn highest(&self) -> Result>; + + fn lookup_message_number(&self, uid: Uid) -> Result>; + fn lookup_uid(&self, msgnum: message::Number) -> Result>; + fn message_number_to_uid_cursor(&self) -> Result; + fn uid_to_message_number_cursor(&self) -> Result; +} + +pub struct ReadOnlyTransaction<'a> { + pub(super) lmdb_path: &'a std::path::Path, + pub(super) msg2uid: lmdb::Database, + pub(super) uid2msg: lmdb::Database, + pub(super) txn: lmdb::RoTransaction<'a>, +} + +impl<'a> ReadTransaction<'a, lmdb::RoTransaction<'a>> for ReadOnlyTransaction<'a> { + fn highest(&self) -> Result> { + highest(&self.txn, self.msg2uid, self.uid2msg, self.lmdb_path) + } + + fn lookup_message_number(&self, uid: Uid) -> Result> { + lookup_message_number(&self.txn, self.uid2msg, self.lmdb_path, uid) + } + + fn lookup_uid(&self, msgnum: message::Number) -> Result> { + lookup_uid(&self.txn, self.msg2uid, self.lmdb_path, msgnum) + } + + fn message_number_to_uid_cursor(&self) -> Result { + message_number_to_uid_cursor(&self.txn, self.msg2uid) + } + + fn uid_to_message_number_cursor(&self) -> Result { + uid_to_message_number_cursor(&self.txn, self.uid2msg) + } +} + +pub struct WriteTransaction<'a> { + pub(super) lmdb_path: &'a std::path::Path, + pub(super) msg2uid: lmdb::Database, + pub(super) uid2msg: lmdb::Database, + pub(super) txn: lmdb::RwTransaction<'a>, +} + +impl<'a> WriteTransaction<'a> { + pub fn set_uid_msgnum(&mut self, uid: Uid, msgnum: message::Number) -> Result<()> { + let uid_bytes: [u8; 4] = uid_to_bytes(uid); + let msg_bytes: [u8; 4] = msgnum_to_bytes(msgnum); + self.txn + .put( + self.uid2msg, + &uid_bytes, + &msg_bytes, + lmdb::WriteFlags::NO_OVERWRITE, + ) + .map_err(|e| { + Error::describe( + e, + format_args!("set {}={} in uid2msg database", uid, msgnum), + ) + })?; + self.txn + .put( + self.msg2uid, + &msg_bytes, + &uid_bytes, + lmdb::WriteFlags::NO_OVERWRITE, + ) + .map_err(|e| Error::describe(e, format_args!("put in msg2uid database"))) + } + + pub fn store_message(&mut self, uid: Uid) -> Result { + let one_past_highest_msgnum_in_folder = message::Number::from( + NonZeroI32::new(1 + highest_msgnum_or_zero(&self.txn, self.msg2uid)?) + .expect("1 + anything to be non-zero"), + ); + + self.set_uid_msgnum(uid, one_past_highest_msgnum_in_folder)?; + + Ok(one_past_highest_msgnum_in_folder) + } + + /// Lookup old UID of `msgnum` and delete it, storing a new relation between `new_uid` and `msgnum`. + /// Return old UID. + pub fn replace_uid_msgnum(&mut self, new_uid: Uid, msgnum: message::Number) -> Result { + // Lookup old UID. + let msg_bytes: [u8; 4] = msgnum_to_bytes(msgnum); + let old_uid_bytes: Vec = self + .txn + .get(self.msg2uid, &msg_bytes) + .map_err(|e| Error::describe(e, format_args!("{:?}", self.lmdb_path)))? + .to_owned(); + let old_uid: Uid = bytes_to_uid(&old_uid_bytes); + + // Delete old UID / msgnum. + self.txn + .del(self.uid2msg, &old_uid_bytes, None) + .map_err(|e| Error::describe(e, format_args!("{:?}", self.lmdb_path)))?; + self.txn + .del(self.msg2uid, &msg_bytes, None) + .map_err(|e| Error::describe(e, format_args!("{:?}", self.lmdb_path)))?; + + // Put new UID / msgnum. + self.set_uid_msgnum(new_uid, msgnum)?; + + Ok(old_uid) + } + + pub fn commit(self) -> Result<()> { + let path = self.lmdb_path; + self.txn + .commit() + .map_err(|e| Error::describe(e, format_args!("commit {:?}", path)))?; + Ok(()) + } +} + +impl<'a> ReadTransaction<'a, lmdb::RwTransaction<'a>> for WriteTransaction<'a> { + fn highest(&self) -> Result> { + highest(&self.txn, self.msg2uid, self.uid2msg, self.lmdb_path) + } + + fn lookup_message_number(&self, uid: Uid) -> Result> { + lookup_message_number(&self.txn, self.uid2msg, self.lmdb_path, uid) + } + + fn lookup_uid(&self, msgnum: message::Number) -> Result> { + lookup_uid(&self.txn, self.msg2uid, self.lmdb_path, msgnum) + } + + fn message_number_to_uid_cursor(&self) -> Result { + message_number_to_uid_cursor(&self.txn, self.msg2uid) + } + + fn uid_to_message_number_cursor(&self) -> Result { + uid_to_message_number_cursor(&self.txn, self.uid2msg) + } +} + +fn highest_msgnum_or_zero(txn: &T, msg2uid: lmdb::Database) -> Result { + let msg2uid_cursor: lmdb::RoCursor = txn + .open_ro_cursor(msg2uid) + .map_err(|e| Error::describe(e, format_args!("open cursor in msg2uid database")))?; + Ok(match msg2uid_cursor.get(None, None, lmdb_sys::MDB_LAST) { + Err(error) => { + if error != lmdb::Error::NotFound { + todo!("cursor.get returned Err {}", error) + } + 0 + } + Ok((opt_key, _)) => match opt_key { + None => todo!("key not returned for MDB_LAST"), + Some(key) => bytes_to_i32(key), + }, + }) +} + +fn highest( + txn: &T, + msg2uid: lmdb::Database, + uid2msg: lmdb::Database, + db_path: &std::path::Path, +) -> Result> { + let msgnum = match highest_msgnum_or_zero(txn, msg2uid)? { + 0 => return Ok(None), + n => message::Number::from( + NonZeroI32::new(n).ok_or_else(|| Error::new("illegal message number zero"))?, + ), + }; + // TODO basically the same as highest_msgnum_or_zero; factor out + let uid: Uid = { + let uid2msg_cursor: lmdb::RoCursor = txn + .open_ro_cursor(uid2msg) + .map_err(|e| Error::describe(e, format_args!("{:?}", db_path)))?; + match uid2msg_cursor.get(None, None, lmdb_sys::MDB_LAST) { + Ok((opt_key, _value)) => match opt_key { + Some(key) => bytes_to_uid(key), + None => todo!("key not returned for MDB_LAST"), + }, + Err(error) => { + if error != lmdb::Error::NotFound { + todo!("cursor.get returned Err {}", error) + } + todo!("db corrupt: found msgnum but not uid") + } + } + }; + Ok(Some((uid, msgnum))) +} + +fn lookup_message_number( + txn: &T, + db: lmdb::Database, + db_path: &std::path::Path, + uid: Uid, +) -> Result> { + match txn.get(db, &uid_to_bytes(uid)) { + Err(e) => { + if e != lmdb::Error::NotFound { + Err(Error::describe( + e, + format_args!("{} in {}", uid, db_path.display()), + )) + } else { + Ok(None) + } + } + Ok(value) => Ok(Some(bytes_to_msgnum(value))), + } +} + +fn lookup_uid( + txn: &T, + db: lmdb::Database, + db_path: &std::path::Path, + msgnum: message::Number, +) -> Result> { + match txn.get(db, &msgnum_to_bytes(msgnum)) { + Err(e) => { + if e != lmdb::Error::NotFound { + Err(Error::describe( + e, + format_args!("{} in {}", msgnum, db_path.display()), + )) + } else { + Ok(None) + } + } + Ok(value) => Ok(Some(bytes_to_uid(value))), + } +} + +fn message_number_to_uid_cursor( + txn: &T, + db: lmdb::Database, +) -> Result { + // XXX lmdb-rkv bug: The iterator cannot outlive the txn but that is not expressed in the types. + Ok(MessageNumber2UidCursor::new( + txn.open_ro_cursor(db) + .map_err(|e| Error::describe(e, format_args!("Transaction::open_ro_cursor")))?, + )) +} + +fn uid_to_message_number_cursor( + txn: &T, + db: lmdb::Database, +) -> Result { + // XXX lmdb-rkv bug: The iterator cannot outlive the txn but that is not expressed in the types. + Ok(Uid2MessageNumberCursor::new( + txn.open_ro_cursor(db) + .map_err(|e| Error::describe(e, format_args!("Transaction::open_ro_cursor")))?, + )) +} diff --git a/imap/store/src/transaction/cursor.rs b/imap/store/src/transaction/cursor.rs new file mode 100644 index 00000000..b79b1753 --- /dev/null +++ b/imap/store/src/transaction/cursor.rs @@ -0,0 +1,131 @@ +use lmdb::Cursor; +use lmdb_sys::{MDB_NEXT, MDB_SET_RANGE}; + +use nmh::message; +use nmhimaptypes::Uid; + +use crate::{bytes_to_msgnum, bytes_to_uid, msgnum_to_bytes, Result}; + +/// Cursor over [message::Number] and something else (unit or [Uid]). Use to write generic +/// functions which may operate on [crate::FolderStore] or [crate::MessageNumberView]. +pub trait MessageCursor { + /// Return the lowest message number in the database greater than or equal to msgnum if any, or + /// None if no more remain. + /// Set the position for subsequent next calls. + fn get(&self, msgnum: message::Number) -> Result>; + + // TODO Is this really carrying its own weight? Why not just use our Iterator::next? + /// Return the next lowest message number greater than that returned by the previous next or get + /// call, or None if no more remain. + fn next(&self) -> Result>; +} + +/// This [MessageCursor] provides access to message numbers and their corresponding UIDs. This can +/// be used as an [Iterator], even after calling [MessageCursor::get]. +pub struct MessageNumber2UidCursor<'a>(lmdb::RoCursor<'a>); + +impl<'a> MessageNumber2UidCursor<'a> { + pub fn new(cursor: lmdb::RoCursor<'a>) -> Self { + Self(cursor) + } +} + +type Num2Uid = (message::Number, Uid); +impl<'a> MessageCursor for MessageNumber2UidCursor<'a> { + fn get(&self, msgnum: message::Number) -> Result> { + process_msg2uid_cursor( + self.0 + .get(Some(&msgnum_to_bytes(msgnum)), None, MDB_SET_RANGE), + ) + } + + fn next(&self) -> Result> { + process_msg2uid_cursor(self.0.get(None, None, MDB_NEXT)) + } +} + +impl Iterator for MessageNumber2UidCursor<'_> { + type Item = Result<(message::Number, Uid)>; + fn next(&mut self) -> Option { + to_iter(process_msg2uid_cursor(self.0.get(None, None, MDB_NEXT))) + } +} + +/// Provides access to UIDs and their corresponding message numbers. Its purpose is to be an [Iterator]. +pub struct Uid2MessageNumberCursor<'a>(lmdb::RoCursor<'a>); + +impl<'a> Uid2MessageNumberCursor<'a> { + pub fn new(cursor: lmdb::RoCursor<'a>) -> Self { + Self(cursor) + } +} + +impl Iterator for Uid2MessageNumberCursor<'_> { + type Item = Result<(Uid, message::Number)>; + fn next(&mut self) -> Option { + to_iter(process( + self.0.get(None, None, MDB_NEXT), + bytes_to_uid, + bytes_to_msgnum, + )) + } +} + +/// This [MessageCursor] only provides access to message numbers. This can be used as an +/// [Iterator], even after calling [MessageCursor::get]. +pub struct MessageNumberCursor<'a>(lmdb::RoCursor<'a>); + +impl<'a> MessageNumberCursor<'a> { + pub fn new(cursor: lmdb::RoCursor<'a>) -> Self { + Self(cursor) + } +} + +type OnlyNumber = (message::Number, ()); +impl<'a> MessageCursor<()> for MessageNumberCursor<'a> { + fn get(&self, msgnum: message::Number) -> Result> { + process_msgnum_cursor_get( + self.0 + .get(Some(&msgnum_to_bytes(msgnum)), None, MDB_SET_RANGE), + ) + } + + fn next(&self) -> Result> { + process_msgnum_cursor_get(self.0.get(None, None, MDB_NEXT)) + } +} + +fn process_msg2uid_cursor(result: lmdb::Result<(Option<&[u8]>, &[u8])>) -> Result> { + process(result, bytes_to_msgnum, bytes_to_uid) +} + +fn process_msgnum_cursor_get( + result: lmdb::Result<(Option<&[u8]>, &[u8])>, +) -> Result> { + process(result, bytes_to_msgnum, |_| ()) +} + +fn process K, VF: Fn(&[u8]) -> V>( + result: lmdb::Result<(Option<&[u8]>, &[u8])>, + convert_key: KF, + convert_value: VF, +) -> Result> { + Ok(match result { + Err(error) => { + if error != lmdb::Error::NotFound { + todo!("cursor.get returned Err {}", error) + } + None + } + Ok((Some(key), value)) => Some((convert_key(key), convert_value(value))), + unexpected => todo!("shouldn't happen {:?}", unexpected), + }) +} + +fn to_iter(result: Result>) -> Option> { + match result { + Err(e) => Some(Err(e)), + Ok(None) => None, + Ok(Some(v)) => Some(Ok(v)), + } +} diff --git a/imap/types/Cargo.toml b/imap/types/Cargo.toml new file mode 100644 index 00000000..6c65ab3c --- /dev/null +++ b/imap/types/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "nmhimaptypes" +# TODO derive from nmh version +version = "0.0.0" +edition = "2018" + +[dependencies] +# nmh +nmh = { path = "../../sbr/rust" } diff --git a/imap/types/src/lib.rs b/imap/types/src/lib.rs new file mode 100644 index 00000000..d4904c0c --- /dev/null +++ b/imap/types/src/lib.rs @@ -0,0 +1,37 @@ +pub mod uid; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Uid(std::num::NonZeroU32); + +impl std::fmt::Display for Uid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl From for Uid { + fn from(n: std::num::NonZeroU32) -> Self { + Self(n) + } +} + +impl From for u32 { + fn from(uid: Uid) -> Self { + uid.0.get() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UidValidity(std::num::NonZeroU32); + +impl std::fmt::Display for UidValidity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl From for UidValidity { + fn from(n: std::num::NonZeroU32) -> Self { + Self(n) + } +} diff --git a/imap/types/src/uid.rs b/imap/types/src/uid.rs new file mode 100644 index 00000000..1f8f1ce5 --- /dev/null +++ b/imap/types/src/uid.rs @@ -0,0 +1,25 @@ +#[derive(Clone, Debug)] +pub struct Set(std::collections::BTreeSet); + +// TODO dedup with nmh::message::Set? Better just to wrap sbr structures. +impl Set { + pub fn new() -> Self { + Self(std::collections::BTreeSet::new()) + } + + pub fn append(&mut self, mut other: Self) { + self.0.append(&mut other.0) + } + + pub fn insert(&mut self, msgnum: u32) -> bool { + self.0.insert(msgnum) + } + + pub fn contains(&self, msgnum: u32) -> bool { + self.0.contains(&msgnum) + } + + pub fn iter(&self) -> std::collections::btree_set::Iter { + self.0.iter() + } +} diff --git a/imap/types/tests/tests.rs b/imap/types/tests/tests.rs new file mode 100644 index 00000000..1ed58e69 --- /dev/null +++ b/imap/types/tests/tests.rs @@ -0,0 +1 @@ +mod uid; diff --git a/imap/types/tests/uid/mod.rs b/imap/types/tests/uid/mod.rs new file mode 100644 index 00000000..7eb9f323 --- /dev/null +++ b/imap/types/tests/uid/mod.rs @@ -0,0 +1,19 @@ +mod uid { + #[test] + fn option_no_overhead() { + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::>() + ); + } +} + +mod uid_validity { + #[test] + fn option_no_overhead() { + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::>() + ); + } +} diff --git a/managedfolders/Cargo.toml b/managedfolders/Cargo.toml new file mode 100644 index 00000000..2a50f728 --- /dev/null +++ b/managedfolders/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "managed-folders" +# TODO derive from nmh version +version = "0.0.0" +edition = "2018" + +[dependencies] +log = "0.4" +# nmh +nmh = { path = "../sbr/rust" } diff --git a/managedfolders/src/command.rs b/managedfolders/src/command.rs new file mode 100644 index 00000000..7150ddb3 --- /dev/null +++ b/managedfolders/src/command.rs @@ -0,0 +1,159 @@ +use nmh::{folder, message}; + +use crate::{Error, Message}; + +#[derive(Debug)] +pub struct UpdateDraft { + pub folder: folder::Name, + pub msgnum: message::Number, +} + +impl UpdateDraft { + pub fn new(message: &Message) -> Result { + Ok(Self { + folder: get_as_folder_name(message, "folder")?, + msgnum: message + .get("msg") + .ok_or_else(|| Error::new("msg component missing from request"))? + .parse()?, + }) + } +} + +#[derive(Debug)] +pub struct Folder { + pub folder: folder::Name, +} + +impl Folder { + pub fn new(message: &Message) -> Result { + Ok(Self { + folder: get_as_folder_name(message, "folder")?, + }) + } +} + +#[derive(Debug)] +pub struct Pick<'a> { + pub folder: folder::Name, + pub msgs: Vec<&'a str>, + pub need_unseen: bool, +} + +impl<'a> Pick<'a> { + pub fn new(message: &'a Message<'a>) -> Result { + Ok(Self { + folder: get_as_folder_name(message, "folder")?, + msgs: match message.get("msgs") { + None => todo!("no msgs component"), + Some(value) => non_empty_str_vec(value.trim()), + }, + need_unseen: match message.get("needunseen") { + None => todo!("no needunseen component"), + Some(value) => match value { + "0" => false, + "1" => true, + _ => todo!("needunseen must be 0 or 1"), + }, + }, + }) + } +} + +#[derive(Debug)] +pub struct RefileFile { + pub file_path: std::path::PathBuf, + pub dst_folder: folder::Name, +} + +impl RefileFile { + pub fn new(message: &Message) -> Result { + Ok(Self { + file_path: match message.get("file") { + None => todo!("no file component"), + Some(value) => std::path::Path::new(value).to_path_buf(), + }, + dst_folder: get_as_folder_name(message, "dest")?, + }) + } +} + +#[derive(Debug)] +pub struct Mark<'a> { + pub folder: folder::Name, + pub msgs: Vec<&'a str>, + pub sequences: Vec<&'a str>, +} + +impl<'a> Mark<'a> { + pub fn new(message: &'a Message<'a>) -> Result { + Ok(Self { + folder: get_as_folder_name(message, "folder")?, + msgs: match message.get("msgs") { + None => todo!("no msgs component"), + Some(value) => non_empty_str_vec(value.trim()), + }, + sequences: match message.get("seqs") { + None => todo!("no seqs component"), + Some(value) => non_empty_str_vec(value.trim()), + }, + }) + } +} + +#[derive(Debug)] +pub struct RefileMove<'a> { + pub src_folder: folder::Name, + pub msgs: Vec<&'a str>, + pub dst_folder: folder::Name, +} + +impl<'a> RefileMove<'a> { + pub fn new(message: &'a Message<'a>) -> Result { + Ok(Self { + src_folder: get_as_folder_name(message, "folder")?, + msgs: match message.get("msgs") { + None => todo!("no msgs component"), + Some(value) => non_empty_str_vec(value.trim()), + }, + dst_folder: get_as_folder_name(message, "dest")?, + }) + } +} + +#[derive(Debug)] +pub struct Rmm<'a> { + pub folder: folder::Name, + pub msgs: Vec<&'a str>, +} + +impl<'a> Rmm<'a> { + pub fn new(message: &'a Message<'a>) -> Result { + Ok(Self { + folder: get_as_folder_name(message, "folder")?, + msgs: match message.get("msgs") { + None => todo!("no msgs component"), + Some(value) => non_empty_str_vec(value.trim()), + }, + }) + } +} + +fn get_as_folder_name(message: &Message, name: &str) -> Result { + folder::Name::from_utf8( + std::path::Path::new( + message + .get(name) + .ok_or_else(|| Error::new(format!("{} component missing from request", name)))?, + ) + .to_path_buf(), + ) +} + +fn non_empty_str_vec(s: &str) -> Vec<&str> { + let result: Vec<&str> = nmh::hacks::split(s).collect(); + if result.is_empty() { + todo!("missing required strs") + } + result +} diff --git a/managedfolders/src/components.rs b/managedfolders/src/components.rs new file mode 100644 index 00000000..de482ad3 --- /dev/null +++ b/managedfolders/src/components.rs @@ -0,0 +1,36 @@ +// TODO this poor man's component parser is used two ways: +// 1. managed_folders::protocol::read::read_message +// instead use mh_folder_manager_protocol_read_message +// 2. imap-folder-manager reading .mh_sequences +// instead use seq_read (but need to stop it from readdir(3) first) +// Or just use m_getfld2 directly. +#[derive(Debug, PartialEq, Eq)] +pub struct Components<'a> { + // TODO this is super lazy because i currently have single-value fields and all multiple-value + // fields are ORDERED but this is currently bug-prone. + // We don't want BTreeMap; want to preserve insertion order. + map: std::collections::HashMap<&'a str, &'a str>, + vec: Vec<(&'a str, &'a str)>, +} + +impl<'a> Components<'a> { + pub fn new() -> Self { + Self { + map: std::collections::HashMap::new(), + vec: Vec::new(), + } + } + + pub fn push(&mut self, component: (&'a str, &'a str)) { + self.map.insert(component.0, component.1); + self.vec.push(component) + } + + pub fn get(&self, name: &str) -> Option<&str> { + self.map.get(name).copied() + } + + pub fn iter(&self) -> std::slice::Iter<(&str, &str)> { + self.vec.iter() + } +} diff --git a/managedfolders/src/lib.rs b/managedfolders/src/lib.rs new file mode 100644 index 00000000..b02e0736 --- /dev/null +++ b/managedfolders/src/lib.rs @@ -0,0 +1,34 @@ +use nmh::Error; + +pub mod command; + +mod components; +mod protocol; + +pub use components::Components; +pub use protocol::message::Message; +pub use protocol::{close_with_error, Command, EmptyResponse, FolderManagerProtocol, IncResponse}; + +// see TODO on Components +/// Primitive m_getfld2 replacement. Doesn't even support line folding or missing final newline. +pub fn parse_components(message: &str) -> Result { + let mut result = Components::new(); + let mut i = 0; + while i < message.len() { + let name_end = i + match message[i..].find(':') { + None => todo!("error missing colon in component name starting at {}", i), + Some(index) => index, + }; + let value_end = name_end + + 1 + + match &message[name_end + 1..].find('\n') { + None => todo!("error missing newline in {}", &message[i..]), + Some(index) => index, + }; + result.push((&message[i..name_end], &message[name_end + 1..value_end])); + i = value_end + 1; + } + Ok(result) +} + +type Result = std::result::Result; diff --git a/managedfolders/src/protocol.rs b/managedfolders/src/protocol.rs new file mode 100644 index 00000000..2f8e8cb3 --- /dev/null +++ b/managedfolders/src/protocol.rs @@ -0,0 +1,249 @@ +use std::io::Write; +use std::os::unix::net::UnixStream; + +use nmh::folder; + +use crate::{command, Message, Result}; + +pub mod message; +mod read; +mod write; + +#[derive(Debug)] +pub struct FolderManagerProtocol { + stream: UnixStream, +} + +impl FolderManagerProtocol { + pub fn new(stream: UnixStream) -> Self { + FolderManagerProtocol { stream } + } + + fn error(mut self, message: &str) -> Result { + self.write_all(&write::write(&Message::from( + &[(KEY_STATUS, STATUS_NOTOK), (KEY_ERROR, message)][..], + )))?; + Ok(self) + } + + fn write_all(&mut self, bytes: &[u8]) -> Result<()> { + self.stream.write_all(bytes).map_err(nmh::Error::wrap) + } +} + +impl FolderManagerProtocol { + pub fn read_message(mut self, message_buffer: &mut [u8]) -> Result<(Message, Self)> { + match read::read_message(message_buffer, &mut self.stream) { + Err(e) => { + self.stream + .shutdown(std::net::Shutdown::Both) + .map_err(crate::Error::wrap)?; + Err(e) + } + Ok(message) => Ok((message, self)), + } + } + + pub fn read_command<'a>(self, message: &'a Message) -> Result> { + match message.get("op") { + None => todo!("no 'op' component"), + Some(value) => match value { + "no" => Ok(Command::No(EmptyResponse::new(self))), + "quit" => { + self.close().unwrap(); + Ok(Command::Quit) + } + "folder" => Ok(Command::Folder( + EmptyResponse::new(self), + command::Folder::new(message)?, + )), + "comp" => Ok(Command::MakeDraft( + MessageNumberResponse::new(self), + command::Folder::new(message)?, + )), + "comp-update" => Ok(Command::UpdateDraft( + EmptyResponse::new(self), + command::UpdateDraft::new(message)?, + )), + "inc" => Ok(Command::Inc( + IncResponse::new(self), + command::Folder::new(message)?, + )), + "make-folder" => Ok(Command::MakeFolder( + EmptyResponse::new(self), + command::Folder::new(message)?, + )), + "mark-add" => Ok(Command::MarkAdd( + EmptyResponse::new(self), + command::Mark::new(message)?, + )), + "mark-del" => Ok(Command::MarkDel( + EmptyResponse::new(self), + command::Mark::new(message)?, + )), + "pick" => Ok(Command::Pick( + EmptyResponse::new(self), + command::Pick::new(message)?, + )), + "refile-file" => Ok(Command::RefileFile( + EmptyResponse::new(self), + command::RefileFile::new(message)?, + )), + "refile-move" => Ok(Command::RefileMove( + EmptyResponse::new(self), + command::RefileMove::new(message)?, + )), + "rmf" => Ok(Command::Rmf( + EmptyResponse::new(self), + command::Folder::new(message)?, + )), + "rmm" => Ok(Command::Rmm( + EmptyResponse::new(self), + command::Rmm::new(message)?, + )), + _ => todo!("unknown 'op' {}", value), + }, + } + } + + pub fn close(self) -> std::io::Result<()> { + self.stream.shutdown(std::net::Shutdown::Both) + } +} + +#[derive(Debug)] +pub struct EmptyResponse { + protocol: FolderManagerProtocol, +} + +impl EmptyResponse { + pub fn ok(mut self) -> Result { + self.protocol + .stream + .write_all(MESSAGE_OK.as_bytes()) + .unwrap(); + self.protocol.stream.flush().unwrap(); + Ok(self.protocol) + } + + pub fn error(self, message: &str) -> Result { + self.protocol.error(message) + } + + fn new(protocol: FolderManagerProtocol) -> Self { + Self { protocol } + } +} +#[derive(Debug)] +pub struct MessageNumberResponse { + protocol: FolderManagerProtocol, +} + +impl MessageNumberResponse { + pub fn ok(mut self, msgnum: nmh::message::Number) -> Result { + self.protocol + .stream + .write_all(&write::write(&Message::from( + &[(KEY_STATUS, STATUS_OK), (KEY_MSG, &msgnum.to_string())][..], + ))) + .unwrap(); + self.protocol.stream.flush().unwrap(); + Ok(self.protocol) + } + + pub fn error(self, message: &str) -> Result { + self.protocol.error(message) + } + + fn new(protocol: FolderManagerProtocol) -> Self { + Self { protocol } + } +} + +#[derive(Debug)] +pub struct IncResponse { + protocol: FolderManagerProtocol, + folder: folder::Name, +} + +impl IncResponse { + pub fn message( + &mut self, + folder: &folder::Name, + msgnum: nmh::message::Number, + unseen: bool, + ) -> Result<()> { + let mut m = Message::new(); + if *folder != self.folder { + self.folder = folder.clone(); + m.push((KEY_STATUS, STATUS_INC_FOLDER)); + m.push((KEY_FOLDER, folder.to_str())); + } + let msgnum_s = msgnum.to_string(); + m.push((KEY_MSG, &msgnum_s)); + m.push((KEY_UNSEEN, if unseen { "1" } else { "0" })); + self.protocol.stream.write_all(&write::write(&m)).unwrap(); + self.protocol.stream.flush().unwrap(); + Ok(()) + } + + pub fn ok(mut self) -> Result { + self.protocol + .stream + .write_all(MESSAGE_OK.as_bytes()) + .unwrap(); + Ok(self.protocol) + } + + pub fn error(self, message: &str) -> Result { + self.protocol.error(message) + } + + fn new(protocol: FolderManagerProtocol) -> Self { + Self { + protocol, + folder: folder::Name::from_utf8(std::path::PathBuf::new()).unwrap(), + } + } +} + +#[derive(Debug)] +pub enum Command<'a> { + Quit, + + Inc(IncResponse, command::Folder), + MakeDraft(MessageNumberResponse, command::Folder), + + // All the rest use EmptyResponse. + No(EmptyResponse), + Folder(EmptyResponse, command::Folder), + MakeFolder(EmptyResponse, command::Folder), + MarkAdd(EmptyResponse, command::Mark<'a>), + MarkDel(EmptyResponse, command::Mark<'a>), + Pick(EmptyResponse, command::Pick<'a>), + RefileFile(EmptyResponse, command::RefileFile), + RefileMove(EmptyResponse, command::RefileMove<'a>), + Rmf(EmptyResponse, command::Folder), + Rmm(EmptyResponse, command::Rmm<'a>), + UpdateDraft(EmptyResponse, command::UpdateDraft), +} + +pub fn close_with_error(mut stream: UnixStream, message: &str) -> std::io::Result<()> { + stream.write_all(&write::write(&Message::from( + &[(KEY_STATUS, STATUS_NOTOK), (KEY_ERROR, message)][..], + )))?; + stream.flush()?; + FolderManagerProtocol { stream }.close() +} + +const MESSAGE_OK: &str = "len:17\nstatus:OK\n"; + +const KEY_ERROR: &str = "error"; +const KEY_FOLDER: &str = "folder"; +const KEY_MSG: &str = "msg"; +const KEY_STATUS: &str = "status"; +const KEY_UNSEEN: &str = "unseen"; + +const STATUS_NOTOK: &str = "NOTOK"; +const STATUS_OK: &str = "OK"; +const STATUS_INC_FOLDER: &str = "INC_FOLDER"; diff --git a/managedfolders/src/protocol/message.rs b/managedfolders/src/protocol/message.rs new file mode 100644 index 00000000..de05b615 --- /dev/null +++ b/managedfolders/src/protocol/message.rs @@ -0,0 +1,44 @@ +use crate::Components; + +#[derive(Debug, PartialEq, Eq)] +pub struct Message<'a>(Components<'a>); + +impl<'a> Message<'a> { + pub fn new() -> Self { + Self(Components::new()) + } + + pub fn push(&mut self, component: (&'a str, &'a str)) { + self.0.push(component) + } + + pub fn get(&self, name: &str) -> Option<&str> { + self.0.get(name) + } + + pub fn iter(&self) -> std::slice::Iter<(&str, &str)> { + self.0.iter() + } +} + +impl Default for Message<'_> { + fn default() -> Self { + Self::new() + } +} + +impl<'a> From<&[(&'a str, &'a str)]> for Message<'a> { + fn from(components: &[(&'a str, &'a str)]) -> Self { + let mut m = Self::new(); + for c in components { + m.push(*c); + } + m + } +} + +impl<'a> From> for Message<'a> { + fn from(components: Components<'a>) -> Self { + Self(components) + } +} diff --git a/managedfolders/src/protocol/read.rs b/managedfolders/src/protocol/read.rs new file mode 100644 index 00000000..7ed7097e --- /dev/null +++ b/managedfolders/src/protocol/read.rs @@ -0,0 +1,71 @@ +use crate::{parse_components, Error, Message, Result}; + +pub fn read_message<'a, T: std::io::Read>( + message_buffer: &'a mut [u8], + stream: &mut T, +) -> Result> { + let (end_of_len, mut bytes_read) = read_line(message_buffer, stream)?; + let read_str = std::str::from_utf8(&message_buffer[..bytes_read])?; + if bytes_read < 6 { + return Err(Error::new(format!( + "protocol error: shortest possible message is 6 bytes not {}: {}", + bytes_read, read_str + ))); + } + if !read_str.starts_with("len:") { + return Err(Error::new(format!( + "protocol error: must start with len not {}", + read_str + ))); + } + let len: usize = read_str[4..end_of_len].parse().map_err(|e| { + Error::describe( + e, + format_args!( + "protocol error: parse len component of message: {}", + read_str + ), + ) + })?; + while bytes_read < len { + bytes_read += read(&mut message_buffer[bytes_read..len], stream)?; + } + Ok(Message::from(parse_components(std::str::from_utf8( + &message_buffer[end_of_len + 1..len], + )?)?)) +} + +// return (end_of_line, bytes_read) +fn read_line(buf: &mut [u8], stream: &mut T) -> Result<(usize, usize)> { + let max = std::cmp::min(15, buf.len()); + let mut bytes_read = 0; + // TODO timeout + loop { + let this_read = read(&mut buf[bytes_read..max], stream)?; + if this_read == 0 { + return Err(Error::new("protocol error: zero bytes read".to_owned())); + } + let previous_read = bytes_read; + bytes_read += this_read; + for i in previous_read..bytes_read { + if buf[i] == 10 { + return Ok((i, bytes_read)); + } + } + if bytes_read == max { + return Err(Error::new(format!( + "protocol error: can't find newline within {} bytes", + max + ))); + } + } +} + +fn read(buf: &mut [u8], stream: &mut T) -> Result { + stream + .read(buf) + .map_err(|e| Error::describe(e, format_args!("read"))) +} + +#[cfg(test)] +mod tests; diff --git a/managedfolders/src/protocol/read/tests.rs b/managedfolders/src/protocol/read/tests.rs new file mode 100644 index 00000000..e1b9d4a4 --- /dev/null +++ b/managedfolders/src/protocol/read/tests.rs @@ -0,0 +1,85 @@ +use super::*; + +mod read_message { + use super::*; + + mod read_buffer { + use super::*; + + #[test] + fn too_small() { + let mut buf = [0 as u8; 1]; + let s: Vec = b"len:11\na:b\n".to_vec(); + assert_eq!( + "protocol error: can't find newline within 1 bytes", + read_message(&mut buf, &mut std::io::Cursor::new(s)) + .unwrap_err() + .to_string() + ) + } + + #[test] + fn zero_sized() { + let mut buf = [0 as u8; 0]; + let s: Vec = b"len:11\na:b\n".to_vec(); + assert_eq!( + "protocol error: zero bytes read", + read_message(&mut buf, &mut std::io::Cursor::new(s)) + .unwrap_err() + .to_string() + ) + } + } + + mod message { + use super::*; + + #[test] + fn empty() { + let mut buf = [0 as u8; 64]; + let s: Vec = b"".to_vec(); + assert_eq!( + "protocol error: zero bytes read", + read_message(&mut buf, &mut std::io::Cursor::new(s)) + .unwrap_err() + .to_string() + ) + } + + #[test] + fn too_short() { + let mut buf = [0 as u8; 64]; + let s: Vec = b"bad\n".to_vec(); + assert_eq!( + "protocol error: shortest possible message is 6 bytes not 4: bad\n", + read_message(&mut buf, &mut std::io::Cursor::new(s)) + .unwrap_err() + .to_string() + ) + } + + #[test] + fn bad() { + let mut buf = [0 as u8; 64]; + let s: Vec = b"bad:things\n".to_vec(); + assert_eq!( + "protocol error: must start with len not bad:things\n", + read_message(&mut buf, &mut std::io::Cursor::new(s)) + .unwrap_err() + .to_string() + ) + } + + #[test] + fn bad_len() { + let mut buf = [0 as u8; 64]; + let s: Vec = b"len:what\n".to_vec(); + assert_eq!( + "protocol error: parse len component of message: len:what\n: invalid digit found in string", + read_message(&mut buf, &mut std::io::Cursor::new(s)) + .unwrap_err() + .to_string() + ) + } + } +} diff --git a/managedfolders/src/protocol/write.rs b/managedfolders/src/protocol/write.rs new file mode 100644 index 00000000..266e9f4e --- /dev/null +++ b/managedfolders/src/protocol/write.rs @@ -0,0 +1,29 @@ +use std::convert::TryInto; + +use crate::Message; + +// enough to hold u32::MAX - 4294967295 ought to be enough for anybody +const LEN_BASE: &str = "len: \n"; + +pub fn write(message: &Message) -> Vec { + let mut len = LEN_BASE.len(); + let mut s = LEN_BASE.to_owned(); + for (name, value) in message.iter() { + // + 2 for ':' and '\n' + len += name.len() + value.len() + 2; + s.push_str(name); + s.push_str(":"); + s.push_str(value); + s.push_str("\n"); + } + let len_u32: u32 = len.try_into().expect("4294967295 ought to be enough for anybody"); + // We already had a beautifully-sized buffer into which to write this but oh well. + // If profiling shows a problem, we'll look at it. + let len_string = len_u32.to_string(); + let mut bytes: Vec = s.into(); + bytes[4..4 + len_string.len()].clone_from_slice(len_string.as_bytes()); + bytes +} + +#[cfg(test)] +mod tests; diff --git a/managedfolders/src/protocol/write/tests.rs b/managedfolders/src/protocol/write/tests.rs new file mode 100644 index 00000000..1a53d13f --- /dev/null +++ b/managedfolders/src/protocol/write/tests.rs @@ -0,0 +1,50 @@ +use super::*; + +#[test] +fn zero_components() { + assert_eq!( + "len:15 \n", + std::str::from_utf8(&write(&Message::from(&[][..]))) + .unwrap() + .to_owned() + ); +} + +#[test] +fn one_component() { + assert_eq!( + "len:21 \nmsg:1\n", + std::str::from_utf8(&write(&Message::from(&[("msg", "1")][..]))) + .unwrap() + .to_owned() + ); +} + +#[test] +fn two_components() { + assert_eq!( + "len:48 \nstatus:NOTOK\nerror:error message\n", + std::str::from_utf8(&write(&Message::from( + &[("status", "NOTOK"), ("error", "error message")][..] + ))) + .unwrap() + .to_owned() + ); +} + +#[test] +fn four_components() { + assert_eq!( + "len:56 \nstatus:INC_FOLDER\nfolder:\nmsg:1\nunseen:1\n", + std::str::from_utf8(&write(&Message::from( + &[ + ("status", "INC_FOLDER"), + ("folder", ""), + ("msg", "1"), + ("unseen", "1"), + ][..] + ))) + .unwrap() + .to_owned() + ); +} diff --git a/managedfolders/tests/tests.rs b/managedfolders/tests/tests.rs new file mode 100644 index 00000000..7fc67f1e --- /dev/null +++ b/managedfolders/tests/tests.rs @@ -0,0 +1,12 @@ +use managed_folders::{parse_components, Message}; + +#[test] +fn simple_error() { + let mut m = Message::new(); + m.push(("status", "NOTOK")); + m.push(("error", "error message")); + assert_eq!( + &m, + &Message::from(parse_components("status:NOTOK\nerror:error message\n").unwrap()) + ); +} diff --git a/rls.in b/rls.in new file mode 100644 index 00000000..e39378a1 --- /dev/null +++ b/rls.in @@ -0,0 +1,2 @@ +#!/bin/sh +CARGO_TARGET_DIR='@CARGO_TARGET_DIR@' exec '@RLS@' "$@" diff --git a/sbr/arglist.c b/sbr/arglist.c index f771155a..b0471d9c 100644 --- a/sbr/arglist.c +++ b/sbr/arglist.c @@ -77,9 +77,8 @@ argsplit(char *command, char **file, int *argp) */ if (space && !metachar) { - char **split; p = mh_xstrdup(command); - split = brkstring(p, " \t", NULL); + char *const *const split = brkstring(p, " \t", NULL); if (split[0] == NULL) { die("Invalid blank command found"); } diff --git a/sbr/brkstring.c b/sbr/brkstring.c index 8f1c297f..bf262699 100644 --- a/sbr/brkstring.c +++ b/sbr/brkstring.c @@ -19,11 +19,11 @@ static int len = 0; /* current size of "broken" */ /* * static prototypes */ -static int brkany (char, char *); +static int brkany (char, char const *); -char ** -brkstring (char *str, char *brksep, char *brkterm) +char *const * +brkstring (char *const str, char const *const brksep, char const *const brkterm) { int i; char c, *s; @@ -78,7 +78,7 @@ brkstring (char *str, char *brksep, char *brkterm) */ static int -brkany (char c, char *str) +brkany (char const c, char const *const str) { return str && c && strchr(str, c); } diff --git a/sbr/brkstring.h b/sbr/brkstring.h index f727179e..55d47b23 100644 --- a/sbr/brkstring.h +++ b/sbr/brkstring.h @@ -5,4 +5,9 @@ * COPYRIGHT file in the root directory of the nmh distribution for * complete copyright information. */ -char **brkstring(char *, char *, char *) NONNULL(1); +/* Break up str on characters in brksep, terminated by brkterm. + * Each character in str that exists in brksep is replace with a null byte. + * Processing stops at the first character in str that exists in brkterm. + * Directly mutates str. + * Returns pointer to private, static array of pointers into str. */ +char *const *brkstring(char *str, char const *brksep, char const *brkterm) NONNULL(1); diff --git a/sbr/client.c b/sbr/client.c index 8d390a81..ee6987e4 100644 --- a/sbr/client.c +++ b/sbr/client.c @@ -6,8 +6,6 @@ */ #include "h/mh.h" -#include "client.h" -#include "h/mts.h" #include "h/utils.h" #include #include @@ -18,8 +16,8 @@ int -client (char *server, char *service, char *response, int len_response, - int debug) +client (char const *const server, char const *const service, char *const response, int const len_response, + int const debug) { int sd, rc; struct addrinfo hints, *res, *ai; diff --git a/sbr/client.h b/sbr/client.h index e339cc93..80a21a96 100644 --- a/sbr/client.h +++ b/sbr/client.h @@ -4,4 +4,4 @@ * COPYRIGHT file in the root directory of the nmh distribution for * complete copyright information. */ -int client(char *, char *, char *, int, int); +int client(char const *, char const *, char *, int, int); diff --git a/sbr/folder_manager_client.c b/sbr/folder_manager_client.c new file mode 100644 index 00000000..3422aae4 --- /dev/null +++ b/sbr/folder_manager_client.c @@ -0,0 +1,1296 @@ +/* folder_manager_client.c + * + * This code is Copyright (c) 2020, by the authors of nmh. See the + * COPYRIGHT file in the root directory of the nmh distribution for + * complete copyright information. + */ + +#include "h/mh.h" + +#include +#include +#include + +#include +#include +#include +#include + +#include "h/folder_manager_protocol.h" +#include "h/managed_folders.h" +#include "h/utils.h" +#include "sbr/error.h" +#include "sbr/getcpy.h" +#include "sbr/m_getfld.h" +#include "sbr/trimcpy.h" + +struct mh_folder_manager_client { + int skt; + FILE *readf, *writef; + svector_t response; + char *message_buf; + size_t message_buf_len; + mh_managed_folder *mmf; +}; + +struct mh_folder_manager_inc_context { + mh_managed_folder_inc_page page; + mh_folder_manager_client *mfmc; + mh_folder_manager_protocol_read_message_t rmt; +}; + +static char const OP_MAKE_DRAFT[] = "op:comp\n"; +static char const OP_FOLDER[] = "op:folder\n"; +static char const OP_MAKE_FOLDER[] = "op:make-folder\n"; +static char const OP_MARK_ADD[] = "op:mark-add\n"; +static char const OP_MARK_DEL[] = "op:mark-del\n"; +static char const OP_RMF[] = "op:rmf\n"; +static char const OP_RMM[] = "op:rmm\n"; +static char const OP_UPDATE_DRAFT[] = "op:comp-update\n"; +static char const OP_INC[] = "op:inc\n"; +static char const OP_PICK[] = "op:pick\n"; +static char const OP_REFILE_FILE[] = "op:refile-file\n"; +static char const OP_REFILE_MOVE[] = "op:refile-move\n"; +static char const KEY_FILE[] = "file:"; +static char const KEY_FOLDER[] = "folder:"; +static char const KEY_DEST[] = "dest:"; +static char const KEY_MSG[] = "msg:"; +static char const KEY_MSGS[] = "msgs:"; +static char const KEY_NEED_UNSEEN[] = "needunseen:"; +static char const KEY_SEQS[] = "seqs:"; + +static bool +read_message(mh_folder_manager_client *const mfmc); + +static bool +write_message(mh_folder_manager_client *const mfmc, char const *const name1, ...) ENDNULL; + +static char * +msgs_str(struct msgs_array const msgs); + +static char * +seqs_str(struct svector const *const sequences); + +bool +mh_folder_manager_connect( + mh_folder_manager_client **const result, + mh_managed_folder *const mmf) +{ + struct sockaddr_un sun = { 0 }; + mh_folder_manager_client* mfmc; + // TODO hang this off the mmfs so we can free everything in one bang? + NEW(mfmc); + mfmc->skt = -1; + mfmc->readf = mfmc->writef = NULL; + mfmc->response = NULL; + mfmc->message_buf = NULL; + mfmc->message_buf_len = 0; + mfmc->mmf = mmf; + *result = mfmc; + + char const *socket_path = mh_managed_folder_socket_path(mmf); + sun.sun_family = AF_UNIX; + // TODO ensure strlen(socket_path) < sizeof sun.sun_path at configuration load time + strncpy(sun.sun_path, socket_path, strlen(socket_path)); + mfmc->skt = socket(AF_UNIX, SOCK_STREAM, 0); + if (mfmc->skt == -1) { + // TODO return errors rather than abort + adios(socket_path, "socket"); + } + // 1. Try to connect to running manager. + if (connect(mfmc->skt, (struct sockaddr*)&sun, sizeof sun) == -1) { + if (errno != ENOENT) { + // 2. If not ENOENT, unlink the stale socket name. + if (unlink(socket_path) < 0) + adios(socket_path, "unable to unlink"); + } + // 3. make pipe + int fds[2]; + if (pipe(fds) < 0) + adios ("pipe", "unable to"); + // 4. fork + char const *path = mh_managed_folder_manager_path(mmf); + switch (fork()) { + case -1: + adios("fork", "unable to"); + case 0: + // 5.child. folder-manager; prints to stdout if successfully listening + if (dup2(fds[1], 1) < 0) + adios("pipe to child stdout", "dup2"); + if (execlp(path, "imap-folder-manager", mh_managed_folder_name(mmf), NULL) < 0) + adios(path, "unable to exec"); + break; + default: { + if (close(fds[1]) < 0) + adios("pipe", "close"); + // 5. parent read from pipe to child + char buf[1]; + int status = read(fds[0], buf, 1); + // 6.parent if error, die + if (status < 0) + adios("folder manager", "unable to read from"); + if (status < 1) + adios("folder manager", "read too small from"); + // 7.parent else, time to connect again! this time, die if error + if (connect(mfmc->skt, (struct sockaddr*)&sun, sizeof sun) == -1) + adios(socket_path, "unable to connect"); + break; } + } + } + + mfmc->readf = fdopen(mfmc->skt, "r"); + if (mfmc->readf == NULL) { + adios(socket_path, "fdopen for reading"); + } + + mfmc->writef = fdopen(mfmc->skt, "w"); + if (mfmc->writef == NULL) { + adios(socket_path, "fdopen for writing"); + } + + return true; +} + +bool +mh_folder_manager_close(mh_folder_manager_client *const mfmc) +{ + bool success = true; + free(mfmc->message_buf); + if (fclose(mfmc->readf) < 0) { + perror("TODO fclose"); + //set_err(mfmc->mmfs, TODO); + success = false; + } + free(mfmc); + return success; +} + +int +mh_folder_manager_create_draft( + mh_folder_manager_client *const mfmc, + char const *const draft_folder) +{ + if (!write_message( + mfmc, + OP_MAKE_DRAFT, + KEY_FOLDER, draft_folder, "\n", + NULL)) { + return 0; + } + if (!read_message(mfmc)) { + return 0; + } + int msgnum = 0; + for (size_t i = 0; i < svector_size(mfmc->response); i++) { + char const *const name = svector_read_at(mfmc->response, i++); + char *const value = svector_read_at(mfmc->response, i); + if (strcmp(name, "msg") == 0) { + errno = 0; + msgnum = strtol(value, NULL, 10); + if (errno != 0) { + fprintf(stderr, "TODO failed to convert msgnum from %s\n", value); + return 0; + } + if (msgnum == 0) { + fputs("TODO msgnum mustn't be 0\n", stderr); + return 0; + } + break; + } + } + if (msgnum == 0) { + // error - manager didn't return "msg" component + return 0; + } + return msgnum; +} + +bool +mh_folder_manager_update_draft( + mh_folder_manager_client *const mfmc, + char const *const draft_folder, + int const msgnum) +{ + char msgnum_buf[32]; + sprintf(msgnum_buf, "%d\n", msgnum); + if (!write_message( + mfmc, + OP_UPDATE_DRAFT, + KEY_FOLDER, draft_folder, "\n", + KEY_MSG, msgnum_buf, /* msgnum_buf has final newline */ + NULL)) { + return false; + } + + free_svector_and_elements(&mfmc->response); + mfmc->response = svector_create(16); + + mh_folder_manager_protocol_read_message_t rmt; + rmt.getfld_ctx = m_getfld_state_init(mfmc->readf); + if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) { + /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote + * advise message to stderr. */ + mh_managed_folder_set_err(mfmc->mmf, "see above"); + return false; + } + + for (size_t i = 0; i < svector_size(mfmc->response); i++) { + if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) { + if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) { + fprintf(stderr, "TODO handle error message\n"); + return false; + } + break; + } + } + return true; +} + +bool +mh_folder_manager_make_folder( + mh_folder_manager_client *const mfmc, + char const *const folder) +{ + if (!write_message( + mfmc, + OP_MAKE_FOLDER, + KEY_FOLDER, folder, "\n", + NULL)) { + return false; + } + if (!read_message(mfmc)) { + return false; + } + int status = NOTOK; + char const *error = NULL; + for (size_t i = 0; i < svector_size(mfmc->response); i++) { + char const *key = svector_read_at(mfmc->response, i++); + char const *val = svector_read_at(mfmc->response, i); + if (strcmp(key, "status") == 0) { + if (strcmp(val, "OK") == 0) { + status = OK; + } + } else if (strcmp(key, "error") == 0) { + error = val; + } + } + if (status == OK) { + if (error != NULL) { + // TODO don't spam stderr + fprintf(stderr, "folder manager said OK but also gave error message: %s\n", error); + mh_managed_folder_set_err(mfmc->mmf, "folder manager said OK but also gave error message"); + return false; + } + return true; + } + if (error == NULL) { + mh_managed_folder_set_err(mfmc->mmf, "unspecified error from folder manager"); + } else { + mh_managed_folder_set_err(mfmc->mmf, error); + } + return false; +} + +static mh_folder_manager_inc_context * +new_inc_page(mh_folder_manager_client *const mfmc) +{ + mh_folder_manager_inc_context *result; + NEW(result); + result->page.done = false; + result->page.folder = NULL; + result->mfmc = mfmc; + result->rmt.getfld_ctx = m_getfld_state_init(mfmc->readf); + return result; +} + +mh_folder_manager_inc_context * +mh_folder_manager_inc(mh_folder_manager_client *const mfmc, char const *const folder) +{ + if (!write_message( + mfmc, + OP_INC, + KEY_FOLDER, folder, "\n", + NULL)) { + return NULL; + } + return new_inc_page(mfmc); +} + +mh_managed_folder_inc_page * +mh_folder_manager_inc_next(mh_folder_manager_inc_context *const mfinc) +{ + if (mfinc->page.done) { + fprintf(stderr, "TODO can't call after done!\n"); + return NULL; + } + + free_svector_and_elements(&mfinc->mfmc->response); + mfinc->mfmc->response = svector_create(16); + + if (!mh_folder_manager_protocol_read_message(mfinc->mfmc->response, &mfinc->rmt)) { + /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote + * advise message to stderr. */ + mh_managed_folder_set_err(mfinc->mfmc->mmf, "see above"); + return NULL; + } + + int status = OK; + char const *error = NULL; + for (size_t i = 0; i < svector_size(mfinc->mfmc->response); i++) { + char const *const name = svector_read_at(mfinc->mfmc->response, i++); + char *const value = svector_read_at(mfinc->mfmc->response, i); + if (strcmp(name, "status") == 0) { + if (strcmp(value, "OK") == 0) { + mfinc->page.done = true; + m_getfld_state_destroy(&mfinc->rmt.getfld_ctx); + } else if (strcmp(value, "NOTOK") == 0) { + status = NOTOK; + } else if (strcmp(value, "INC_FOLDER") != 0) { + inform("folder manager status=%s", value); + mh_managed_folder_set_err(mfinc->mfmc->mmf, "illegal folder manager status"); + return NULL; + } + } else if (strcmp(name, "folder") == 0) { + free(mfinc->page.folder); + mfinc->page.folder = mh_xstrdup(value); + } else if (strcmp(name, "msg") == 0) { + errno = 0; + mfinc->page.msg = strtol(value, NULL, 10); + if (errno != 0) { + fprintf(stderr, "TODO failed to convert msgnum from %s\n", value); + return NULL; + } + } else if (strcmp(name, "unseen") == 0) { + mfinc->page.unseen = *value == '1'; + } else if (strcmp(name, "error") == 0) { + error = value; + } else if (strcmp(name, "len") != 0) { + fprintf(stderr, "TODO unknown response component %s\n", name); + return NULL; + } + } + + if (status == NOTOK) { + mh_managed_folder_set_err(mfinc->mfmc->mmf, + error == NULL ? "NOTOK status without error message" : error); + return NULL; + } else if (error != NULL) { + // TODO complain about error status:OK + } + + return &mfinc->page; +} + +bool +mh_folder_manager_folder_read(mh_folder_manager_client *const mfmc, char const *const folder) +{ + if (!write_message( + mfmc, + OP_FOLDER, + KEY_FOLDER, folder, "\n", + NULL)) { + return false; + } + free_svector_and_elements(&mfmc->response); + mfmc->response = svector_create(16); + mh_folder_manager_protocol_read_message_t rmt; + rmt.getfld_ctx = m_getfld_state_init(mfmc->readf); + if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) { + /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote + * advise message to stderr. */ + mh_managed_folder_set_err(mfmc->mmf, "see above"); + return false; + } + + for (size_t i = 0; i < svector_size(mfmc->response); i++) { + if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) { + if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) { + fprintf(stderr, "TODO handle error message\n"); + return false; + } + break; + } + } + return true; +} + +bool +mh_folder_manager_refile_move( + mh_folder_manager_client *const mfmc, + char const *const src_folder, + struct msgs_array const msgs, + char const *const dst_folder) +{ + char *msgs_buf = msgs_str(msgs); + if (!write_message( + mfmc, + OP_REFILE_MOVE, + KEY_FOLDER, src_folder, "\n", + KEY_MSGS, msgs_buf, /* msgs_buf has final newline */ + KEY_DEST, dst_folder, "\n", + NULL)) { + free(msgs_buf); + return false; + } + free(msgs_buf); + + free_svector_and_elements(&mfmc->response); + mfmc->response = svector_create(16); + mh_folder_manager_protocol_read_message_t rmt; + rmt.getfld_ctx = m_getfld_state_init(mfmc->readf); + if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) { + /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote + * advise message to stderr. */ + mh_managed_folder_set_err(mfmc->mmf, "see above"); + return false; + } + + for (size_t i = 0; i < svector_size(mfmc->response); i++) { + if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) { + if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) { + fprintf(stderr, "TODO handle error message\n"); + return false; + } + break; + } + } + return true; +} + +bool +mh_folder_manager_refile_file( + mh_folder_manager_client *const mfmc, + char const *const src_file, + char const *const dst_folder) +{ + if (!write_message( + mfmc, + OP_REFILE_FILE, + KEY_FILE, src_file, "\n", + KEY_DEST, dst_folder, "\n", + NULL)) { + return false; + } + + free_svector_and_elements(&mfmc->response); + mfmc->response = svector_create(16); + mh_folder_manager_protocol_read_message_t rmt; + rmt.getfld_ctx = m_getfld_state_init(mfmc->readf); + if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) { + /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote + * advise message to stderr. */ + mh_managed_folder_set_err(mfmc->mmf, "see above"); + return false; + } + + for (size_t i = 0; i < svector_size(mfmc->response); i++) { + if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) { + if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) { + fprintf(stderr, "TODO handle error message\n"); + return false; + } + break; + } + } + return true; +} + +bool +mh_folder_manager_pick( + mh_folder_manager_client *const mfmc, + char const *const folder, + struct msgs_array const msgs, + int const need_unseen) +{ + if (need_unseen < 0 || need_unseen > 1) { + die("mh_folder_manager_pick need_unseen must be 0 or 1"); + } + char *msgs_buf = msgs_str(msgs); + if (!write_message( + mfmc, + OP_PICK, + KEY_FOLDER, folder, "\n", + KEY_MSGS, msgs_buf, /* msgs_buf has final newline */ + KEY_NEED_UNSEEN, (need_unseen ? "1\n" : "0\n"), + NULL)) { + free(msgs_buf); + return false; + } + free(msgs_buf); + + free_svector_and_elements(&mfmc->response); + mfmc->response = svector_create(16); + mh_folder_manager_protocol_read_message_t rmt; + rmt.getfld_ctx = m_getfld_state_init(mfmc->readf); + if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) { + /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote + * advise message to stderr. */ + mh_managed_folder_set_err(mfmc->mmf, "see above"); + return false; + } + + for (size_t i = 0; i < svector_size(mfmc->response); i++) { + if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) { + if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) { + fprintf(stderr, "TODO handle error message\n"); + return false; + } + break; + } + } + return true; +} + +bool +mh_folder_manager_rmf(mh_folder_manager_client *const mfmc, char const *const folder) +{ + if (!write_message( + mfmc, + OP_RMF, + KEY_FOLDER, folder, "\n", + NULL)) { + return false; + } + + free_svector_and_elements(&mfmc->response); + mfmc->response = svector_create(16); + mh_folder_manager_protocol_read_message_t rmt; + rmt.getfld_ctx = m_getfld_state_init(mfmc->readf); + if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) { + /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote + * advise message to stderr. */ + mh_managed_folder_set_err(mfmc->mmf, "see above"); + return false; + } + + for (size_t i = 0; i < svector_size(mfmc->response); i++) { + if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) { + if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) { + fprintf(stderr, "TODO handle error message\n"); + return false; + } + break; + } + } + return true; +} + +bool +mh_folder_manager_rmm(mh_folder_manager_client *const mfmc, char const *const folder, struct msgs_array const msgs) +{ + char *msgs_buf = msgs_str(msgs); + if (!write_message( + mfmc, + OP_RMM, + KEY_FOLDER, folder, "\n", + KEY_MSGS, msgs_buf, /* msgs_buf has final newline */ + NULL)) { + free(msgs_buf); + return false; + } + free(msgs_buf); + + free_svector_and_elements(&mfmc->response); + mfmc->response = svector_create(16); + mh_folder_manager_protocol_read_message_t rmt; + rmt.getfld_ctx = m_getfld_state_init(mfmc->readf); + if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) { + /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote + * advise message to stderr. */ + mh_managed_folder_set_err(mfmc->mmf, "see above"); + return false; + } + + for (size_t i = 0; i < svector_size(mfmc->response); i++) { + if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) { + if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) { + fprintf(stderr, "TODO handle error message\n"); + return false; + } + break; + } + } + return true; +} + +bool +mh_folder_manager_mark_add( + mh_folder_manager_client *const mfmc, + char const *const folder, + struct msgs_array const msgs, + struct svector const *const sequences) +{ + char *msgs_buf = msgs_str(msgs); + char *seqs_buf = seqs_str(sequences); + if (!write_message( + mfmc, + OP_MARK_ADD, + KEY_FOLDER, folder, "\n", + KEY_MSGS, msgs_buf, /* msgs_buf has final newline */ + KEY_SEQS, seqs_buf, /* seqs_buf has final newline */ + NULL)) { + free(msgs_buf); + free(seqs_buf); + return false; + } + free(msgs_buf); + free(seqs_buf); + + free_svector_and_elements(&mfmc->response); + mfmc->response = svector_create(16); + mh_folder_manager_protocol_read_message_t rmt; + rmt.getfld_ctx = m_getfld_state_init(mfmc->readf); + if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) { + /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote + * advise message to stderr. */ + mh_managed_folder_set_err(mfmc->mmf, "see above"); + return false; + } + + for (size_t i = 0; i < svector_size(mfmc->response); i++) { + if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) { + if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) { + fprintf(stderr, "TODO handle error message\n"); + return false; + } + break; + } + } + return true; +} + +bool +mh_folder_manager_mark_del( + mh_folder_manager_client *const mfmc, + char const *const folder, + struct msgs_array const msgs, + struct svector const *const sequences) +{ + char *msgs_buf = msgs_str(msgs); + char *seqs_buf = seqs_str(sequences); + if (!write_message( + mfmc, + OP_MARK_DEL, + KEY_FOLDER, folder, "\n", + KEY_MSGS, msgs_buf, /* msgs_buf has final newline */ + KEY_SEQS, seqs_buf, /* seqs_buf has final newline */ + NULL)) { + free(msgs_buf); + free(seqs_buf); + return false; + } + free(msgs_buf); + free(seqs_buf); + + free_svector_and_elements(&mfmc->response); + mfmc->response = svector_create(16); + mh_folder_manager_protocol_read_message_t rmt; + rmt.getfld_ctx = m_getfld_state_init(mfmc->readf); + if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) { + /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote + * advise message to stderr. */ + mh_managed_folder_set_err(mfmc->mmf, "see above"); + return false; + } + + for (size_t i = 0; i < svector_size(mfmc->response); i++) { + if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) { + if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) { + fprintf(stderr, "TODO handle error message\n"); + return false; + } + break; + } + } + return true; +} + +// TODO why is this not all inside mh_folder_manager_protocol_read_message +static bool +read_message(mh_folder_manager_client *const mfmc) +{ + free_svector_and_elements(&mfmc->response); + mfmc->response = svector_create(16); + + mh_folder_manager_protocol_read_message_t rmt; + rmt.getfld_ctx = m_getfld_state_init(mfmc->readf); + const bool success = mh_folder_manager_protocol_read_message(mfmc->response, &rmt); + m_getfld_state_destroy(&rmt.getfld_ctx); + return success; +} + +static void +allocate_buffer(mh_folder_manager_client *const mfmc, size_t const len) +{ + if (mfmc->message_buf_len < len) { + if (mfmc->message_buf_len == 0) + mfmc->message_buf_len = 1024; + for (; mfmc->message_buf_len < len; mfmc->message_buf_len *= 2); + free(mfmc->message_buf); + mfmc->message_buf = mh_xmalloc(mfmc->message_buf_len); + } +} + +static bool +write_message(mh_folder_manager_client *const mfmc, char const *const name1, ...) +{ + // Iterate over parameters the first time adding up the buffer size. + va_list list; + size_t len = 0; + { + va_start(list, name1); + char const *cp = name1; + while (cp != NULL) { + // Recomputing lengths of all the names which we already knew. + // Not hard to fix that, but we're just going to replace this + // with calls into the Rust implementation anyway. + len += strlen(cp); + cp = va_arg(list, char const *); + } + va_end(list); + } + + allocate_buffer(mfmc, len); + + // Copy. (TODO good fit for writev(2) instead) + { + char *mp = mfmc->message_buf; + va_start(list, name1); + char const *cp = name1; + while (cp != NULL) { + mp = stpcpy(mp, cp); + cp = va_arg(list, char const *); + } + va_end(list); + } + return mh_folder_manager_protocol_write_message(mfmc->writef, mfmc->message_buf, len); +} + +static char * +msgs_str(struct msgs_array const msgs) +{ + // Start with the number of bytes we add: one space after each msg but the last, which gets LF. + size_t msgs_len = msgs.size; + for (int i = 0; i < msgs.size; i++) { + msgs_len += strlen(msgs.msgs[i]); + } + char *const result = mh_xmalloc(msgs_len); + char *mp = result; + for (int i = 0; i < msgs.size; i++) { + mp = stpcpy(mp, msgs.msgs[i]); + mp = stpcpy(mp, " "); + } + if (mp == result) { + stpcpy(result, "\n"); + } else { + *(mp - 1) = '\n'; + } + return result; +} + +static char * +seqs_str(struct svector const *const sequences) +{ + // Start with the number of bytes we add: one space after each seq but the last, which gets LF. + size_t seqs_len = svector_size(sequences); + for (size_t i = 0; i < svector_size(sequences); i++) { + seqs_len += strlen(svector_read_at(sequences, i)); + } + char *const result = mh_xmalloc(seqs_len); + char *mp = result; + for (size_t i = 0; i < svector_size(sequences); i++) { + mp = stpcpy(mp, svector_read_at(sequences, i)); + mp = stpcpy(mp, " "); + } + if (mp == result) { + stpcpy(result, "\n"); + } else { + *(mp - 1) = '\n'; + } + return result; +} + + + + + + + + + + + + +/* copied from seq_read.c + * TODO put them back together + */ + +#include "ssequal.h" +#include "trimcpy.h" +#include "m_atoi.h" +#include "brkstring.h" +#include "lock_file.h" + +/* + * static prototypes + */ +static int seq_init (struct msgs *, char *, char *); +static void seq_private (struct msgs *); + + + +/* + * read folder's sequences file for public sequences + */ + +typedef struct { + // outputs + bool done; + int failed_to_lock; + char name[NAMESZ], *value; + + // inputs + struct msgs *mp; + int lockflag; + + // internal state + char seqfile[PATH_MAX]; + FILE *fp; + m_getfld_state_t gstate; + char valuebuf[NMH_BUFSIZ]; +} seq_public_state_t; +static int +seq_public_open(seq_public_state_t *const sps) +{ + /* + * If mh_seq == NULL or if *mh_seq == 0 (the user has defined + * the "mh-sequences" profile entry, but left it empty), + * then just return, and do not initialize any public sequences. + */ + if (mh_seq == NULL || *mh_seq == 0) { + sps->done = true; + return OK; + } + sps->done = false; + + /* get filename of sequence file */ + snprintf(sps->seqfile, sizeof(sps->seqfile), "%s/%s", sps->mp->foldpath, mh_seq); + sps->fp = lkfopendata(sps->seqfile, sps->lockflag ? "r+" : "r", &sps->failed_to_lock); + if (sps->fp == NULL) { + sps->done = true; + return NOTOK; + } + + /* Use m_getfld2 to scan sequence file */ + sps->gstate = m_getfld_state_init(sps->fp); + return OK; +} +static void +seq_public_next(seq_public_state_t *const sps) +{ + if (sps->done) { + adios(sps->seqfile, "attempt to keep reading after sequence file is done"); + } + { + int state, fieldsz = sizeof sps->valuebuf; + switch (state = m_getfld2(&sps->gstate, sps->name, sps->valuebuf, &fieldsz)) { + case FLD: + case FLDPLUS: + if (state == FLDPLUS) { + char *cp = mh_xstrdup(sps->valuebuf); + while (state == FLDPLUS) { + fieldsz = sizeof sps->valuebuf; + state = m_getfld2(&sps->gstate, sps->name, sps->valuebuf, &fieldsz); + cp = add (sps->valuebuf, cp); + } + sps->value = trimcpy(cp); + free(cp); + } else { + sps->value = trimcpy(sps->valuebuf); + } + return; + + case BODY: + lkfclosedata (sps->fp, sps->seqfile); + die("no blank lines are permitted in %s", sps->seqfile); + break; + + case FILEEOF: + break; + + default: + lkfclosedata (sps->fp, sps->seqfile); + die("%s is poorly formatted", sps->seqfile); + } + } + sps->done = true; + m_getfld_state_destroy (&sps->gstate); + + if (sps->lockflag) { + sps->mp->seqhandle = sps->fp; + sps->mp->seqname = mh_xstrdup(sps->seqfile); + } else { + lkfclosedata (sps->fp, sps->seqfile); + } +} + + +/* + * Scan profile/context list for private sequences. + * + * We search the context list for all keys that look like + * "atr-seqname-folderpath", and add them as private sequences. + */ + +static void +seq_private (struct msgs *mp) +{ + int i, j, alen, plen; + char *cp; + struct node *np; + + alen = LEN("atr-"); + plen = strlen (mp->foldpath) + 1; + + for (np = m_defs; np; np = np->n_next) { + if (ssequal ("atr-", np->n_name) + && (j = strlen (np->n_name) - plen) > alen + && *(np->n_name + j) == '-' + && strcmp (mp->foldpath, np->n_name + j + 1) == 0) { + cp = mh_xstrdup(np->n_name + alen); + *(cp + j - alen) = 0; + if ((i = seq_init (mp, cp, getcpy (np->n_field))) != -1) + make_seq_private (mp, i); + } + } +} + + +/* + * Split up the different message ranges at whitespace + */ +typedef struct { + bool done; + int start; + int end; + char *const *piece; + char *field; +} parse_range_t; +static void +parse_range(parse_range_t *const state) +{ + if (state->done) { + die("attempt to keep reading after sequence range is done"); + } + if (state->piece == NULL) { + state->piece = brkstring(state->field, " ", "\n"); + state->done = *state->piece == NULL; + } else { + state->piece++; + } + if ((state->done = *state->piece == NULL)) { + return; + } + char *const dash_if_any = strchr(*state->piece, '-'); + if (dash_if_any != NULL) + *dash_if_any = 0; + state->start = m_atoi (*state->piece); + if (state->start > 0) { + state->end = dash_if_any == NULL ? state->start : m_atoi(dash_if_any+1); + } +} + +/* + * Add the name of sequence to the list of folder sequences. + * Then parse the list of message ranges for this + * sequence, and setup the various bit flags for each + * message in the sequence. + * + * Return internal index for the sequence if successful. + * Return -1 on error. + */ + +static int +seq_init (struct msgs *mp, char *name, char *field) +{ + unsigned int i; + int is_current; + + /* + * Check if this is "cur" sequence, + * so we can do some special things. + */ + is_current = !strcmp (current, name); + + /* + * Search for this sequence name to see if we've seen + * it already. If we've seen this sequence before, + * then clear the bit for this sequence from all the + * messages in this folder. + */ + for (i = 0; i < svector_size (mp->msgattrs); i++) { + if (!strcmp (svector_read_at (mp->msgattrs, i), name)) { + for (int j = mp->lowmsg; j <= mp->hghmsg; j++) + clear_sequence (mp, i, j); + break; + } + } + + /* + * If we've already seen this sequence name, just free the + * name string. Else add it to the list of sequence names. + */ + if (svector_read_at (mp->msgattrs, i)) { + free (name); + } else { + svector_push_back (mp->msgattrs, name); + } + + /* + * Split up the different message ranges at whitespace + */ + parse_range_t state; + state.done = false; + state.piece = NULL; + state.field = field; + for (parse_range(&state); !state.done; parse_range(&state)) { + if (state.start > 0) { + /* + * Keep mp->curmsg and "cur" sequence in synch. Unlike + * other sequences, this message doesn't need to exist. + * Think about the series of command (rmm; next) to + * understand why this can be the case. But if it does + * exist, we will still set the bit flag for it like + * other sequences. + */ + if (is_current) + mp->curmsg = state.start; + /* + * We iterate through messages in this range + * and flip on bit for this sequence. + */ + for (int j = state.start; j <= state.end; j++) { + if (j >= mp->lowmsg && j <= mp->hghmsg && does_exist(mp, j)) + add_sequence (mp, i, j); + } + } + } + + free (field); /* free string containing message ranges */ + return i; +} + + + + + + + + + + + + +/* copied from folder_read.c + * TODO put them back together + + * The original folder_read uses readdir to find messages, but managed folders + * have a different source of truth known only to the folder manager. + * We never ask the folder manager for ALL messages. Instead, we pass it the + * user's message specifications and expect it to write all matching message + * numbers into the nmh-picked sequence. + */ + +#include "m_maildir.h" + +/* We allocate the `mi' array 1024 elements at a time */ +#define NUMMSGS 1024 + +/* + * 1) Create the folder/message structure + * 2) Read the directory (folder) and temporarily + * record the numbers of the messages we have seen. + * 3) Then allocate the array for message attributes and + * set the initial flags for all messages we've seen. + * 4) Read and initialize the sequence information. + */ + +struct msgs * +mh_managed_folder_read (char *in_name, int lockflag) +{ + struct msgs *mp; + struct bvector *v; + size_t i; + char *const name = m_mailpath (in_name); + + /* Allocate the main structure for folder information */ + NEW(mp); + clear_folder_flags (mp); + mp->foldpath = name; + mp->lowmsg = 0; + mp->hghmsg = 0; + mp->curmsg = 0; + mp->lowsel = 0; + mp->hghsel = 0; + mp->numsel = 0; + mp->nummsg = 0; + mp->seqhandle = NULL; + mp->seqname = NULL; + + if (access (name, W_OK) == -1) + set_readonly (mp); + + mp->msgattrs = svector_create (0); + + // inlined fromseq_read + /* + * Initialize the list of sequence names. Go ahead and + * add the "cur" sequence to the list of sequences. + */ + svector_push_back (mp->msgattrs, getcpy (current)); + make_all_public (mp); /* initially, make all public */ + + seq_public_state_t sps; + sps.mp = mp; + sps.lockflag = lockflag; + if (seq_public_open(&sps) != OK) { + if (sps.failed_to_lock) { + advise (sps.seqfile, "failed to lock"); + // TODO leaks! + return NULL; + } + perror("TODO is errno even useful or did lkfopendata clobber it?"); + adios(sps.seqfile, "TODO pre-existing bug was to continue on all other failures"); + // TODO leaks! + return NULL; + } + + { + /* + * Allocate a temporary place to record the + * name of the messages in this folder. + */ + int len = NUMMSGS; + int *mi = mh_xmalloc ((size_t) (len * sizeof(*mi))); + + seq_public_next(&sps); + // TODO make nmh-picked configurable. if user already configured Previous-Sequence, use that? + if (!sps.done && strcmp(sps.name, "nmh-picked") == 0) { + parse_range_t state; + state.done = false; + state.piece = NULL; + state.field = sps.value; + for (parse_range(&state); !state.done; parse_range(&state)) { + if (state.start <= 0) { + adios(sps.seqfile, "folder manager put invalid message number %d into `nmh-picked` sequence", state.start); + } + if (state.end < state.start) { + adios(sps.seqfile, "folder manager put invalid message range %d-%d into `nmh-picked` sequence", state.start, state.end); + } + for (int msgnum = state.start; msgnum <= state.end; msgnum++) { + /* + * Check if we need to allocate more + * temporary elements for message names. + */ + if (mp->nummsg >= len) { + len += NUMMSGS; + mi = mh_xrealloc (mi, (size_t) (len * sizeof(*mi))); + } + + /* Check if this is the first message we've seen */ + if (mp->nummsg == 0) { + mp->lowmsg = msgnum; + mp->hghmsg = msgnum; + } else { + /* Check if this is it the highest or lowest we've seen? */ + if (msgnum < mp->lowmsg) + mp->lowmsg = msgnum; + if (msgnum > mp->hghmsg) + mp->hghmsg = msgnum; + } + + /* + * Now increment count, and record message + * number in a temporary place for now. + */ + mi[mp->nummsg++] = msgnum; + + /* TODO Original readdir-based version would set this when + * encountering a file type it didn't know about. But this is + * only used by `folder -r` to determine whether this folder + * has subfolders to process. + * Instead we'll have `folder -r` ask the folder manager to + * enumerate subfolders. */ + //set_other_files (mp); + } + } + + free(sps.value); + seq_public_next(&sps); + } + + mp->lowoff = max (mp->lowmsg, 1); + + /* Go ahead and allocate space for 100 additional messages. */ + mp->hghoff = mp->hghmsg + 100; + + /* for testing, allocate minimal necessary space */ + /* mp->hghoff = max (mp->hghmsg, 1); */ + + /* + * If for some reason hghoff < lowoff (like we got an integer overflow) + * the complain about this now. + */ + + if (mp->hghoff < mp->lowoff) { + die("Internal failure: high message limit < low message " + "limit; possible overflow?"); + } + + /* + * Allocate space for status of each message. + */ + mp->num_msgstats = MSGSTATNUM (mp->lowoff, mp->hghoff); + mp->msgstats = mh_xmalloc (MSGSTATSIZE(mp)); + for (i = 0, v = mp->msgstats; i < mp->num_msgstats; ++i, ++v) { + bvector_init(v); + } + + /* + * Scan through the array of messages we've seen and + * setup the initial flags for those messages in the + * newly allocated mp->msgstats area. + */ + for (int msgnum = 0; msgnum < mp->nummsg; msgnum++) { + set_exists (mp, mi[msgnum]); + } + + free (mi); /* We don't need this anymore */ + } + + /* + * Read and initialize the sequence information. + */ + // inlined seq_read +/* + * Get the sequence information for this folder from + * .mh_sequences (or equivalent specified in .mh_profile) + * or context file (for private sequences). + */ + + + /* If folder is empty, don't scan for sequence information */ + if (mp->nummsg == 0) { + return mp; + } + + /* Initialize the public sequences */ + for (; !sps.done; seq_public_next(&sps)) { + seq_init(mp, mh_xstrdup(sps.name), sps.value); + } + + /* Initialize the private sequences */ + seq_private (mp); + + return mp; +} diff --git a/sbr/folder_manager_protocol.c b/sbr/folder_manager_protocol.c new file mode 100644 index 00000000..b1b9ed02 --- /dev/null +++ b/sbr/folder_manager_protocol.c @@ -0,0 +1,164 @@ +#include "h/mh.h" + +#include +#include +#include +#include + +#include "h/folder_manager_protocol.h" +#include "h/utils.h" +#include "sbr/error.h" +#include "sbr/getcpy.h" +#include "sbr/trimcpy.h" +#include "sbr/m_getfld.h" + +// TODO replace with calling the Rust protocol implementation. + +void +free_svector_and_elements(svector_t *svector) +{ + size_t i; + if (*svector == NULL) return; + size_t const len = svector_size(*svector); + for (i = 0; i < len; i++) { + free(svector_at(*svector, i)); + } + svector_free(*svector); + *svector = NULL; +} + +bool +mh_folder_manager_protocol_read_message(svector_t message, mh_folder_manager_protocol_read_message_t *const rms) +{ + size_t len = 0, bytes_read = 0; + for (;;) { + int size = sizeof rms->value_buf; + int state = m_getfld2(&rms->getfld_ctx, rms->name, rms->value_buf, &size); + switch (state) { + case FLD: + case FLDPLUS: { + char *value, *trimmed; + if (state == FLD) { + value = trimcpy(rms->value_buf); + } else { + char *tmp = getcpy(rms->value_buf); + while (state == FLDPLUS) { + size = sizeof rms->value_buf; + state = m_getfld2(&rms->getfld_ctx, rms->name, rms->value_buf, &size); + tmp = add(rms->value_buf, tmp); + } + value = trimcpy(tmp); + free(tmp); + } + bytes_read += size; + trimmed = trimcpy(rms->name); + + if (strcmp(trimmed, "len") == 0) { + if (len > 0) { + /* m_getfld2 already spews to stderr so why not. */ + fprintf(stderr, "len component repeated\n"); + return false; + } + errno = 0; + len = strtol(value, NULL, 10); + if (errno != 0) { + /* m_getfld2 already spews to stderr so why not. */ + perror("len"); + return false; + } + if (len == 0) { + /* m_getfld2 already spews to stderr so why not. */ + fprintf(stderr, "len component repeated\n"); + return false; + } + } + + if (len == 0) { + /* m_getfld2 already spews to stderr so why not. */ + fprintf(stderr, "len must be first not %s:%s\n", trimmed, value); + return false; + } + + svector_push_back(message, trimmed); + svector_push_back(message, value); + + if (bytes_read > len) { + /* m_getfld2 already spews to stderr so why not. */ + fprintf(stderr, "we read %zu instead of %zu\n", bytes_read, len); + return false; + } + if (bytes_read == len) { + return true; + } + break; + } + + case BODY: + fprintf(stderr, "should never see body: %s\n", rms->value_buf); + return false; + + case FILEEOF: + if (len == 0) { + fprintf(stderr, "EOF before we saw anything? size=%d\n", size); + } else { + fprintf(stderr, "EOF with %zu bytes missing\n", len - bytes_read); + } + return false; + + default: + die("unexpected m_getfld2 state %d\n", state); + } + } + die("UNREACHABLE"); +} + +typedef struct { + char b[16]; + size_t len; +} len_buf_t; +static len_buf_t +compute_len(size_t const partial_len) +{ + len_buf_t len_buf; + *len_buf.b = 0; + size_t len_len = 1; + for (;;) { + sprintf(len_buf.b, "%zu", partial_len + len_len); + size_t const new_len_len = strlen(len_buf.b); + if (new_len_len == len_len) + break; + len_len = new_len_len; + } + len_buf.len = len_len; + return len_buf; +} + +bool +mh_folder_manager_protocol_write_message(FILE *const fp, char const *const message, size_t partial_len) +{ + len_buf_t const len_buf = compute_len(partial_len + 5); + + if (fputs("len:", fp) == EOF) { + perror("TODO fputs"); + return false; + } + if (fwrite(len_buf.b, 1, len_buf.len, fp) < len_buf.len) { + perror("TODO fwrite"); + return false; + } + if (fputs("\n", fp) == EOF) { + perror("TODO fputs"); + return false; + } + if (fwrite(message, 1, partial_len, fp) < partial_len) { + perror("TODO fwrite"); + return false; + } + + if (fflush(fp) == EOF) { + perror("TODO fflush"); + return false; + } + + return true; +} diff --git a/sbr/folder_operations.c b/sbr/folder_operations.c new file mode 100644 index 00000000..1c560e33 --- /dev/null +++ b/sbr/folder_operations.c @@ -0,0 +1,296 @@ +/* folder_operations.c -- mh_folder and associated operations. + * + * This code is Copyright (c) 2020, by the authors of nmh. See the + * COPYRIGHT file in the root directory of the nmh distribution for + * complete copyright information. + */ + +#include "h/mh.h" + +#include +#include +#include +#include + +#include "h/done.h" +#include "h/managed_folders.h" +#include "h/folder.h" +#include "h/utils.h" +#include "sbr/brkstring.h" +#include "sbr/context_find.h" +#include "sbr/context_replace.h" +#include "sbr/context_save.h" +#include "sbr/error.h" +#include "sbr/folder_free.h" +#include "sbr/folder_read.h" +#include "sbr/m_convert.h" +#include "sbr/m_maildir.h" +#include "sbr/m_name.h" +#include "sbr/seq_getnum.h" +#include "sbr/seq_save.h" +#include "sbr/seq_setcur.h" +#include "sbr/seq_setprev.h" +#include "sbr/seq_setunseen.h" + +struct mh_folder { + char *name, *path; + struct msgs *mp; + + mh_managed_folder *mmf; + + // for scan + int num_unseen_seq; + ivector_t seqnum; +}; + +static mh_folder * +new_mhf() +{ + mh_folder *mhf; + NEW(mhf); + mhf->name = mhf->path = NULL; + mhf->mp = NULL; + mhf->seqnum = ivector_create (0); + return mhf; +} + +bool +mh_folder_open(mh_folder **const result, char const *const name, mh_managed_folders *const mmfs) +{ + mh_folder *const mhf = *result = new_mhf(); + mhf->name = mh_xstrdup(name); + mhf->path = mh_xstrdup(m_maildir(mhf->name)); + + mhf->mmf = mh_managed_folder_lookup(mmfs, name, strlen(name)); + + return true; +} + +char const * +mh_folder_get_err_string(const mh_folder *const mhf) +{ + if (mhf->mmf != NULL) + return mh_managed_folder_get_err_string(mhf->mmf); + adios(mhf->path, "mh_folder_get_err_string not implemented because everything just dies"); + return NULL; +} + +void +mh_folder_close(mh_folder *const mhf) +{ + free(mhf->name); + free(mhf->path); + ivector_free(mhf->seqnum); + folder_free(mhf->mp); + + free(mhf); +} + +/* TODO Factor just a few common message selection routines out of all these + * after message selection has been factored out of all uip programs. */ + +static void +load_unseen_sequences_for_scan(mh_folder *const mhf) +{ + /* + * Get the sequence number for each sequence + * specified by Unseen-Sequence + */ + char const *const cp = context_find (usequence); + if (cp != NULL && *cp != 0) { + char *const dp = mh_xstrdup(cp); + int i = 0; + for (char *const *ap = brkstring (dp, " ", "\n"); ap && *ap; i++, ap++) { + ivector_push_back (mhf->seqnum, seq_getnum (mhf->mp, *ap)); + } + mhf->num_unseen_seq = i; + free(dp); + } +} + +struct msgs * +mh_managed_folder_read (char *, int); + +static struct msgs * +managed_select(mh_folder *const mhf, struct msgs_array const msgs, int const need_unseen) +{ + mh_folder_manager_client *mfmc; + if (!mh_folder_manager_connect(&mfmc, mhf->mmf)) + die("TODO: mh_folder_manager_connect"); + if (!mh_folder_manager_pick(mfmc, mhf->name, msgs, need_unseen)) + die("TODO mh_folder_manager_pick"); + if (!mh_folder_manager_close(mfmc)) + die("TODO mh_folder_manager_close"); + struct msgs *mp = mh_managed_folder_read(mhf->name, 1); + if (mp == NULL) + adios(mhf->name, "TODO mh_managed_folder_read"); + // See comments above mh_managed_folder_read for explanation. + if (!m_convert(mp, "all")) { + done(1); + } + return mp; +} + +static struct msgs * +managed_select_and_mark_seen(mh_folder *const mhf, struct msgs_array const msgs) +{ + mh_folder_manager_client *mfmc; + if (!mh_folder_manager_connect(&mfmc, mhf->mmf)) + die("TODO: mh_folder_manager_connect"); + svector_t sequences = svector_create (0); + // TODO Unseen-Sequence + svector_push_back(sequences, "unseen"); + bool ok = mh_folder_manager_mark_del(mfmc, mhf->name, msgs, sequences); + svector_free(sequences); + if (!ok) + die("TODO mh_folder_manager_mark_del"); + if (!mh_folder_manager_close(mfmc)) + die("TODO mh_folder_manager_close"); + struct msgs *mp = mh_managed_folder_read(mhf->name, 1); + if (mp == NULL) + adios(mhf->name, "TODO mh_managed_folder_read"); + // See comments above mh_managed_folder_read for explanation. + if (!m_convert(mp, "all")) { + done(1); + } + return mp; +} + +struct msgs const * +mh_folder_select_and_chdir_for_scan(mh_folder *const mhf, struct msgs_array const msgs) +{ + if (chdir (mhf->path) == NOTOK) + adios (mhf->path, "unable to change directory to"); + + if (mhf->mmf == NULL) { + /* read folder and create message structure */ + if (!(mhf->mp = folder_read (mhf->name, 1))) + die("unable to read folder %s", mhf->name); + + /* check for empty folder */ + if (mhf->mp->nummsg == 0) + die("no messages in %s", mhf->name); + + /* parse all the message ranges/sequences and set SELECTED */ + for (int i = 0; i < msgs.size; i++) { + if (!m_convert (mhf->mp, msgs.msgs[i])) + done(1); + } + } else { + mhf->mp = managed_select(mhf, msgs, /* need_unseen */ 1); + } + + /* set the Previous-Sequence */ + seq_setprev (mhf->mp); + + /* update current folder */ + context_replace (pfolder, mhf->name); + /* synchronize message sequences */ + seq_save (mhf->mp); + /* save the context file */ + context_save (); + + // TODO Should be part of Open or Read? + load_unseen_sequences_for_scan(mhf); + + return mhf->mp; +} + +struct msgs const * +mh_folder_select_and_chdir_and_mark_seen(struct msgs_array *const vec, mh_folder *const mhf, struct msgs_array const msgs) +{ + if (chdir (mhf->path) == NOTOK) + adios (mhf->path, "unable to change directory to"); + + if (mhf->mmf == NULL) { + /* read folder and create message structure */ + if (!(mhf->mp = folder_read (mhf->name, 1))) + die("unable to read folder %s", mhf->name); + + /* check for empty folder */ + if (mhf->mp->nummsg == 0) + die("no messages in %s", mhf->name); + + /* parse all the message ranges/sequences and set SELECTED */ + for (int i = 0; i < msgs.size; i++) { + if (!m_convert (mhf->mp, msgs.msgs[i])) + done(1); + } + + /* + * Set the SELECT_UNSEEN bit for all the SELECTED messages, + * since we will use that as a tag to know which messages + * to remove from the "unseen" sequence. + */ + for (int msgnum = mhf->mp->lowsel; msgnum <= mhf->mp->hghsel; msgnum++) { + if (is_selected(mhf->mp, msgnum)) + set_unseen (mhf->mp, msgnum); + } + + seq_setprev (mhf->mp); /* set the Previous-Sequence */ + seq_setunseen (mhf->mp, 1); /* unset the Unseen-Sequence */ + + // TODO Can we combine this with the previous loop? Tests passed... + for (int msgnum = mhf->mp->lowsel; msgnum <= mhf->mp->hghsel; msgnum++) + if (is_selected(mhf->mp, msgnum)) + app_msgarg(vec, mh_xstrdup(m_name (msgnum))); + + seq_setcur (mhf->mp, mhf->mp->hghsel); /* update current message */ + seq_save (mhf->mp); /* synchronize sequences */ + } else { + mhf->mp = managed_select_and_mark_seen(mhf, msgs); + for (int msgnum = mhf->mp->lowsel; msgnum <= mhf->mp->hghsel; msgnum++) + if (is_selected(mhf->mp, msgnum)) + app_msgarg(vec, mh_xstrdup(m_name (msgnum))); + seq_setcur (mhf->mp, mhf->mp->hghsel); /* update current message */ + seq_save (mhf->mp); /* synchronize sequences */ + } + + return mhf->mp; +} + +bool +mh_folder_message_is_unseen_for_scan(mh_folder *const mhf, int const msgnum) +{ + for (int i = 0; i < mhf->num_unseen_seq; i++) { + if (in_sequence(mhf->mp, ivector_at (mhf->seqnum, i), msgnum)) { + return true; + } + } + return false; +} + +/* Mostly identical to scan version. Why can't this use msgs_array? */ +struct msgs const * +mh_folder_select_and_chdir_for_send(mh_folder *const mhf, char *const *const msgs, size_t const msg_count) +{ + if (chdir (mhf->path) == NOTOK) + adios (mhf->path, "unable to change directory to"); + + if (mhf->mmf != NULL) { + // TODO Leaks msgs_array.msgs. Have send own the msgs_array. + struct msgs_array msgsa = { 0, 0, NULL }; + for (size_t i = 0; i < msg_count; i++) { + app_msgarg(&msgsa, msgs[i]); + } + return managed_select(mhf, msgsa, /* need_unseen */ 0); + } + + /* read folder and create message structure */ + if (!(mhf->mp = folder_read (mhf->name, 1))) + die("unable to read folder %s", mhf->name); + + /* check for empty folder */ + if (mhf->mp->nummsg == 0) + die("no messages in %s", mhf->name); + + /* parse all the message ranges/sequences and set SELECTED */ + for (size_t i = 0; i < msg_count; i++) { + if (!m_convert (mhf->mp, msgs[i])) + done(1); + } + /* set the Previous-Sequence */ + seq_setprev (mhf->mp); + + return mhf->mp; +} diff --git a/sbr/folder_read.c b/sbr/folder_read.c index 18a9ec14..76dad516 100644 --- a/sbr/folder_read.c +++ b/sbr/folder_read.c @@ -26,7 +26,7 @@ */ struct msgs * -folder_read (char *name, int lockflag) +folder_read (char *in_name, int lockflag) { int msgnum, len, *mi; struct msgs *mp; @@ -35,7 +35,7 @@ folder_read (char *name, int lockflag) struct bvector *v; size_t i; - name = m_mailpath (name); + char *const name = m_mailpath (in_name); if (!(dd = opendir (name))) { free (name); return NULL; diff --git a/sbr/getarguments.c b/sbr/getarguments.c index 1ab11634..d9e7a8f0 100644 --- a/sbr/getarguments.c +++ b/sbr/getarguments.c @@ -14,7 +14,8 @@ char ** getarguments (char *invo_name, int argc, char **argv, int check_context) { - char *cp = NULL, **ap = NULL, **bp = NULL, **arguments = NULL; + char *cp = NULL, **bp = NULL, **arguments = NULL; + char *const *ap = NULL; int n = 0; /* @@ -25,8 +26,8 @@ getarguments (char *invo_name, int argc, char **argv, int check_context) ap = brkstring (cp, " ", "\n"); /* split string */ /* Count number of arguments split */ - bp = ap; - while (*bp++) + char *const *counter = ap; + while (*counter++) n++; } diff --git a/sbr/m_convert.c b/sbr/m_convert.c index be12e507..8a6e3bf6 100644 --- a/sbr/m_convert.c +++ b/sbr/m_convert.c @@ -40,8 +40,8 @@ static int m_conv (struct msgs *, char *, int); static int attr (struct msgs *, char *); -int -m_convert (struct msgs *mp, char *name) +static int +convert (struct msgs *mp, char *const name, const bool quiet) { int first, last, found, count, err; char *bp, *cp; @@ -103,7 +103,7 @@ badmsg: case BADLST: badlist: - inform("bad message list %s", name); + if (!quiet) inform("bad message list %s", name); break; case BADNEW: @@ -249,6 +249,12 @@ single: return 1; } +int +m_convert (struct msgs *mp, char *name) +{ + return convert (mp, name, false); +} + /* * Convert the various message names to * their numeric values. diff --git a/sbr/managed_folders.c b/sbr/managed_folders.c new file mode 100644 index 00000000..5bff3a56 --- /dev/null +++ b/sbr/managed_folders.c @@ -0,0 +1,146 @@ +/* managed_folders.c + * + * This code is Copyright (c) 2020, by the authors of nmh. See the + * COPYRIGHT file in the root directory of the nmh distribution for + * complete copyright information. + */ + +#include "h/mh.h" + +#include +#include +#include + +#include "h/managed_folders.h" +#include "h/utils.h" +#include "sbr/arglist.h" +#include "sbr/concat.h" +#include "sbr/context_find.h" +#include "sbr/error.h" + +struct mh_managed_folders { + int status; + const char *error_message; + + int count; + char **names; + mh_managed_folder **folders; +}; + +struct mh_managed_folder { + char const *name; + size_t name_len; + char *manager_path; + char *socket_path; + mh_managed_folders *mmfs; +}; + +void +mh_managed_folder_set_err(mh_managed_folder *const mmf, const char *const error_message) +{ + if (error_message == NULL) + die("mh_managed_folder_set_err called with NULL error message"); + mmf->mmfs->status = NOTOK; + mmf->mmfs->error_message = error_message; +} + +bool +mh_managed_folders_open(mh_managed_folders **const result) +{ + mh_managed_folders *mmfs; + NEW(mmfs); + *result = mmfs; + + char *const managed_folders = context_find("Managed-Folders"); + if (managed_folders == NULL) { + mmfs->count = 0; + } else { + // TODO argsplit is a poor choice here + char *unused; + mmfs->names = argsplit(managed_folders, &unused, &(mmfs->count)); + mmfs->folders = mh_xmalloc(mmfs->count * sizeof *mmfs->folders); + for (int i = 0; i < mmfs->count; i++) { + NEW(mmfs->folders[i]); + mmfs->folders[i]->name_len = strlen(mmfs->names[i]); + char *key = concat(mmfs->names[i], "-manager", NULL); + // TODO free these + mmfs->folders[i]->name = mmfs->names[i]; + mmfs->folders[i]->manager_path= context_find(key); + free(key); + key = concat(mmfs->names[i], "-socket", NULL); + mmfs->folders[i]->socket_path= context_find(key); + free(key); + mmfs->folders[i]->mmfs = mmfs; + } + free(unused); + } + + mmfs->status = OK; + mmfs->error_message = NULL; + return true; +} + +char const * +mh_managed_folders_get_err_string(mh_managed_folders *const mmfs) +{ + if (mmfs->status == OK) { + return "mh_managed_folders_get_err_string called without error"; + } + if (mmfs->error_message == NULL) { + return "TODO mh_managed_folders_get_err_string require non-OK and error_message"; + } + return mmfs->error_message; +} + +char const * +mh_managed_folder_get_err_string(mh_managed_folder *const mmf) +{ + return mh_managed_folders_get_err_string(mmf->mmfs); +} + +mh_managed_folder * +mh_managed_folder_lookup( + mh_managed_folders const *const mmfs, char const *const name, size_t const name_len) +{ + for (int i = 0; i < mmfs->count; i++) { + mh_managed_folder *const folder = mmfs->folders[i]; + if (folder->name_len <= name_len + && strncmp(mmfs->names[i], name, folder->name_len) == 0 + && (folder->name_len == name_len + || name[folder->name_len] == '/')) { + return folder; + } + } + return NULL; +} + +char const * +mh_managed_folder_manager_path(const mh_managed_folder *mmf) +{ + return mmf->manager_path; +} + +char const * +mh_managed_folder_socket_path(const mh_managed_folder *mmf) +{ + return mmf->socket_path; +} + +void +mh_managed_folders_free(mh_managed_folders *const mmfs) +{ + int i; + for (i = 0; i < mmfs->count; i++) { + free(mmfs->names[i]); + free(mmfs->folders[i]); + } + free(mmfs->names); + free(mmfs->folders); + free(mmfs); +} + +char const * +mh_managed_folder_name(mh_managed_folder const *mmf) +{ + return mmf->name; +} diff --git a/sbr/netsec.c b/sbr/netsec.c index 2d70b379..0c19bcd5 100644 --- a/sbr/netsec.c +++ b/sbr/netsec.c @@ -415,7 +415,7 @@ netsec_read(netsec_context *nsc, void *buffer, size_t size, char **errstr) nsc->ns_inbuflen -= size; } - return OK; + return retlen; } /* @@ -1110,7 +1110,7 @@ netsec_set_sasl_params(netsec_context *nsc, const char *service, * library when asked. */ -int +static int netsec_get_user(void *context, int id, const char **result, unsigned int *len) { @@ -1177,7 +1177,7 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr) #ifdef CYRUS_SASL sasl_security_properties_t secprops; const char *chosen_mech; - const unsigned char *saslbuf; + unsigned char *saslbuf; unsigned char *outbuf; unsigned int saslbuflen, outbuflen; sasl_ssf_t *ssf; @@ -1213,11 +1213,10 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr) */ if (nsc->sasl_mech) { - char **str, *mlist = getcpy(mechlist); + char *mlist = getcpy(mechlist); + char *const *const str = brkstring(mlist, " ", NULL); int i; - str = brkstring(mlist, " ", NULL); - for (i = 0; str[i] != NULL; i++) { if (strcasecmp(nsc->sasl_mech, str[i]) == 0) { break; @@ -1256,16 +1255,12 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr) nsc->sasl_chosen_mech = mh_xstrdup(nsc->sasl_mech); - if (mh_oauth_do_xoauth(nsc->ns_userid, nsc->oauth_service, - &xoauth_client_res, &xoauth_client_res_len, - nsc->ns_snoop ? stderr : NULL) != OK) { - netsec_err(errstr, "Internal error: Unable to get OAuth2 " - "bearer token"); - return NOTOK; - } + mh_oauth_do_xoauth(nsc->ns_userid, nsc->oauth_service, + &xoauth_client_res, &xoauth_client_res_len, + nsc->ns_snoop ? stderr : NULL); rc = nsc->sasl_proto_cb(NETSEC_SASL_START, xoauth_client_res, - xoauth_client_res_len, NULL, 0, + xoauth_client_res_len, NULL, NULL, nsc->sasl_proto_context, errstr); free(xoauth_client_res); @@ -1279,7 +1274,7 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr) * error. */ - rc = nsc->sasl_proto_cb(NETSEC_SASL_FINISH, NULL, 0, NULL, 0, + rc = nsc->sasl_proto_cb(NETSEC_SASL_FINISH, NULL, 0, NULL, NULL, nsc->sasl_proto_context, errstr); if (rc != OK) { @@ -1290,9 +1285,9 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr) * NOT get a success message at this point. */ free(*errstr); - nsc->sasl_proto_cb(NETSEC_SASL_WRITE, NULL, 0, NULL, 0, + nsc->sasl_proto_cb(NETSEC_SASL_WRITE, NULL, 0, NULL, NULL, nsc->sasl_proto_context, NULL); - rc = nsc->sasl_proto_cb(NETSEC_SASL_FINISH, NULL, 0, NULL, 0, + rc = nsc->sasl_proto_cb(NETSEC_SASL_FINISH, NULL, 0, NULL, NULL, nsc->sasl_proto_context, errstr); if (rc == 0) { netsec_err(errstr, "Unexpected success after OAuth failure!"); @@ -1374,7 +1369,7 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr) if (nsc->ns_snoop) fprintf(stderr, "Chosen sasl mechanism: %s\n", chosen_mech); - if (nsc->sasl_proto_cb(NETSEC_SASL_START, saslbuf, saslbuflen, NULL, 0, + if (nsc->sasl_proto_cb(NETSEC_SASL_START, saslbuf, saslbuflen, NULL, NULL, nsc->sasl_proto_context, errstr) != OK) return NOTOK; @@ -1390,7 +1385,7 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr) if (nsc->sasl_proto_cb(NETSEC_SASL_READ, NULL, 0, &outbuf, &outbuflen, nsc->sasl_proto_context, errstr) != OK) { - nsc->sasl_proto_cb(NETSEC_SASL_CANCEL, NULL, 0, NULL, 0, + nsc->sasl_proto_cb(NETSEC_SASL_CANCEL, NULL, 0, NULL, NULL, nsc->sasl_proto_context, NULL); return NOTOK; } @@ -1403,15 +1398,15 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr) if (rc != SASL_OK && rc != SASL_CONTINUE) { netsec_err(errstr, "SASL client negotiation failed: %s", sasl_errdetail(nsc->sasl_conn)); - nsc->sasl_proto_cb(NETSEC_SASL_CANCEL, NULL, 0, NULL, 0, + nsc->sasl_proto_cb(NETSEC_SASL_CANCEL, NULL, 0, NULL, NULL, nsc->sasl_proto_context, NULL); return NOTOK; } if (nsc->sasl_proto_cb(NETSEC_SASL_WRITE, saslbuf, saslbuflen, - NULL, 0, nsc->sasl_proto_context, + NULL, NULL, nsc->sasl_proto_context, errstr) != OK) { - nsc->sasl_proto_cb(NETSEC_SASL_CANCEL, NULL, 0, NULL, 0, + nsc->sasl_proto_cb(NETSEC_SASL_CANCEL, NULL, 0, NULL, NULL, nsc->sasl_proto_context, NULL); return NOTOK; } @@ -1422,7 +1417,7 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr) * from the server. */ - if (nsc->sasl_proto_cb(NETSEC_SASL_FINISH, NULL, 0, NULL, 0, + if (nsc->sasl_proto_cb(NETSEC_SASL_FINISH, NULL, 0, NULL, NULL, nsc->sasl_proto_context, errstr) != OK) { /* * At this point we can't really send an abort since the SASL dialog diff --git a/sbr/oauth.c b/sbr/oauth.c index c4509fda..776332f4 100644 --- a/sbr/oauth.c +++ b/sbr/oauth.c @@ -134,7 +134,7 @@ static bool get_json_strings(const char *, size_t, FILE *, ...) ENDNULL; static bool make_query_url(char *, size_t, CURL *, const char *, ...) ENDNULL; static bool post(struct curl_ctx *, const char *, const char *); -int +void mh_oauth_do_xoauth(const char *user, const char *svc, unsigned char **oauth_res, size_t *oauth_res_len, FILE *log) { @@ -154,7 +154,7 @@ mh_oauth_do_xoauth(const char *user, const char *svc, unsigned char **oauth_res, fp = lkfopendata(fn, "r+", &failed_to_lock); if (fp == NULL) { if (errno == ENOENT) { - die("no credentials -- run mhlogin -saslmech xoauth2 -authservice %s", svc); + adios(fn, "credential file not found -- run mhlogin -saslmech xoauth2 -authservice %s", svc); } adios(fn, "failed to open"); } @@ -196,8 +196,6 @@ mh_oauth_do_xoauth(const char *user, const char *svc, unsigned char **oauth_res, mh_oauth_free(ctx); *oauth_res = (unsigned char *) client_res; - - return OK; } static bool diff --git a/sbr/read_switch_multiword.c b/sbr/read_switch_multiword.c index 9c123673..4d843253 100644 --- a/sbr/read_switch_multiword.c +++ b/sbr/read_switch_multiword.c @@ -23,12 +23,12 @@ static sigjmp_buf sigenv; static void intrser (int); -char ** +char *const * read_switch_multiword (const char *prompt, const struct swit *ansp) { int i; SIGNAL_HANDLER istat = NULL; - char *cp, **cpp; + char *cp; if (sigsetjmp(sigenv, 1)) { SIGNAL (SIGINT, istat); @@ -77,7 +77,7 @@ read_switch_multiword (const char *prompt, const struct swit *ansp) print_sw (ALL, ansp, "", stdout); continue; } - cpp = brkstring (ansbuf, " ", NULL); + char *const *cpp = brkstring (ansbuf, " ", NULL); switch (smatch (*cpp, ansp)) { case AMBIGSW: ambigsw (*cpp, ansp); diff --git a/sbr/read_switch_multiword.h b/sbr/read_switch_multiword.h index 98205e9f..1f3ea6e4 100644 --- a/sbr/read_switch_multiword.h +++ b/sbr/read_switch_multiword.h @@ -10,4 +10,4 @@ * When one matches, return a pointer to an array of pointers to the words. * Return NULL on EOF, interrupt, or other error. */ -char **read_switch_multiword(const char *, const struct swit *); +char *const *read_switch_multiword(const char *, const struct swit *); diff --git a/sbr/read_switch_multiword_via_readline.c b/sbr/read_switch_multiword_via_readline.c index 67d405c5..2e0e66fe 100644 --- a/sbr/read_switch_multiword_via_readline.c +++ b/sbr/read_switch_multiword_via_readline.c @@ -28,10 +28,10 @@ static char ansbuf[BUFSIZ]; * getans, but with readline support */ -char ** +char *const * read_switch_multiword_via_readline(char *prompt, struct swit *ansp) { - char *ans, **cpp; + char *ans; initialize_readline(); rl_cmds = ansp; @@ -56,7 +56,7 @@ read_switch_multiword_via_readline(char *prompt, struct swit *ansp) ansbuf[sizeof(ansbuf) - 1] = '\0'; free(ans); - cpp = brkstring(ansbuf, " ", NULL); + char *const *cpp = brkstring(ansbuf, " ", NULL); switch (smatch(*cpp, ansp)) { case AMBIGSW: ambigsw(*cpp, ansp); @@ -92,7 +92,7 @@ static char * nmh_command_generator(const char *text, int state) { static int list_index, len; - char *name, *p; + char *name; char buf[256]; if (!state) { @@ -104,7 +104,7 @@ nmh_command_generator(const char *text, int state) list_index++; strncpy(buf, name, sizeof(buf)); buf[sizeof(buf) - 1] = '\0'; - p = *brkstring(buf, " ", NULL); + const char *p = *brkstring(buf, " ", NULL); if (strncmp(p, text, len) == 0) return strdup(p); } diff --git a/sbr/read_switch_multiword_via_readline.h b/sbr/read_switch_multiword_via_readline.h index e1ac82d0..0b7a693e 100644 --- a/sbr/read_switch_multiword_via_readline.h +++ b/sbr/read_switch_multiword_via_readline.h @@ -8,5 +8,5 @@ * Same as read_switch_multiword but using readline(3) for input. */ #ifdef READLINE_SUPPORT -char **read_switch_multiword_via_readline(char *, struct swit *); +char *const *read_switch_multiword_via_readline(char *, struct swit *); #endif /* READLINE_SUPPORT */ diff --git a/sbr/refile.c b/sbr/refile.c index b4442f18..2975500f 100644 --- a/sbr/refile.c +++ b/sbr/refile.c @@ -16,7 +16,7 @@ int -refile (char **arg, char *file) +refile (char *const *arg, char *const file) { pid_t pid; int vecp; diff --git a/sbr/refile.h b/sbr/refile.h index 3930a85f..6d58fb42 100644 --- a/sbr/refile.h +++ b/sbr/refile.h @@ -5,4 +5,4 @@ * COPYRIGHT file in the root directory of the nmh distribution for * complete copyright information. */ -int refile(char **, char *); +int refile(char *const *, char *); diff --git a/sbr/rust/Cargo.toml b/sbr/rust/Cargo.toml new file mode 100644 index 00000000..de107064 --- /dev/null +++ b/sbr/rust/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "nmh" +# TODO derive from nmh version - or not; does it even matter? these are all internal crates +version = "0.0.0" +edition = "2018" + +[dependencies] +libc = "0.2.78" diff --git a/sbr/rust/bindgen.h b/sbr/rust/bindgen.h new file mode 100644 index 00000000..fa6bcf2f --- /dev/null +++ b/sbr/rust/bindgen.h @@ -0,0 +1,8 @@ +#include "h/mh.h" +#include "h/netsec.h" +#include "h/utils.h" +#include "sbr/base64.h" +#include "sbr/client.h" +#include "sbr/context_find.h" +#include "sbr/dtime.h" +#include "sbr/m_getfld.h" diff --git a/sbr/rust/build.rs.in b/sbr/rust/build.rs.in new file mode 100644 index 00000000..6f83917a --- /dev/null +++ b/sbr/rust/build.rs.in @@ -0,0 +1,10 @@ +fn main() { + println!("cargo:rustc-flags=@LDFLAGS@"); + println!("cargo:rustc-link-search=native=@abs_top_builddir@/sbr"); + + println!("cargo:rustc-link-lib=static=mh"); + println!("cargo:rustc-link-lib=sasl2"); + println!("cargo:rustc-link-lib=curl"); + println!("cargo:rustc-link-lib=ssl"); + println!("cargo:rustc-link-lib=crypto"); +} diff --git a/sbr/rust/src/base64.rs b/sbr/rust/src/base64.rs new file mode 100644 index 00000000..b641b92f --- /dev/null +++ b/sbr/rust/src/base64.rs @@ -0,0 +1,23 @@ +//! routines for converting to base64 (base64.h wrapper) + +use crate::sbr; +use crate::str::{NString, UnsafeNString}; + +pub fn encode_raw(bytes: &[u8]) -> NString { + let len = base64_size(bytes.len()); + let mut b64_raw: UnsafeNString = NString::with_capacity(len).into_unsafe(); + let b64_raw_unowned_ptr: *mut std::os::raw::c_uchar = b64_raw.mut_ptr(); + unsafe { + sbr::writeBase64raw( + bytes.as_ptr(), + bytes.len() as sbr::size_t, + b64_raw_unowned_ptr, + ); + } + unsafe { b64_raw.to_nstring(len) }.expect("writeBase64raw writes ASCII") +} + +// copied from base64.h +fn base64_size(x: usize) -> usize { + ((x + 2) / 3) * 4 +} diff --git a/sbr/rust/src/client.rs b/sbr/rust/src/client.rs new file mode 100644 index 00000000..390960a4 --- /dev/null +++ b/sbr/rust/src/client.rs @@ -0,0 +1,86 @@ +//! connect to a server (client.h wrapper) + +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int}; + +use crate::sbr; +use crate::str::NString; +use crate::{Error, Result}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Connector { + server: Option, + service: Option, +} + +impl Connector { + pub fn new() -> Self { + Self { + server: None, + service: None, + } + } + + /// Must be valid UTF-8. All other error-checking happens in [Connector::connect]. + pub fn server(mut self, s: &str) -> Result { + self.server = Some(CString::new(s)?); + Ok(self) + } + + /// Must be valid UTF-8. All other error-checking happens in [Connector::connect]. + pub fn service(mut self, s: &str) -> Result { + self.service = Some(CString::new(s)?); + Ok(self) + } + + /// Panics if called without setting server. Don't do that. + pub fn get_server(&self) -> &CStr { + self.server + .as_ref() + .expect("get_server called without server") + } + + /// `connect(2)` to specified [Connector::server] and [Connector::service] and return the socket. + /// Panics if called without setting server and service + pub fn connect(&self) -> Result { + let server_ptr = self + .server + .as_ref() + .expect("connect called without server") + .as_ptr() as *const c_char; + let service_ptr = self + .service + .as_ref() + .expect("connect called without service") + .as_ptr() as *const c_char; + // popsbr.c uses BUFSIZ which is 1024 (at least on FreeBSD 12.2). + let error_message_size: c_int = 1024; + let mut error_message = NString::with_capacity(error_message_size as usize); + error_message.push_ascii_str("client() provided no error message")?; + let mut error_message_raw = error_message.into_unsafe(); + let socket = { + let error_message_raw_ptr = error_message_raw.mut_ptr() as *mut c_char; + unsafe { + sbr::client( + server_ptr, + service_ptr, + error_message_raw_ptr, + error_message_size, + 1, + ) + } + }; + if socket < 0 { + let error_message = unsafe { error_message_raw.to_nstring_sized_by_null_byte()? }; + Err(Error::from(error_message)) + } else { + Ok(socket) + } + } +} + +impl Default for Connector { + fn default() -> Self { + Self::new() + } +} diff --git a/sbr/rust/src/error.rs b/sbr/rust/src/error.rs new file mode 100644 index 00000000..2eb20db5 --- /dev/null +++ b/sbr/rust/src/error.rs @@ -0,0 +1,132 @@ +use std::convert::TryFrom as _; + +use crate::str::{NString, NStringWithFree, OrString}; + +/// [std::error::Error] that supports three kinds of error: +/// - [Error::new] - a direct, base `Error` containing only an error message +/// - [Error::wrap] - a wrapper around any other `Error` without additional information +/// - [Error::describe] - a wrapper around any other `Error` with a description prefixed to the wrapped error's message +/// +/// See below for examples and details on the debug and display formats of each kind. +#[derive(Debug)] +pub struct Error(Kind); + +#[derive(Debug)] +enum Kind { + Direct(OrString), + Wrapper(Box), + Described(String, Box), +} + +impl Error { + /// ``` + /// assert_eq!("direct error", format!("{}", nmh::Error::new("direct error"))); + /// assert_eq!( + /// "Error(Direct(String(\"direct error\")))", + /// format!("{:?}", nmh::Error::new("direct error")) + /// ); + /// ``` + pub fn new>(message: T) -> Self { + Self(Kind::Direct(message.into())) + } + + /// ``` + /// let error = "one".parse::().unwrap_err(); + /// assert_eq!( + /// "invalid digit found in string", + /// format!("{}", nmh::Error::wrap(error.clone())) + /// ); + /// assert_eq!( + /// "Error(Wrapper(ParseIntError { kind: InvalidDigit }))", + /// format!("{:?}", nmh::Error::wrap(error.clone())) + /// ); + /// ``` + pub fn wrap(e: E) -> Self { + Self(Kind::Wrapper(Box::new(e))) + } + + /// ``` + /// let error = "one".parse::().unwrap_err(); + /// assert_eq!( + /// "parse(one): invalid digit found in string", + /// format!( + /// "{}", + /// nmh::Error::describe(error.clone(), format_args!("parse(one)")) + /// ) + /// ); + /// assert_eq!( + /// "Error(Described(\"parse(one): invalid digit found in string\", ParseIntError { kind: InvalidDigit }))", + /// format!( + /// "{:?}", + /// nmh::Error::describe(error.clone(), format_args!("parse(one)")) + /// ) + /// ); + /// ``` + pub fn describe(e: E, fmt: std::fmt::Arguments) -> Self { + Self(Kind::Described(format!("{}: {}", fmt, e), Box::new(e))) + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + Kind::Direct(message) => message.fmt(f), + Kind::Wrapper(source) => source.fmt(f), + Kind::Described(description, _) => description.fmt(f), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.0 { + Kind::Direct(_) => None, + Kind::Wrapper(source) => Some(source.as_ref()), + Kind::Described(_, source) => Some(source.as_ref()), + } + } +} + +mod convert { + use super::*; + + impl From for Error { + fn from(e: std::ffi::NulError) -> Self { + Self::wrap(e) + } + } + + impl From for Error { + fn from(e: std::num::TryFromIntError) -> Self { + Self::wrap(e) + } + } + + impl From for Error { + fn from(e: std::str::Utf8Error) -> Self { + Self::wrap(e) + } + } + + impl From for Error { + fn from(ns: NString) -> Self { + Self(Kind::Direct(OrString::N(ns))) + } + } + + impl From for Error { + fn from(s: NStringWithFree) -> Self { + // TODO Expand OrString rather than copying. + match String::try_from(s) { + Err(e) => e, + Ok(s) => Self::new(s), + } + } + } + + impl From for String { + fn from(e: Error) -> String { + e.to_string() + } + } +} diff --git a/sbr/rust/src/folder.rs b/sbr/rust/src/folder.rs new file mode 100644 index 00000000..8db9df44 --- /dev/null +++ b/sbr/rust/src/folder.rs @@ -0,0 +1,33 @@ +use std::path::{Path, PathBuf}; + +use crate::{Error, Result}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Name(PathBuf); + +impl Name { + /// Construct from a [PathBuf] which MUST be a relative path encoded in UTF-8, else return [Error]. + pub fn from_utf8(name: PathBuf) -> Result { + if name.is_absolute() { + Err(Error::new("folder name must be relative".to_owned())) + } else { + name.to_str() + .ok_or_else(|| Error::new("folder name must be in UTF-8"))?; + Ok(Self(name)) + } + } + + pub fn as_path(&self) -> &Path { + self.0.as_path() + } + + pub fn to_str(&self) -> &str { + self.0.to_str().expect("checked in constructor") + } +} + +impl std::fmt::Display for Name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.display().fmt(f) + } +} diff --git a/sbr/rust/src/hacks.rs b/sbr/rust/src/hacks.rs new file mode 100644 index 00000000..5649f896 --- /dev/null +++ b/sbr/rust/src/hacks.rs @@ -0,0 +1,17 @@ +// TODO other splits have this problem too; generalize +/// `"".split(' ')` returns [""] lol wut (ノಠ益ಠ)ノ彡┻━┻ +/// ``` +/// let empty = ""; +/// assert_eq!(vec![""], empty.split(' ').collect::>()); +/// +/// // ( ͡° ͜ ʖ ͡°) +/// let expected_split: Vec<&str> = vec![]; +/// assert_eq!(expected_split, nmh::hacks::split(empty).collect::>()); +/// ``` +pub fn split<'a>(s: &'a str) -> Box + 'a> { + if s.is_empty() { + Box::new(std::iter::empty()) + } else { + Box::new(s.split(' ')) + } +} diff --git a/sbr/rust/src/lib.rs b/sbr/rust/src/lib.rs new file mode 100644 index 00000000..be87dfcf --- /dev/null +++ b/sbr/rust/src/lib.rs @@ -0,0 +1,61 @@ +pub mod base64; +pub mod client; +pub mod folder; +pub mod hacks; +pub mod message; +pub mod netsec; +pub mod profile; +pub mod str; + +mod error; +pub use error::Error; + +pub use netsec::netsec::Netsec; +pub use profile::p::Profile; + +/// on error, nmh_init writes to stderr and this panics +pub fn init(argv0: std::ffi::CString) -> Context { + let csptr = argv0.as_ptr(); + // The only caller (imap-folder-manager) always wants the context. + let read_context = true; + // And also doesn't need to check version because it already happened. + let check_version = false; + if unsafe { sbr::nmh_init(csptr, read_context as i32, check_version as i32) } < 0 { + // nmh_init already spewed to stderr + panic!("nmh_init") + } + Context { _argv0: argv0 } +} + +// Conceptually, things like netsec depend on this. But it's currently all +// globals and you just have to call things in the right magical order or things +// break. We use this to at least enforce things in Rust. +pub struct Context { + _argv0: std::ffi::CString, +} + +impl Context { + pub fn profile(&self) -> Profile { + Profile(self) + } +} + +// Suppress all these warnings on bindgen's output. +// TODO Yeesh. Are they going to clean this up? +#[allow(dead_code)] +#[allow(deref_nullptr)] +#[allow(improper_ctypes)] +#[allow(non_camel_case_types)] +#[allow(non_snake_case)] +#[allow(non_upper_case_globals)] +mod bindgen; +pub(crate) mod sbr; + +/// Return a reasonably-human-readable description of the current time. +/// # Safety +/// As safe as `dtimenow`. +pub unsafe fn dtimenow() -> &'static std::ffi::CStr { + std::ffi::CStr::from_ptr(bindgen::dtimenow(0)) +} + +type Result = std::result::Result; diff --git a/sbr/rust/src/message.rs b/sbr/rust/src/message.rs new file mode 100644 index 00000000..a78195ec --- /dev/null +++ b/sbr/rust/src/message.rs @@ -0,0 +1,59 @@ +use std::num::NonZeroI32; + +mod set; +pub use set::Set; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Number(NonZeroI32); + +impl Number { + pub fn one() -> Self { + Self(NonZeroI32::new(1).expect("1 is not 0")) + } + + pub fn increment(&mut self) { + self.0 = NonZeroI32::new(self.0.get() + 1).expect("non-zero + 1 is not 0"); + } +} + +impl std::fmt::Display for Number { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +mod convert { + use super::*; + + impl From for Number { + fn from(n: NonZeroI32) -> Self { + Self(n) + } + } + + impl From for i32 { + fn from(n: Number) -> i32 { + n.0.get() + } + } + + impl From for NonZeroI32 { + fn from(n: Number) -> NonZeroI32 { + n.0 + } + } + + impl std::str::FromStr for Number { + type Err = crate::Error; + fn from_str(s: &str) -> Result { + let n: NonZeroI32 = s + .parse() + .map_err(|e| crate::Error::describe(e, format_args!("message number")))?; + if n.get() < 0 { + Err(crate::Error::new(format!("illegal message number {}", n))) + } else { + Ok(Number::from(n)) + } + } + } +} diff --git a/sbr/rust/src/message/set.rs b/sbr/rust/src/message/set.rs new file mode 100644 index 00000000..8f8e43bf --- /dev/null +++ b/sbr/rust/src/message/set.rs @@ -0,0 +1,119 @@ +// TODO wrap sbr structures + +#[derive(Clone)] +pub struct Set(Has); + +impl Set { + pub fn new() -> Self { + Self(Has::Set(std::collections::BTreeSet::new())) + } + + pub fn all() -> Self { + Self(Has::All) + } + + pub fn append(&mut self, other: &mut Self) { + if let Has::Set(set) = &mut self.0 { + match &mut other.0 { + Has::All => self.0 = Has::All, + Has::Set(other_set) => set.append(other_set), + } + } + } + + pub fn insert(&mut self, msgnum: i32) -> bool { + match &mut self.0 { + Has::All => false, + Has::Set(set) => set.insert(msgnum), + } + } + + pub fn remove(&mut self, msgnum: i32) -> bool { + match &mut self.0 { + Has::All => todo!(), + Has::Set(set) => set.remove(&msgnum), + } + } + + pub fn clone_without(&self, other: &Self) -> Self { + match &self.0 { + Has::All => todo!(), + Has::Set(set) => match &other.0 { + Has::All => Self::new(), + Has::Set(other_set) => Self(Has::Set(set.difference(other_set).cloned().collect())), + }, + } + } + + pub fn ends(&self) -> Option<(i32, i32)> { + match &self.0 { + Has::All => Some((1, i32::MAX)), + Has::Set(set) => { + let mut i = set.iter(); + match i.next() { + None => None, + Some(beg) => match i.next_back() { + None => Some((*beg, *beg)), + Some(end) => Some((*beg, *end)), + }, + } + } + } + } + + pub fn contains(&self, msgnum: i32) -> bool { + match &self.0 { + Has::All => true, + Has::Set(set) => set.contains(&msgnum), + } + } + + pub fn is_empty(&self) -> bool { + match &self.0 { + Has::All => false, + Has::Set(set) => set.is_empty(), + } + } +} + +impl std::fmt::Display for Set { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match &self.0 { + Has::All => write!(f, "1-{}", i32::MAX), + Has::Set(set) => { + let mut space = ""; + let (mut beg, mut end) = (-1, -1); + for msgnum in set.iter() { + if beg == -1 && end == -1 { + beg = *msgnum; + } else if *msgnum != end + 1 { + assert!(end > 0); + if beg == end { + write!(f, "{}{}", space, beg)? + } else { + write!(f, "{}{}-{}", space, beg, end)? + } + space = " "; + beg = *msgnum; + } + end = *msgnum; + } + if beg > 0 { + if beg == end { + write!(f, "{}{}", space, beg) + } else { + write!(f, "{}{}-{}", space, beg, end) + } + } else { + Ok(()) + } + } + } + } +} + +#[derive(Clone)] +enum Has { + All, + Set(std::collections::BTreeSet), +} diff --git a/sbr/rust/src/netsec.rs b/sbr/rust/src/netsec.rs new file mode 100644 index 00000000..d7a1a760 --- /dev/null +++ b/sbr/rust/src/netsec.rs @@ -0,0 +1,31 @@ +//! network-security library routines (netsec.h wrapper) +//! +//! This is a wrapper around netsec, a common set of routines to handle network security for things +//! like SASL and OpenSSL. See netsec.h for more information. +//! +//! Start with [crate::Netsec::builder]. +//! +//! # Examples +//! +//! ```ignore +//! use nmh::{client, netsec, Netsec}; +//! +//! let mut builder: netsec::Builder = Netsec::builder() +//! .host(&std::ffi::CString::new("hostname")?); +//! let mut connector: netsec::tls::Connector = builder.connect(client::Connector::new().server("...")?.build()); +//! let mut authenticator: netsec::sasl::Authenticator = connector.negotiate_tls()?; +//! let mut nsc: Netsec = authenticator.negotiate_sasl(&context, &mut my_callback)?; +//! ``` + +mod build; +pub(crate) mod netsec; +pub mod sasl; +pub mod tls; + +pub use build::Builder; + +type ErrorString = crate::str::NStringWithFree; + +fn empty_cstring() -> std::ffi::CString { + std::ffi::CString::new("").expect("null byte in empty string") +} diff --git a/sbr/rust/src/netsec/build.rs b/sbr/rust/src/netsec/build.rs new file mode 100644 index 00000000..e4fb63f9 --- /dev/null +++ b/sbr/rust/src/netsec/build.rs @@ -0,0 +1,106 @@ +use std::convert::TryInto; +use std::ffi::{CStr, CString}; +use std::os::raw::c_int; + +use crate::{sbr, Result}; + +use super::*; + +// We could use state transitions to prevent callers from bugs such as +// attempting to negotiate TLS without first setting a host, but this is not +// some published API with goals of stability, but internal, so we know the +// cost of Holding It Wrong is not a subtle crash mid-operation, but merely an +// obvious crash at startup. + +/// This is fragile. +#[derive(Debug)] +pub struct Builder { + pub(super) nsc: crate::Netsec, + pub(super) skip_cert_checks: bool, + pub(super) sasl: sasl::AuthenticatorBuilder, +} + +impl Builder { + /// Must be valid UTF-8. All other error-checking happens after [Builder::connect]. + pub fn host(self, host: &CStr) -> Self { + // The CString doesn't need to live past this scope because netsec_set_hostname makes its own copy. + let csptr = host.as_ptr(); + unsafe { + sbr::netsec_set_hostname(self.nsc.nsc, csptr); + } + self + } + + /// `timeout` must be representable as a 32-bit integer of seconds. + pub fn timeout(self, timeout: std::time::Duration) -> Result { + let timeout_c: c_int = timeout.as_secs().try_into()?; + unsafe { sbr::netsec_set_timeout(self.nsc.nsc, timeout_c) } + Ok(self) + } + + /// Enable logging of traffic to the standard error. + pub fn snoop(self) -> Self { + unsafe { sbr::netsec_set_snoop(self.nsc.nsc, 1) } + self + } + + /// I wouldn't. + pub fn skip_certificate_checks(mut self) -> Self { + self.skip_cert_checks = true; + self + } + + /// Must be valid UTF-8. All other error-checking happens after [crate::netsec::sasl::Authenticator::negotiate_sasl]. + pub fn sasl_service(mut self, value: &str) -> Result { + self.sasl.service = CString::new(value)?; + Ok(self) + } + + /// Must be valid UTF-8. All other error-checking happens after [crate::netsec::sasl::Authenticator::negotiate_sasl]. + pub fn sasl_userid(self, userid: &str) -> Result { + // The CString doesn't need to live past this scope because netsec_set_userid makes its own copy. + let cs = CString::new(userid)?; + let csptr = cs.as_ptr(); + unsafe { + sbr::netsec_set_userid(self.nsc.nsc, csptr); + } + Ok(self) + } + + /// Must be valid UTF-8. All other error-checking happens after [crate::netsec::sasl::Authenticator::negotiate_sasl]. + pub fn sasl_prefer_mechanism(&mut self, value: &str) -> Result<()> { + self.sasl.preferred_mech = CString::new(value)?; + Ok(()) + } + + /// Must be valid UTF-8. All other error-checking happens after [crate::netsec::sasl::Authenticator::negotiate_sasl]. + pub fn sasl_oauth_service(&mut self, value: &str) -> Result<()> { + // The CString doesn't need to live past this scope because netsec_set_userid makes its own copy. + let cs = CString::new(value)?; + let csptr = cs.as_ptr(); + let status = unsafe { sbr::netsec_set_oauth_service(self.nsc.nsc, csptr) }; + if status < 0 { + todo!("nmh not built with OAuth support"); + } + Ok(()) + } + + /// `connect(2)` via the [crate::client::Connector] and return + /// [tls::Connector] for negotiating TLS. + pub fn connect(self, connector: crate::client::Connector) -> Result { + let socket: std::os::unix::io::RawFd = connector.connect()?; + unsafe { sbr::netsec_set_fd(self.nsc.nsc, socket, socket) } + Ok(tls::Connector { builder: self }) + } + + pub(super) fn new() -> Self { + Builder { + nsc: crate::Netsec::new(), + skip_cert_checks: false, + sasl: sasl::AuthenticatorBuilder { + preferred_mech: empty_cstring(), + service: empty_cstring(), + }, + } + } +} diff --git a/sbr/rust/src/netsec/netsec.rs b/sbr/rust/src/netsec/netsec.rs new file mode 100644 index 00000000..259a4bf6 --- /dev/null +++ b/sbr/rust/src/netsec/netsec.rs @@ -0,0 +1,139 @@ +use std::os::raw::{c_char, c_int, c_void}; + +use crate::sbr; + +use super::{Builder, ErrorString}; + +#[derive(Debug)] +pub struct Netsec { + pub(super) nsc: *mut sbr::netsec_context, + last_write_ends_with_crlf: bool, +} + +impl Netsec { + pub fn builder() -> Builder { + Builder::new() + } + + /// Safe due to mutable borrow: you can't call this a second time and + /// invalidate a previously stored &str reference. + pub fn read_line(&mut self) -> crate::Result<&mut str> { + let as_char_ptr: *mut c_char = self.read_line_raw()?; + let as_u8_ptr = as_char_ptr as *mut u8; + // TODO underlying netsec knows len and should return it + let as_u8_slice: &mut [u8] = + unsafe { std::slice::from_raw_parts_mut(as_u8_ptr, libc::strlen(as_char_ptr)) }; + // Skip the valid UTF-8 enforcement, as we're going to instead enforce valid ASCII. + let as_str: &mut str = unsafe { std::str::from_utf8_unchecked_mut(as_u8_slice) }; + if !as_str.is_ascii() { + Err(crate::Error::new(format!("not ASCII: {}", as_str))) + } else { + Ok(as_str) + } + } + + pub fn read(&mut self, buffer: &mut [u8]) -> crate::Result { + let mut errstr = ErrorString::new(); + let bytes_read: sbr::ssize_t = { + let buffer_c = buffer as *mut _ as *mut c_void; + let buffer_len = buffer.len() as sbr::size_t; + unsafe { sbr::netsec_read(self.nsc, buffer_c, buffer_len, errstr.pointer()) } + }; + if bytes_read < 0 { + return Err(errstr.into()); + } + Ok(bytes_read as usize) + } + + pub fn write(&mut self, data: &[u8]) -> crate::Result<()> { + self.last_write_ends_with_crlf = data.ends_with(b"\r\n"); + let mut errstr = ErrorString::new(); + let status = unsafe { + sbr::netsec_write( + self.nsc, + data.as_ptr() as *const c_void, + data.len() as sbr::size_t, + errstr.pointer(), + ) + }; + if status != 0 { + Err(errstr.into()) + } else { + Ok(()) + } + } + + // TODO Tried to do this cleanly but the base64-decoding in netsec snoop is all tangled up. + // There's this generic callback mechanism but it's actually only used for base64-decoding. + // The snoop only happens in flush so we have to flush before clearing the decoding snoop callback. + // The snoop only happens anyway if there's a CRLF in the buffer. + // The "offset at which base64 data starts" is also computed from the start of a line. + pub fn write_with_base64_at_offset_then_crlf_then_flush( + &mut self, + line: &str, + mut offset: c_int, + ) -> crate::Result<()> { + assert!(line.ends_with("\r\n")); + let offset_c = &mut offset as *mut _ as *mut c_void; + unsafe { + sbr::netsec_set_snoop_callback(self.nsc, SNOOP_CALLBACK, offset_c); + } + // Hold onto the success or failure while we clear the callback. + let result = self.write(line.as_bytes()).and_then(|_| self.flush()); + unsafe { + sbr::netsec_set_snoop_callback(self.nsc, None, std::ptr::null_mut()); + } + result + } + + pub fn flush(&mut self) -> crate::Result<()> { + if !self.last_write_ends_with_crlf { + // I reliably triggered a runaway memory allocation when testing + // refile -file on Gandi, but only on Gandi. Why? + // TODO Come up with reliable test and fix the underlying bug. + todo!("flush may call snoop which may crash if the buffer doesn't end with CRLF"); + } + + let mut errstr = ErrorString::new(); + let status = { unsafe { sbr::netsec_flush(self.nsc, errstr.pointer()) } }; + if status != 0 { + return Err(errstr.into()); + } + Ok(()) + } + + pub fn write_and_flush(&mut self, data: &[u8]) -> crate::Result<()> { + self.write(data)?; + self.flush() + } + + pub(super) fn new() -> Self { + Self { + nsc: unsafe { sbr::netsec_init() }, + last_write_ends_with_crlf: false, + } + } + + fn read_line_raw(&mut self) -> crate::Result<*mut c_char> { + let mut len: sbr::size_t = 0; + let mut errstr = ErrorString::new(); + let line: *mut c_char = { + let len_c = &mut len as *mut _; + unsafe { sbr::netsec_readline(self.nsc, len_c, errstr.pointer()) } + }; + if line.is_null() { + return Err(errstr.into()); + } + Ok(line) + } +} + +impl Drop for Netsec { + fn drop(&mut self) { + unsafe { + sbr::netsec_shutdown(self.nsc); + } + } +} + +const SNOOP_CALLBACK: sbr::netsec_snoop_callback = Some(sbr::netsec_b64_snoop_decoder); diff --git a/sbr/rust/src/netsec/sasl.rs b/sbr/rust/src/netsec/sasl.rs new file mode 100644 index 00000000..43ff4897 --- /dev/null +++ b/sbr/rust/src/netsec/sasl.rs @@ -0,0 +1,264 @@ +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int, c_uchar, c_uint, c_void}; + +use crate::{sbr, Netsec}; + +use super::*; + +pub type Result = std::result::Result>; +pub enum Error { + /// an error you returned from a [Callback] method + Callback(E), + /// an error [Authenticator::negotiate_sasl] encountered + Internal(crate::Error), +} + +impl From for Error { + fn from(e: crate::Error) -> Self { + Error::Internal(e) + } +} + +impl From> for crate::Error { + fn from(e: Error) -> Self { + match e { + Error::Internal(e) => e, + Error::Callback(e) => e, + } + } +} + +/// Reading netsec.h is required at this point. +/// +/// You can pass errors back out to yourself by returning [Error::Callback] from any of these +/// methods and [Authenticator::negotiate_sasl] will return it to you. +pub trait Callback { + /// See `NETSEC_SASL_START`. + fn start( + &mut self, + nsc: &mut Netsec, + selected_mechanism: &str, + message: &[u8], + ) -> Result<(), E>; + /// See `NETSEC_SASL_READ`. + fn read(&mut self, nsc: &mut Netsec) -> Result, E>; + /// See `NETSEC_SASL_WRITE`. + fn write(&mut self, nsc: &mut Netsec, mesage: &[u8]) -> Result<(), E>; + /// See `NETSEC_SASL_FINISH`. + fn finish(&mut self, nsc: &mut Netsec) -> Result<(), E>; + /// See `NETSEC_SASL_CANCEL`. + fn cancel(&mut self) -> Result<(), E>; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub enum Mechanism { + Plain, + // TODO replace with OAuthBearer + XOAuth2, +} + +impl std::fmt::Display for Mechanism { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Plain => "PLAIN", + Self::XOAuth2 => "XOAUTH2", + }) + } +} + +/// Use in conjunction with your [Callback] impl to negotiate authentication with SASL. +#[derive(Debug)] +pub struct Authenticator { + nsc: Netsec, + preferred_mech: CString, + server_mechlist: CString, + service: CString, +} + +impl Authenticator { + /// Return a line of text from the server. Useful with [Authenticator::write_and_flush] for SASL. + pub fn read_line(&mut self) -> crate::Result<&mut str> { + self.nsc.read_line() + } + + /// Return a line of text from the server. Useful with [Authenticator::read_line] for SASL. + pub fn write_and_flush(&mut self, data: &[u8]) -> crate::Result<()> { + self.nsc.write_and_flush(data) + } + + pub fn server_supported_sasl_mechanisms>( + mut self, + value: T, + ) -> Self { + self.server_mechlist = CString::new( + value + .map(|mech| mech.to_string()) + .collect::>() + .join(" "), + ) + .expect("sasl::Mechanism::to_string never to return null bytes"); + self + } + + /// Drive [Callback] to negotiate SASL. + /// If any of your [Callback] methods return [Error::Callback], return that error to you. + pub fn negotiate_sasl>( + mut self, + // Used to lookup credentials, but under the hood, through globals. + _nmh: &crate::Context, + sasl_callback: &mut T, + ) -> Result { + let nsc = self.nsc.nsc; + let mut callback_data = CallbackData { + netsec: &mut self.nsc, + callback: sasl_callback, + error: None, + }; + let mut errstr = ErrorString::new(); + let mut status = { + let service = self.service.as_ptr(); + let preferred_mech = if self.preferred_mech.as_bytes().is_empty() { + std::ptr::null() + } else { + self.preferred_mech.as_ptr() + }; + let context = &mut callback_data as *mut _ as *mut c_void; + unsafe { + sbr::netsec_set_sasl_params( + nsc, + service, + preferred_mech, + Some(netsec_sasl_callback::), + context, + errstr.pointer(), + ) + } + }; + if status != 0 { + return Err(Error::Internal(errstr.into())); + } + let mut errstr = ErrorString::new(); + status = unsafe { + sbr::netsec_negotiate_sasl(nsc, self.server_mechlist.as_ptr(), errstr.pointer()) + }; + if let Some(e) = callback_data.error { + Err(e) + } else if status != 0 { + Err(Error::Internal(errstr.into())) + } else { + Ok(self.nsc) + } + } + + pub fn skip_sasl(self) -> Netsec { + self.nsc + } +} + +#[derive(Debug)] +pub(super) struct AuthenticatorBuilder { + pub(super) preferred_mech: CString, + pub(super) service: CString, +} + +impl AuthenticatorBuilder { + pub fn build(self, nsc: Netsec) -> Authenticator { + Authenticator { + nsc, + preferred_mech: self.preferred_mech, + server_mechlist: empty_cstring(), + service: self.service, + } + } +} + +struct CallbackData<'a, E, T: Callback> { + netsec: &'a mut Netsec, + callback: &'a mut T, + error: Option>, +} + +unsafe extern "C" fn netsec_sasl_callback>( + mtype: sbr::sasl_message_type, + indata: *const c_uchar, + indatasize: c_uint, + outdata: *mut *mut c_uchar, + outdatasize: *mut c_uint, + context: *mut c_void, + _: *mut *mut c_char, +) -> c_int { + let nsc_callback = &mut *(context as *mut CallbackData); + match dispatch_netsec_sasl_callback( + nsc_callback.netsec, + nsc_callback.callback, + mtype, + indata, + indatasize, + outdata, + outdatasize, + ) { + Err(e) => { + nsc_callback.error = Some(e); + -1 + } + _ => 0, + } +} + +fn u8_slice_from_cuchar_cuint<'a>(data: *const c_uchar, len: c_uint) -> &'a [u8] { + if len > 0 { + let len_usize = len as usize; + unsafe { std::slice::from_raw_parts(data, len_usize) } + } else { + &[] + } +} + +fn dispatch_netsec_sasl_callback>( + nsc: &mut Netsec, + callback: &mut T, + mtype: sbr::sasl_message_type, + indata: *const c_uchar, + indatasize: c_uint, + _outdata: *mut *mut c_uchar, + _outdatasize: *mut c_uint, +) -> Result<(), E> { + match mtype { + sbr::sasl_message_type_NETSEC_SASL_START => { + let cstr = unsafe { CStr::from_ptr(sbr::netsec_get_sasl_mechanism(nsc.nsc)) }; + let selected_mech: &str = std::str::from_utf8(cstr.to_bytes()) + .expect("SASL mechanism never to return null bytes"); + callback.start( + nsc, + selected_mech, + u8_slice_from_cuchar_cuint(indata, indatasize), + ) + } + sbr::sasl_message_type_NETSEC_SASL_READ => { + todo!("NETSEC_SASL_READ") + // let result: Result, E> = callback.read(nsc); + // match result { + // Ok(mut bytes) => { + // let len_c: c_uint = bytes.len().try_into()?; + // let bytes_ptr = bytes.as_mut_ptr(); + // unsafe { + // *outdatasize = len_c; + // *outdata = bytes_ptr; + // } + // Ok(()) + // } + // Err(e) => Err(e), + // } + } + sbr::sasl_message_type_NETSEC_SASL_WRITE => { + todo!("NETSEC_SASL_WRITE") + // callback.write(nsc, u8_slice_from_cuchar_cuint(indata, indatasize)) + } + sbr::sasl_message_type_NETSEC_SASL_FINISH => callback.finish(nsc), + sbr::sasl_message_type_NETSEC_SASL_CANCEL => todo!("NETSEC_SASL_CANCEL"), + _ => Err(Error::Internal(crate::Error::new(format!( + "illegal sasl_message_type {}", + mtype, + )))), + } +} diff --git a/sbr/rust/src/netsec/tls.rs b/sbr/rust/src/netsec/tls.rs new file mode 100644 index 00000000..971cd9ac --- /dev/null +++ b/sbr/rust/src/netsec/tls.rs @@ -0,0 +1,49 @@ +use crate::sbr; + +use super::*; + +#[derive(Debug)] +pub struct Connector { + pub(super) builder: Builder, +} + +impl Connector { + /// Return a line of text from the server. Useful with [Connector::write_and_flush] for STARTTLS. + pub fn read_line(&mut self) -> crate::Result<&mut str> { + self.builder.nsc.read_line() + } + + /// Return a line of text from the server. Useful with [Connector::read_line] for STARTTLS. + pub fn write_and_flush(&mut self, data: &[u8]) -> crate::Result<()> { + self.builder.nsc.write_and_flush(data) + } + + /// I wouldn't. + pub fn skip_tls(self) -> sasl::Authenticator { + self.builder.sasl.build(self.builder.nsc) + } + + /// Negotiate TLS and prepare for authentication. + pub fn negotiate_tls(self) -> crate::Result { + let mut errstr = ErrorString::new(); + let mut status = { + unsafe { + sbr::netsec_set_tls( + self.builder.nsc.nsc, + 1, + if self.builder.skip_cert_checks { 1 } else { 0 }, + errstr.pointer(), + ) + } + }; + if status != 0 { + return Err(errstr.into()); + } + let mut errstr = ErrorString::new(); + status = unsafe { sbr::netsec_negotiate_tls(self.builder.nsc.nsc, errstr.pointer()) }; + if status != 0 { + return Err(errstr.into()); + } + Ok(self.builder.sasl.build(self.builder.nsc)) + } +} diff --git a/sbr/rust/src/profile.rs b/sbr/rust/src/profile.rs new file mode 100644 index 00000000..3f931a68 --- /dev/null +++ b/sbr/rust/src/profile.rs @@ -0,0 +1,126 @@ +//! facilities related to [crate::Profile] + +use std::os::raw::c_char; +use std::path::Path; + +use crate::sbr; + +pub(crate) mod p { + use super::*; + pub struct Profile<'a>(pub(crate) &'a crate::Context); + impl<'a> Profile<'a> { + /// Return Some if the profile has a value for `name`, else None. Panic on any error. + pub fn get(&self, name: &str) -> Option<&str> { + get(name) + } + + /// Return Ok if the profile has a value for `name`, else Err. Panic on error. + pub fn get_or_err(&self, name: &str) -> Result<&str, String> { + get_or_err(name) + } + + /// Return view into the profile that only exposes components whose names begin with `prefix`. + pub fn prefixed(&self, prefix: &'a str) -> Prefixed<'a> { + Prefixed { prefix } + } + + //////////////////////////////////////////////////////////////////////////////// + // common parameters + // See mh-profile(5). + + pub fn path(&self) -> Result<&Path, String> { + self.get_or_err("path").map(Path::new) + } + + pub fn previous_sequence(&self) -> Result<&Path, String> { + self.get_or_err("previous-sequence").map(Path::new) + } + + pub fn unseen_sequence(&self) -> Result<&Path, String> { + self.get_or_err("unseen-sequence").map(Path::new) + } + } +} + +/// Prefixed view of profile; only sees components whose name starts with the prefix. +pub struct Prefixed<'a> { + prefix: &'a str, +} + +impl<'a> Prefixed<'a> { + /// Prefixed form of [crate::Profile::get]. + pub fn get(&self, name: &str) -> Option<&str> { + get(&format!("{}{}", self.prefix, name)) + } + + /// Prefixed form of [crate::Profile::get_or_err]. + pub fn get_or_err(&self, name: &str) -> Result<&str, String> { + get_or_err(&format!("{}{}", self.prefix, name)) + } + + pub fn iter(&self) -> PrefixedIterator<'a> { + PrefixedIterator { + prefix: self.prefix, + np: unsafe { sbr::m_defs }, + } + } +} + +pub struct PrefixedIterator<'a> { + prefix: &'a str, + np: *mut sbr::node, +} + +impl<'a> Iterator for PrefixedIterator<'a> { + type Item = (&'a str, &'a std::ffi::CStr); + fn next(&mut self) -> Option { + while !self.np.is_null() { + let node = self.np; + self.np = unsafe { (*node).n_next }; + let name_p: *mut c_char = unsafe { (*node).n_name }; + assert!(!name_p.is_null(), "illegal NULL component name"); + let name_cstr = unsafe { std::ffi::CStr::from_ptr(name_p) }; + let name: &str = match name_cstr.to_str() { + Err(e) => panic!( + "{} decoding component name {}", + e, + name_cstr.to_string_lossy() + ), + Ok(value) => value, + }; + if name.len() < self.prefix.len() { + continue; + } + if !name[..self.prefix.len()].eq_ignore_ascii_case(self.prefix) { + continue; + } + + let value_p: *mut c_char = unsafe { (*node).n_field }; + assert!(!value_p.is_null(), "illegal NULL component value"); + let value_cstr = unsafe { std::ffi::CStr::from_ptr(value_p) }; + return Some((&name[self.prefix.len()..], value_cstr)); + } + None + } +} + +fn get(name: &str) -> Option<&'static str> { + let mut name_bytes: Vec = match std::ffi::CString::new(name) { + Err(_) => return None, + Ok(value) => value.into_bytes_with_nul(), + }; + let name_c = name_bytes.as_mut_ptr() as *mut c_char; + let result: *mut c_char = unsafe { sbr::context_find(name_c) }; + if result.is_null() { + return None; + } + let result_c = unsafe { std::ffi::CStr::from_ptr(result) }; + match result_c.to_str() { + Err(e) => panic!("{} decoding {}", e, result_c.to_string_lossy()), + Ok(value) => Some(value), + } +} + +fn get_or_err(name: &str) -> Result<&'static str, String> { + get(name).ok_or_else(|| format!("required component {} absent from profile", name)) +} diff --git a/sbr/rust/src/sbr.rs b/sbr/rust/src/sbr.rs new file mode 100644 index 00000000..06fc094f --- /dev/null +++ b/sbr/rust/src/sbr.rs @@ -0,0 +1,61 @@ +use crate::bindgen; + +pub use bindgen::context_find; +pub use bindgen::m_defs; +pub use bindgen::nmh_init; +pub use bindgen::node; + +pub use bindgen::m_getfld2; +pub use bindgen::m_getfld_state_destroy; +pub use bindgen::m_getfld_state_init; +pub use bindgen::m_getfld_state_t; +pub use bindgen::FMTERR; +pub use bindgen::LENERR; +pub use bindgen::NAMESZ; + +pub use bindgen::netsec_context; +pub use bindgen::netsec_err; +pub use bindgen::netsec_flush; +pub use bindgen::netsec_get_sasl_mechanism; +pub use bindgen::netsec_get_sasl_ssf; +pub use bindgen::netsec_init; +pub use bindgen::netsec_negotiate_sasl; +pub use bindgen::netsec_negotiate_tls; +pub use bindgen::netsec_printf; +pub use bindgen::netsec_read; +pub use bindgen::netsec_readline; +pub use bindgen::netsec_sasl_callback; +pub use bindgen::netsec_set_fd; +pub use bindgen::netsec_set_hostname; +pub use bindgen::netsec_set_oauth_service; +pub use bindgen::netsec_set_sasl_params; +pub use bindgen::netsec_set_snoop; +pub use bindgen::netsec_set_snoop_callback; +pub use bindgen::netsec_set_timeout; +pub use bindgen::netsec_set_tls; +pub use bindgen::netsec_set_userid; +pub use bindgen::netsec_shutdown; +pub use bindgen::netsec_snoop_callback; +pub use bindgen::netsec_write; + +pub use bindgen::netsec_b64_snoop_decoder; + +pub use bindgen::sasl_message_type; +pub use bindgen::sasl_message_type_NETSEC_SASL_CANCEL; +pub use bindgen::sasl_message_type_NETSEC_SASL_FINISH; +pub use bindgen::sasl_message_type_NETSEC_SASL_READ; +pub use bindgen::sasl_message_type_NETSEC_SASL_START; +pub use bindgen::sasl_message_type_NETSEC_SASL_WRITE; + +pub use bindgen::decodeBase64; +pub use bindgen::writeBase64raw; + +pub use bindgen::client; + +// TODO take from libc crate? But those are weird and require conversions?! +pub use bindgen::fclose; +pub use bindgen::fdopen; +pub use bindgen::free; +pub use bindgen::size_t; +pub use bindgen::ssize_t; +pub use bindgen::FILE; diff --git a/sbr/rust/src/str.rs b/sbr/rust/src/str.rs new file mode 100644 index 00000000..c6c636a8 --- /dev/null +++ b/sbr/rust/src/str.rs @@ -0,0 +1,254 @@ +//! [String]-like types (and when to use them) + +//! - Use [String] and [str] in pure Rust +//! - Use [std::ffi::CStr] to accept a reference to a null-terminated string owned in C. +//! - Use [std::ffi::CString] to pass a reference to a [str] to C, but note that this makes a copy. If this +//! should become a problem, [NString] is ready to support this case. +//! - Use [NString] to pass a mutable reference to a buffer into which C may write a null-terminated string. +//! - Use [NStringWithFree] to accept ownership of a null-terminated string from C, to be freed with sbr::free + +use crate::Error; + +// TODO implement more String-like behaviors as needed, for individual types and for OrString. +// Consider putting those behaviors into a StringLike trait. + +// TODO could easily split into ASCIIString and UTF8String if needed. +/// Like String but always with a final null byte for no-copy use with C-style functions, and always containing only ASCII. +pub struct NString { + // INVARIANT: data.len() > len + // While this starts out all zero, it is possible for bytes between len and data.len() to be non-zero, for example: + // sprintf(nstring.mut_ptr(), b"this many bytes\0"); + // sprintf(nstring.mut_ptr(), b"fewer bytes\0"); + // At this point, data is b"fewer bytes\0tes\0" and nstring.as_str() is "fewer bytes". + data: Vec, + len: usize, +} + +impl NString { + pub fn new() -> Self { + Self::with_capacity(0) + } + + pub fn with_capacity(capacity: usize) -> Self { + Self { + data: vec![0; capacity + 1], + len: 0, + } + } + + pub fn push_ascii_str(&mut self, s: &str) -> Result<(), Error> { + check_ascii(s.as_bytes())?; + let new_len = self.len + s.len(); + // Resize, but only up. + self.data + .resize(std::cmp::max(self.data.len(), new_len + 1), 0); + self.data[self.len..new_len].clone_from_slice(s.as_bytes()); + self.len = new_len; + Ok(()) + } + + #[inline] + pub fn as_str(&self) -> &str { + unsafe { std::str::from_utf8_unchecked(&self.data[..self.len]) } + } + + /// Convert to [UnsafeNString] for ues with C. + pub fn into_unsafe(self) -> UnsafeNString { + UnsafeNString(self) + } +} + +impl Default for NString { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Debug for NString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(f) + } +} + +impl std::ops::Deref for NString { + type Target = str; + #[inline] + fn deref(&self) -> &str { + self.as_str() + } +} + +impl std::ops::DerefMut for NString { + #[inline] + fn deref_mut(&mut self) -> &mut str { + unsafe { std::str::from_utf8_unchecked_mut(&mut self.data[..self.len]) } + } +} + +impl std::fmt::Display for NString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(f) + } +} + +/// Form of [NString] for use with C. +pub struct UnsafeNString(NString); + +impl UnsafeNString { + /// Return the mutable pointer to unsigned char. + pub fn mut_ptr(&mut self) -> *mut std::os::raw::c_uchar { + self.0.data.as_mut_ptr() as *mut std::os::raw::c_uchar + } + + /// Convert back to [NString]. Unsafe because we can't know what whether your C library stomped + /// all over everything. `len` is the length of the string excluding the null byte. `len` + /// obviously MUST be less than the capacity of the underlying [NString], otherwise you've + /// overrun the buffer! + /// # Safety + /// Safe only if caller avoids writing past the end of the buffer. + pub unsafe fn to_nstring(self, len: usize) -> Result { + to_nstring(self, len) + } + + /// Convert back to [NString]. Unsafe because we can't know what whether your C library stomped + /// all over everything. In this case, you promise to have placed a null byte after your + /// string, and we will find it and use its offset as the string length. + /// + /// Doing this is discouraged; you probably already knew the length and lost it, and would be + /// better off fixing that lossage. + /// # Safety + /// Safe only if caller includes a null byte and avoids writing past the end of the buffer. + pub unsafe fn to_nstring_sized_by_null_byte(self) -> Result { + to_nstring_size_by_null_byte(self) + } +} + +pub enum OrString { + N(NString), + S(String), +} + +impl std::ops::Deref for OrString { + type Target = str; + #[inline] + fn deref(&self) -> &str { + match self { + Self::N(ns) => ns, + Self::S(s) => s, + } + } +} + +impl std::fmt::Debug for OrString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::N(ns) => f.debug_tuple("NString").field(ns).finish(), + Self::S(s) => f.debug_tuple("String").field(s).finish(), + } + } +} + +impl std::fmt::Display for OrString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self) + } +} + +impl From for OrString { + fn from(s: String) -> Self { + Self::S(s) + } +} + +impl From<&str> for OrString { + fn from(s: &str) -> Self { + Self::S(s.to_owned()) + } +} + +/// See module documentation. +/// WARNING: This frees the buffer, so only use when caller owns! +/// Don't reuse in order to avoid memory leak; in fact, this panics if you attempt reuse. +pub struct NStringWithFree { + pointer: *mut std::os::raw::c_char, + accessed: bool, +} + +impl NStringWithFree { + pub fn new() -> Self { + Self { + pointer: std::ptr::null::<*mut std::os::raw::c_char>() as *mut std::os::raw::c_char, + accessed: false, + } + } + + pub fn pointer(&mut self) -> *mut *mut std::os::raw::c_char { + assert!( + !self.accessed, + "reusing NStringWithFree risks leaking the buffer" + ); + self.accessed = true; + &mut self.pointer as *mut *mut _ as *mut *mut std::os::raw::c_char + } +} + +impl Default for NStringWithFree { + fn default() -> Self { + Self::new() + } +} + +impl std::convert::TryFrom for String { + type Error = Error; + fn try_from(value: NStringWithFree) -> Result { + if value.pointer.is_null() { + return Err(Error::new( + "BUG: TryFrom for String buffer pointer is null".to_owned(), + )); + } + unsafe { std::ffi::CStr::from_ptr(value.pointer) } + .to_str() + .map_err(Error::wrap) + .map(|v| v.to_owned()) + } +} + +impl Drop for NStringWithFree { + fn drop(&mut self) { + let void = self.pointer as *mut std::os::raw::c_void; + unsafe { crate::sbr::free(void) } + } +} + +fn check_ascii(bytes: &[u8]) -> Result<(), Error> { + let as_str: &str = unsafe { std::str::from_utf8_unchecked(bytes) }; + if !as_str.is_ascii() { + Err(Error::new(format!("not ASCII: {}", as_str))) + } else { + Ok(()) + } +} + +fn to_nstring(mut rs: UnsafeNString, len: usize) -> Result { + assert!( + len < rs.0.data.len(), + "{} >= {}; buffer overrun likely", + len, + rs.0.data.len() + ); + rs.0.len = len; + check_ascii(&rs.0.data[..rs.0.len])?; + Ok(rs.0) +} + +fn to_nstring_size_by_null_byte(rs: UnsafeNString) -> Result { + let len: usize = + rs.0.data + .iter() + .position(|c| *c == 0) + .expect("UnsafeNString lacks null byte; buffer overrun likely"); + to_nstring(rs, len) +} + +#[cfg(test)] +mod tests; diff --git a/sbr/rust/src/str/tests.rs b/sbr/rust/src/str/tests.rs new file mode 100644 index 00000000..818b318b --- /dev/null +++ b/sbr/rust/src/str/tests.rs @@ -0,0 +1,28 @@ +use super::*; + +mod nstring { + use super::*; + + /// Test [NString] starting out holding N bytes, and then being shortened to fewer bytes. + #[test] + fn long_then_short() { + let mut ns = NString::new(); + ns.push_ascii_str("this many bytes").unwrap(); + let mut raw = ns.into_unsafe(); + sprintf(raw.mut_ptr(), b"fewer bytes\0"); + ns = unsafe { raw.to_nstring_sized_by_null_byte() }.unwrap(); + // The reachable part of the NString is short. + assert_eq!("fewer bytes", ns.as_str()); + // The stale bytes remain untouched but unaccessed in the underlying buffer. + assert_eq!(b"fewer bytes\0tes\0", &ns.data[..]); + } +} + +fn sprintf(format: *mut std::os::raw::c_uchar, s: &[u8]) { + unsafe { + libc::sprintf( + format as *mut std::os::raw::c_char, + s.as_ptr() as *const std::os::raw::c_char, + ) + }; +} diff --git a/sbr/rust/tests/base64/mod.rs b/sbr/rust/tests/base64/mod.rs new file mode 100644 index 00000000..9c7caa1c --- /dev/null +++ b/sbr/rust/tests/base64/mod.rs @@ -0,0 +1,21 @@ +use nmh::base64::encode_raw; + +const DECODED: &'static [u8] = b"some bytes\nand lines\r\nbut mixed up"; +const DECODED_EMBEDDED_NULL: &'static [u8] = + b"some bytes\nand lines\r\nbut mixed up\0was that a null"; +static ENCODED: &str = "c29tZSBieXRlcwphbmQgbGluZXMNCmJ1dCBtaXhlZCB1cA=="; +static ENCODED_EMBEDDED_NULL: &str = + "c29tZSBieXRlcwphbmQgbGluZXMNCmJ1dCBtaXhlZCB1cAB3YXMgdGhhdCBhIG51bGw="; + +#[test] +fn round_trip_encode_raw() { + assert_eq!(encode_raw(DECODED).as_str(), ENCODED); +} + +#[test] +fn round_trip_embedded_null() { + assert_eq!( + encode_raw(DECODED_EMBEDDED_NULL).as_str(), + ENCODED_EMBEDDED_NULL + ); +} diff --git a/sbr/rust/tests/client/mod.rs b/sbr/rust/tests/client/mod.rs new file mode 100644 index 00000000..471b5965 --- /dev/null +++ b/sbr/rust/tests/client/mod.rs @@ -0,0 +1,52 @@ +use std::error::Error; + +use nmh::client::*; + +#[test] +fn server_null_byte() { + let e = Connector::new() + .service("imap") + .unwrap() + .server("\0") + .expect_err("need error"); + let source = e.source().expect(&format!("need source in {:?}", e)); + assert_eq!( + 0, + (*source) + .downcast_ref::() + .expect(&format!("need NulError: {:?}", source)) + .nul_position() + ) +} + +#[test] +fn service_null_byte() { + let e = Connector::new() + .server("localhost") + .unwrap() + .service("\0") + .expect_err("need error"); + let source = e.source().expect(&format!("need source in {:?}", e)); + assert_eq!( + 0, + (*source) + .downcast_ref::() + .expect(&format!("need NulError: {:?}", source)) + .nul_position() + ) +} + +#[test] +fn empty_strings() { + assert_eq!( + "Internal error: NULL server name passed to client()", + Connector::new() + .server("") + .unwrap() + .service("") + .unwrap() + .connect() + .expect_err("need error") + .to_string() + ) +} diff --git a/sbr/rust/tests/message/mod.rs b/sbr/rust/tests/message/mod.rs new file mode 100644 index 00000000..d1ae0d70 --- /dev/null +++ b/sbr/rust/tests/message/mod.rs @@ -0,0 +1,47 @@ +#[test] +fn option_no_overhead() { + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::>() + ); +} + +#[test] +fn max() { + nmh::message::Number::from(std::num::NonZeroI32::new(i32::MAX).unwrap()); +} + +#[test] +#[should_panic(expected = "attempt to add with overflow")] +fn increment_max() { + let mut n = nmh::message::Number::from(std::num::NonZeroI32::new(i32::MAX).unwrap()); + n.increment(); +} + +#[test] +fn clone() { + let n = nmh::message::Number::one(); + assert_eq!(n, n.clone()); +} + +#[test] +fn display() { + assert_eq!("1", format!("{}", nmh::message::Number::one())); +} + +#[test] +fn debug() { + assert_eq!("Number(1)", format!("{:?}", nmh::message::Number::one())); +} + +#[test] +fn eq() { + let a = nmh::message::Number::one(); + let b = nmh::message::Number::from(std::num::NonZeroI32::new(2).unwrap()); + assert!(a == a); + assert!(!(a == b)); + assert!(!(a != a)); + assert!(a != b); +} + +mod set; diff --git a/sbr/rust/tests/message/set.rs b/sbr/rust/tests/message/set.rs new file mode 100644 index 00000000..b55e5291 --- /dev/null +++ b/sbr/rust/tests/message/set.rs @@ -0,0 +1,71 @@ +use nmh::message::Set; + +mod display { + use super::*; + + #[test] + fn one() { + let mut set = Set::new(); + set.insert(1); + assert_eq!("1", &format!("{}", set)) + } + + #[test] + fn some() { + let mut set = Set::new(); + set.insert(1); + set.insert(2); + set.insert(3); + set.insert(10); + set.insert(15); + set.insert(16); + set.insert(17); + assert_eq!("1-3 10 15-17", &format!("{}", set)) + } + + #[test] + fn all() { + assert_eq!("1-2147483647", &format!("{}", Set::all())) + } +} + +mod all { + use super::*; + + #[test] + fn append_some_to_all() { + let mut all = Set::all(); + let mut one = Set::new(); + one.insert(1); + all.append(&mut one); + assert_eq!("1-2147483647", &format!("{}", all)) + } + + #[test] + fn append_all_to_some() { + let mut all = Set::all(); + let mut one = Set::new(); + one.insert(1); + one.append(&mut all); + assert_eq!("1-2147483647", &format!("{}", one)) + } + + #[test] + fn insert_to_all() { + let mut all = Set::all(); + all.insert(1); + assert_eq!("1-2147483647", &format!("{}", all)) + } + + #[test] + fn clone_without() { + let mut some = Set::new(); + some.insert(1); + assert!(some.clone_without(&Set::all()).is_empty()) + } + + #[test] + fn is_empty() { + assert!(!Set::all().is_empty()) + } +} diff --git a/sbr/rust/tests/netsec/mod.rs b/sbr/rust/tests/netsec/mod.rs new file mode 100644 index 00000000..0944b84e --- /dev/null +++ b/sbr/rust/tests/netsec/mod.rs @@ -0,0 +1,71 @@ +use std::error::Error; +use std::ffi::{CString, NulError}; + +use nmh::Netsec; + +mod builder { + use super::*; + + #[test] + fn timeout_out_of_range() { + let e = Netsec::builder() + .timeout(std::time::Duration::from_secs(u64::MAX)) + .expect_err("need error"); + let source = e.source().expect(&format!("need source in {:?}", e)); + assert!( + (*source) + .downcast_ref::() + .is_some(), + "need TryFromIntError: {:?}", + e + ) + } + + #[test] + fn userid_with_nulls() { + let e = Netsec::builder() + .host(&CString::new("a hostname").unwrap()) + .sasl_userid("oh\0null") + .expect_err("need error"); + let source = e.source().expect(&format!("need source in {:?}", e)); + assert_eq!( + 2, + (*source) + .downcast_ref::() + .expect(&format!("need NulError: {:?}", source)) + .nul_position() + ) + } + + #[test] + fn sasl_service_with_nulls() { + let e = Netsec::builder() + .host(&CString::new("a hostname").unwrap()) + .sasl_service("oh\0null") + .expect_err("need error"); + let source = e.source().expect(&format!("need source in {:?}", e)); + assert_eq!( + 2, + (*source) + .downcast_ref::() + .expect(&format!("need NulError: {:?}", source)) + .nul_position() + ) + } + + #[test] + fn user_preferred_sasl_mechanism_with_nulls() { + let e = Netsec::builder() + .host(&CString::new("a hostname").unwrap()) + .sasl_prefer_mechanism("oh\0null") + .expect_err("need error"); + let source = e.source().expect(&format!("need source in {:?}", e)); + assert_eq!( + 2, + (*source) + .downcast_ref::() + .expect(&format!("need NulError: {:?}", source)) + .nul_position() + ) + } +} diff --git a/sbr/rust/tests/str/mod.rs b/sbr/rust/tests/str/mod.rs new file mode 100644 index 00000000..d936beae --- /dev/null +++ b/sbr/rust/tests/str/mod.rs @@ -0,0 +1,94 @@ +use std::convert::TryFrom as _; + +use nmh::str::*; + +mod nstring { + use super::*; + + #[test] + fn empty() { + assert!(NString::with_capacity(1).as_str().is_empty()); + } + + #[test] + fn capacity_zero_is_empty() { + assert!(NString::with_capacity(0).as_str().is_empty()); + } + + #[test] + fn push_str_non_ascii() { + let mut ns = NString::new(); + assert_eq!( + "not ASCII: ¯\\_(ツ)_/¯", + ns.push_ascii_str(SHRUG).unwrap_err().to_string() + ); + } + + #[test] + fn sprintf_non_ascii() { + let mut shrug = SHRUG.as_bytes().to_vec(); + shrug.push(0); + let ns = NString::with_capacity(32); + let mut raw = ns.into_unsafe(); + sprintf(raw.mut_ptr(), &shrug); + assert_eq!( + "not ASCII: ¯\\_(ツ)_/¯", + unsafe { raw.to_nstring_sized_by_null_byte() } + .unwrap_err() + .to_string() + ); + } + + #[test] + #[should_panic(expected = "UnsafeNString lacks null byte; buffer overrun likely")] + fn missing_null_byte() { + let ns = NString::with_capacity(0); + let mut raw = ns.into_unsafe(); + unsafe { + *(raw.mut_ptr()) = 1; + raw.to_nstring_sized_by_null_byte().unwrap(); + } + } + + #[test] + #[should_panic(expected = "10 >= 1; buffer overrun likely")] + fn too_many_bytes() { + let ns = NString::with_capacity(0); + let raw = ns.into_unsafe(); + unsafe { raw.to_nstring(10).unwrap() }; + } + + #[test] + fn deref_mut() { + let mut ns = NString::new(); + ns.push_ascii_str("foo").unwrap(); + let mut_str: &mut str = &mut ns; + mut_str.make_ascii_uppercase(); + assert_eq!("FOO", ns.as_str()); + } + + mod with_free { + use super::*; + + #[test] + fn to_string_with_null() { + assert_eq!( + "BUG: TryFrom for String buffer pointer is null", + String::try_from(NStringWithFree::new()) + .unwrap_err() + .to_string(), + ); + } + } +} + +const SHRUG: &str = "¯\\_(ツ)_/¯"; + +fn sprintf(format: *mut std::os::raw::c_uchar, s: &[u8]) { + unsafe { + libc::sprintf( + format as *mut std::os::raw::c_char, + s.as_ptr() as *const std::os::raw::c_char, + ) + }; +} diff --git a/sbr/rust/tests/tests.rs b/sbr/rust/tests/tests.rs new file mode 100644 index 00000000..508f3942 --- /dev/null +++ b/sbr/rust/tests/tests.rs @@ -0,0 +1,5 @@ +mod base64; +mod client; +mod message; +mod netsec; +mod str; diff --git a/sbr/seq_read.c b/sbr/seq_read.c index a3c4e0d6..35639264 100644 --- a/sbr/seq_read.c +++ b/sbr/seq_read.c @@ -182,7 +182,7 @@ seq_init (struct msgs *mp, char *name, char *field) { unsigned int i; int j, k, is_current; - char *cp, **ap; + char *cp; /* * Check if this is "cur" sequence, @@ -217,7 +217,7 @@ seq_init (struct msgs *mp, char *name, char *field) /* * Split up the different message ranges at whitespace */ - for (ap = brkstring (field, " ", "\n"); *ap; ap++) { + for (char *const *ap = brkstring (field, " ", "\n"); *ap; ap++) { if ((cp = strchr(*ap, '-'))) *cp++ = '\0'; if ((j = m_atoi (*ap)) > 0) { diff --git a/sbr/seq_setprev.c b/sbr/seq_setprev.c index 89b052e1..60c01fa4 100644 --- a/sbr/seq_setprev.c +++ b/sbr/seq_setprev.c @@ -21,25 +21,25 @@ */ void -seq_setprev (struct msgs *mp) +seq_setprev (struct msgs *const mp) { - char **ap, *cp, *dp; - /* * Get the list of sequences for Previous-Sequence * and split them. */ - if (!(cp = context_find(psequence))) + const char *const maybe_previous_sequences = context_find(psequence); + if (maybe_previous_sequences == NULL) return; - dp = mh_xstrdup(cp); - if (!(ap = brkstring(dp, " ", "\n")) || !*ap) { - free(dp); + char *const previous_sequences = mh_xstrdup(maybe_previous_sequences); + char *const *broken = brkstring(previous_sequences, " ", "\n"); + if (broken == NULL || *broken == NULL) { + free(previous_sequences); return; } /* Now add all SELECTED messages to each sequence */ - for (; *ap; ap++) - seq_addsel (mp, *ap, -1, 1); + for (; *broken; broken++) + seq_addsel (mp, *broken, -1, 1); - free (dp); + free (previous_sequences); } diff --git a/sbr/seq_setunseen.c b/sbr/seq_setunseen.c index bcad3f18..c5ce6e76 100644 --- a/sbr/seq_setunseen.c +++ b/sbr/seq_setunseen.c @@ -24,20 +24,19 @@ */ void -seq_setunseen (struct msgs *mp, int seen) +seq_setunseen (struct msgs *const mp, const int seen) { - int msgnum; - char **ap, *cp, *dp; - /* * Get the list of sequences for Unseen-Sequence * and split them. */ - if (!(cp = context_find (usequence))) + const char*const maybe_unseen_sequences = context_find (usequence); + if (maybe_unseen_sequences == NULL) return; - dp = mh_xstrdup(cp); - if (!(ap = brkstring (dp, " ", "\n")) || !*ap) { - free (dp); + char *const unseen_sequences = mh_xstrdup(maybe_unseen_sequences); + char *const *broken = brkstring (unseen_sequences, " ", "\n"); + if (broken == NULL || *broken == NULL) { + free (unseen_sequences); return; } @@ -45,19 +44,19 @@ seq_setunseen (struct msgs *mp, int seen) * Now add/delete each message which has the SELECT_UNSEEN * bit set to/from each of these sequences. */ - for (; *ap; ap++) { + for (; *broken; broken++) { if (seen) { /* make sure sequence exists first */ - if (seq_getnum(mp, *ap) != -1) - for (msgnum = mp->lowsel; msgnum <= mp->hghsel; msgnum++) + if (seq_getnum(mp, *broken) != -1) + for (int msgnum = mp->lowsel; msgnum <= mp->hghsel; msgnum++) if (is_unseen (mp, msgnum)) - seq_delmsg (mp, *ap, msgnum); + seq_delmsg (mp, *broken, msgnum); } else { - for (msgnum = mp->lowmsg; msgnum <= mp->hghmsg; msgnum++) + for (int msgnum = mp->lowmsg; msgnum <= mp->hghmsg; msgnum++) if (is_unseen (mp, msgnum)) - seq_addmsg (mp, *ap, msgnum, -1, 0); + seq_addmsg (mp, *broken, msgnum, -1, 0); } } - free (dp); + free (unseen_sequences); } diff --git a/sbr/showfile.c b/sbr/showfile.c index 7daadaed..fb5d9f4f 100644 --- a/sbr/showfile.c +++ b/sbr/showfile.c @@ -14,7 +14,7 @@ int -showfile (char **arg, char *file) +showfile (char *const *arg, char *const file) { pid_t pid; int vecp; diff --git a/sbr/showfile.h b/sbr/showfile.h index 8ff12b8c..08b08cd7 100644 --- a/sbr/showfile.h +++ b/sbr/showfile.h @@ -4,4 +4,4 @@ * COPYRIGHT file in the root directory of the nmh distribution for * complete copyright information. */ -int showfile(char **, char *); +int showfile(char *const *, char *); diff --git a/sbr/vector.c b/sbr/vector.c index d0ebc336..85b5a839 100644 --- a/sbr/vector.c +++ b/sbr/vector.c @@ -211,12 +211,20 @@ svector_at (svector_t vec, size_t i) return vec->strs[i]; } +char * +svector_read_at (const struct svector *const vec, size_t i) +{ + if (i >= vec->maxsize) + return NULL; + return vec->strs[i]; +} + /* * Return address of first element that stringwise matches s. * The caller can replace the contents of the return address. */ char ** -svector_find (svector_t vec, const char *s) +svector_find (const struct svector *const vec, const char *s) { size_t i; char **str = vec->strs; @@ -231,13 +239,13 @@ svector_find (svector_t vec, const char *s) } char ** -svector_strs (svector_t vec) +svector_strs (const struct svector *const vec) { return vec->strs; } size_t -svector_size (svector_t vec) +svector_size (const struct svector *const vec) { return vec->size; } diff --git a/sbr/vector.h b/sbr/vector.h index fc03a2dc..e0afd0b3 100644 --- a/sbr/vector.h +++ b/sbr/vector.h @@ -32,9 +32,10 @@ svector_t svector_create(size_t); void svector_free(svector_t) NONNULL(1); char *svector_push_back(svector_t, char *) NONNULL(1); char *svector_at(svector_t, size_t) NONNULL(1); -char **svector_find(svector_t, const char *) NONNULL(1) PURE; -char **svector_strs(svector_t) NONNULL(1) PURE; -size_t svector_size(svector_t) NONNULL(1) PURE; +char *svector_read_at(const struct svector *, size_t) NONNULL(1); +char **svector_find(const struct svector *, const char *) NONNULL(1) PURE; +char **svector_strs(const struct svector *) NONNULL(1) PURE; +size_t svector_size(const struct svector *) NONNULL(1) PURE; typedef struct ivector *ivector_t; diff --git a/test/common.sh.in b/test/common.sh.in index d3875a39..cd5cb051 100644 --- a/test/common.sh.in +++ b/test/common.sh.in @@ -1,6 +1,7 @@ # Common helper routines for test shell scripts -- to be sourced by them # @configure_input@ +exec < /dev/null #### The following variables are set by "make check". Ensure #### that they are set here so that individual tests can be run @@ -34,6 +35,7 @@ test -z "$CURL_USER_AGENT" && CURL_USER_AGENT="@CURL_USER_AGENT@" HOME="$MH_TEST_DIR" export HOME +unset EDITOR unset MAILDROP MHBUILD MHCONTEXT MHMTSUSERCONF MHN MHSHOW MHSTORE unset MHLDEBUG MHWDEBUG PAGER XOAUTH SMTPUTF8 #### Set LC_ALL in individual tests as needed. Unset these so @@ -189,6 +191,16 @@ prepare_space() { sed 's/'"`printf '\\302\\240'`"'/ /g' } +#### fail increments the global variable "failed" and return non-zero. +#### Any parameters are passed to echo. +fail() { + if [ x"$1" != x ]; then + echo "$@" + fi + failed=`expr ${failed:-0} + 1` + false +} + #### check() requires two arguments, each the name of a file to be #### diff'ed. #### If the contents are same, the second file is removed. If different, @@ -232,10 +244,7 @@ check() { #### POSIX diff should support -c. diff -c "$first" "$second" || true echo - echo "$0: $label failed, outputs are in $first and $second." - failed=`expr ${failed:-0} + 1` - #### Set return status of the function. - [ $failed -eq 0 ] + fail "$0: $label failed, outputs are in $first and $second." fi } @@ -341,6 +350,7 @@ cleanup() { #### cd to $MH_TEST_DIR before trying to remove its Mail #### subdirectory. rm on Solaris won't remove it if it's in the #### path of the current working directory. + # TODO multi-profile? test -z "$MH_TEST_NOCLEANUP" && (cd "$MH_TEST_DIR" && rm -rf "$MH_TEST_DIR"/Mail) #### Report test name if set, which indicates failure. @@ -364,6 +374,7 @@ cleanup() { #### Function to set the test name, and whatever the future brings. start_test() { + [ -n "$MH_TEST_VERBOSE" ] && echo "START TEST $1" #### run_test disables exit on non-zero status, but does increment #### failed. Don't overwrite nmh_tests_testname if there was a #### failure; remember the first test that failed. @@ -378,14 +389,21 @@ finish_test() { [ ${failed:-0} -eq 0 ] && unset nmh_tests_testname } -setup_test () +set_mh_profile() { - set -e + profile="$1" + MH="${MH_TEST_DIR}/$profile/.mh_profile" + MHMTSCONF="${MH_INST_DIR}${nmhetcdir}/mts.conf" + MHTMPDIR="$MH_TEST_DIR/$profile" + MH_LIBEXEC_DIR="${MH_INST_DIR}${nmhlibexecdir}" + export MH MHMTSCONF MHTMPDIR MH_LIBEXEC_DIR +} - MH="${MH_TEST_DIR}/Mail/.mh_profile" - MHMTSCONF="${MH_INST_DIR}${nmhetcdir}/mts.conf" - MH_LIBEXEC_DIR="${MH_INST_DIR}${nmhlibexecdir}" - export MH MHMTSCONF MH_LIBEXEC_DIR +setup_test_without_data () +{ + set -e + set_mh_profile "$1" + profiles="$profiles $profile" # # Only install once @@ -394,8 +412,7 @@ setup_test () : else (cd "${MH_OBJ_DIR}" && - make DESTDIR="${MH_INST_DIR}" SETGID_MAIL= install) || - exit 1 + make DESTDIR="${MH_INST_DIR}" SETGID_MAIL= install) #### Don't test with sendmail because it would really send the #### mail. If configured to use sendmail, change to smtp instead @@ -405,11 +422,12 @@ setup_test () #### test-slocal needs to look at the original mts.conf, so save it. mv -f "${MHMTSCONF}" "${MHMTSCONF}.old" sed -e 's/mts: *.*/mts: smtp/' \ - -e "s%mmdfldir: *.*%mmdfldir: ${MH_TEST_DIR}/Mail%" \ + -e "s%mmdfldir: *.*%mmdfldir: ${MH_TEST_DIR}/$1%" \ -e 's%mmdflfil: *.*%mmdflfil: maildrop%' \ "${MHMTSCONF}.old" >"${MHMTSCONF}" fi + # TODO we prepend it again for every new profile... #### On Solaris, must set PATH after the install! PATH="${MH_INST_DIR}${bindir}:${PATH}" export PATH @@ -417,10 +435,12 @@ setup_test () # clean old test data on exit trap cleanup 0 - # setup test data - mkdir -p "$MH_TEST_DIR/Mail" || exit 1 - cat > "$MH" < "$MH" < /dev/null #### Suppress warnings about a new version. #### mhparam version includes the nmh- prefix, so be consistent with that. - printf 'Version: nmh-%s\n' "${MH_VERSION}" >> "${MH_TEST_DIR}"/Mail/context + printf 'Version: nmh-%s\n' "${MH_VERSION}" >> "${MH_TEST_DIR}/$1/context" # create 10 basic messages for i in 1 2 3 4 5 6 7 8 9 10; do - cat > $MH_TEST_DIR/Mail/inbox/$i < "$MH_TEST_DIR/$1/inbox/$i" < To: Some User Date: Fri, 29 Sep 2006 00:00:00 @@ -468,3 +493,49 @@ This is message number $i EOF done } + +setup_test () +{ + set -e + + setup_test_without_data Mail + setup_test_data Mail +} + +### Start fakeinetd. +### All arguments are passed as-is to fakeinetd. +### If successul, returns 0 and sets parameters fakeinetd_pid and fakeinetd_port. +start_fakeinetd() { + fakeinetd_pid_port=$("$MH_OBJ_DIR/test/fakeinetd" "$@") || return + set -- $fakeinetd_pid_port + unset fakeinetd_pid_port + [ $# -eq 2 ] || return + fakeinetd_pid=$1 + fakeinetd_port=$2 +} + +# TODO autoconf +STUNNEL=stunnel + +### Start stunnel under fakeinetd. +### start_stunnel NAME EXEC +### See start_fakeinetd above. +start_stunnel() { + cat > "$MH_TEST_DIR/$1.stunnel.cf" <> "$MH_TEST_DIR/$1.stunnel.cf" + fi + cat > "$MH_TEST_DIR/$1.stunnel.sh" < ${MH_TEST_STUNNEL_OUTPUT:-/dev/null} +EOF + chmod 755 "$MH_TEST_DIR/$1.stunnel.sh" + start_fakeinetd "$MH_TEST_DIR/$1.stunnel.sh" +} diff --git a/test/comp/test-draft-fcc b/test/comp/test-draft-fcc new file mode 100755 index 00000000..17029175 --- /dev/null +++ b/test/comp/test-draft-fcc @@ -0,0 +1,205 @@ +#!/bin/sh + +if test -z "${MH_OBJ_DIR}"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "${MH_OBJ_DIR}/test/common.sh" + +setup_test + +cat >> "$MH" <> ${MHMTSCONF} + +cat > "$MH_TEST_DIR/Mail/scan.dateless" <%<(unseen)U%| %>%<{replied}-%| %>\ +%<(mymbox{from})%<{to}To:%(unquote(decode(friendly{to})))%>%>\ +%<(zero)%(unquote(decode(friendly{from})))%> \ +%(decode{subject})%<{body}<<%{body}>>%> +EOF + +sbackup="`mhparam sbackup`" + +arith_eval 64000 + $$ % 1000 +smtp_port=$arith_val + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +################################################################################ +start_test 'comp with Draft-Folder' + +check_exit '-eq 0' comp -noedit -to phyl@example.com -fcc sent -subject test -nowhatnowproc < /dev/null + +################################################################################ +start_test 'scan +drafts shows the draft' + +cat > "$expected" < "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'send with Draft-Folder' + +pid=`"${MH_OBJ_DIR}/test/fakesmtp" /dev/null $smtp_port` + +if ! (echo y | check_exit '-eq 0' send -server 127.0.0.1 -port $smtp_port); then + failed=`expr ${failed:-0} + 1` +fi +kill $pid 2> /dev/null || : +[ ${failed:-0} -eq 0 ] + +################################################################################ +start_test 'drafts/.mh_sequences' + +cat > "$expected" < "$expected" < "$actual" 2>&1 || : +check "$actual" "$expected" + +################################################################################ +start_test 'scan +sent shows the sent message' + +cat > "$expected" < "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'drafts/,1.orig is the original draft' + +cat > "$expected" < "$expected" < "$expected" < "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'send -draftfolder' + +pid=`"${MH_OBJ_DIR}/test/fakesmtp" /dev/null $smtp_port` + +if ! check_exit '-eq 0' send -draftfolder drafts -server 127.0.0.1 -port $smtp_port < /dev/null; then + failed=`expr ${failed:-0} + 1` +fi +kill $pid 2> /dev/null || : +[ ${failed:-0} -eq 0 ] + +################################################################################ +start_test 'drafts/.mh_sequences' + +cat > "$expected" < "$expected" < "$actual" 2>&1 || : +check "$actual" "$expected" + +################################################################################ +start_test 'scan +sent shows the sent message now for send -draftfolder' + +cat > "$expected" < "$actual" +check "$actual" "$expected" + +################################################################################ +start_test "drafts/${sbackup}1.orig is the original draft now for send -draftfolder" + +cat > "$expected" < "$expected" < +#include +#include +#include + +#include +#include +#include +#include + +int +main(int argc, char *argv[]) +{ + if (close(0) < 0) { + perror("close(0)"); + return 1; + } + + // We're not interested in children; ignore this to avoid zombies. + signal(SIGCHLD, SIG_IGN); + + int const listening = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listening < 0) { + perror("socket"); + return 1; + } + + { + struct sockaddr_in sin = { 0 }; + int status = inet_pton(AF_INET, "127.0.0.1", &(sin.sin_addr)); + switch (status) { + case 1: + break; + case -1: + perror("inet_pton(127.0.0.1)"); + return 1; + case 0: + fputs("inet_pton rejected hard-coded 127.0.0.1?!\n", stderr); + return 1; + default: + fprintf(stderr, "inet_pton(127.0.0.1) nonstandard return %d\n", status); + return 1; + } + sin.sin_family = AF_INET; + if (bind(listening, (struct sockaddr *)&sin, sizeof sin) < 0) { + perror("bind"); + return 1; + } + } + + if (listen(listening, SOMAXCONN) < 0) { + perror("listen"); + return 1; + } + + setsid(); + switch (fork()) { + case -1: + perror("fork"); + return 1; + case 0: + break; + default: + return 0; + } + + { + struct sockaddr_in sin = { 0 }; + socklen_t slen = sizeof sin; + if (getsockname(listening, (struct sockaddr *)&sin, &slen) < 0) { + perror("getsockname"); + return 1; + } + if (printf("%llu %llu\n", (unsigned long long)getpid(), (unsigned long long)ntohs(sin.sin_port)) < 0) { + perror("printf"); + return 1; + } + if (fclose(stdout) != 0) { + perror("fclose(1)"); + return 1; + } + } + + argv++; + for (;;) { + int const connected = accept(listening, NULL, NULL); + if (connected < 0) { + if (errno == EINTR) + continue; + perror("accept"); + return 1; + } + switch (fork()) { + case -1: + perror("fork"); + return 1; + case 0: + if (dup2(connected, 0) < 0 || dup2(connected, 1) < 0) { + perror("dup2"); + return 1; + } + if (execvp(argv[0], argv) < 0) { + perror("execvp"); + return 1; + } + default: + if (close(connected) < 0) { + perror("close"); + return 1; + } + } + } +} diff --git a/test/imap/test-astring b/test/imap/test-astring new file mode 100755 index 00000000..670ab540 --- /dev/null +++ b/test/imap/test-astring @@ -0,0 +1,121 @@ +#!/bin/sh + +# Test astring handling. +# For now only in LIST response. +# Gmail encodes all mailbox names in quotes and embedded quotes like "foo \" bar" +# Panda encodes them using literals. + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +# TODO no need for xoauth in this test +. "${srcdir}/test/oauth/common.sh" + +# common.sh sets this to something crazy; did i do that?! +testname=${0#$srcdir/} + +# coverage notes +# ( +# set -ex +# . ~/.cargo/env +# covdir="$builddir/test/covdata/${MH_COVERAGE_PROFILE:-default}/test/cargo" +# install -d "$covdir" +# export LLVM_PROFILE_FILE="$covdir/%m-%p.profraw" +# export CARGO_TARGET_DIR=$builddir/target +# export RUSTC_BOOTSTRAP=1 +# export RUSTFLAGS="-Zinstrument-coverage" +# cargo t -- --test-threads=1 +# make cargo +# MH_IMAP_TEST_SERVER=panda make check 'TESTS=test/imap/test-astring' +# ) +#grcov .o/clang10 -s . --binary-path .o/clang10/target/debug/ -t html --branch --ignore-not-existing -o /coverage/default > covlog 2>&1 + +setup_test_without_data Mail + +setup_service +fake_creds <> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/Mail/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +# Test astring and nstring handling with some faked up but legal responses. +# I don't know whether any implementations return nil or quoted in these FETCH +# respones, but it is legal, so it's a fair test. +# TODO removed nstring testing for now + +cat > "$imapd" <> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +start_test 'unrecoverable error: BAD LIST' + +cat > "$imapd" < "$expected" < "$actual" 2>&1 || status=$? +check "$actual" "$expected" +(exit $status) && fail 'inc exited 0 on error' + +finish_test +exit ${failed:-0} diff --git a/test/imap/test-bad-fetch-body b/test/imap/test-bad-fetch-body new file mode 100755 index 00000000..ecb85be4 --- /dev/null +++ b/test/imap/test-bad-fetch-body @@ -0,0 +1,90 @@ +#!/bin/sh + +# TODO FIX uh oh this and the inc missing msg cases passed by accident after inc uses ESEARCH +# and now switching inc to leave mailbox open has broken it completely! + +# TODO figure out how to get a server to send a BAD response so we have an accurate fake + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +# common.sh sets this to something crazy; did i do that?! +testname=${0#$srcdir/} + +setup_test_without_data Mail + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + rm -f "$imapd" + + [ -n "$imapd_pid" ] && kill $imapd_pid + "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} +trap imap_cleanup 0 + +covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname" +install -d "$covdir" +export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" + +imapd="$MH_TEST_DIR/$$.imapd" +start_fakeinetd "$imapd" +imapd_pid=$fakeinetd_pid + +cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +start_test 'unrecoverable error: BAD FETCH' + +cat > "$imapd" <&2 + fail "BAD response not in $MH_TEST_DIR/$profile/imap.d/stderr" +fi + +finish_test +exit ${failed:-0} diff --git a/test/imap/test-folder-create-disallowed b/test/imap/test-folder-create-disallowed new file mode 100755 index 00000000..ae241c4d --- /dev/null +++ b/test/imap/test-folder-create-disallowed @@ -0,0 +1,159 @@ +#!/bin/sh + +# Errors: can't live with 'em can't live without 'em +# - Priorities: +# 0. db integrity +# 1. report comprehensible error to user +# 2. comprehensible log (good enough for a quality bug report) +# 3. clean close of IMAP connection +# - If db open: +# 1. clean close if at all possible +# 2. if not, report failure along with the original failure +# - If nmh connection open: +# 1. write blind error +# 2. if that write failed log but ignore +# 3. Else close the socket, log+ignore error here too +# - If IMAP connection is open: +# 1. write a blind LOGOUT +# 2. if that write failed log but ignore +# 3. Else close the socket, log+ignore error here too +# - If error is detected at layer that has both nmh and IMAP connection +# available, handle nmh first; we fight for the users +# - In any case, finally error!(it) +# Types: +# Environmental +# - read/write error +# - open error (except ENOENT) +# Action: report, log, clean shutdown +# Protocol +# - either the bytes received were bytes expected or they weren't; +# context for logging is useful, but we don't need fine-grained types +# - this goes for nmh-folder-manager and IMAP protocols +# Action: report, log, clean shutdown +# Invalid request +# A request is valid or not regardless of the state of the system. +# - E.g. +# - UID FETCH without mailbox open - protocol violation: EXAMINE first +# - UID FETCH fred - `fred` is not a valid UID +# - In theory, this maps to IMAP `BAD response code, but can we rely on it? +# Action: Internal error, below +# Illegal request +# A request must be valid before its legality may be judged. +# - E.g. +# - FETCH 10 on mailbox with 9 messages - NO such message +# - EXAMINE nosuchmailbox - NO mailbox by that name exists +# - In theory, this maps to IMAP `NO response code, but can we rely on it? +# - These SHOULD only be caused by user action, e.g. create folder that already exists. +# Action: report, log, NO SHUTDOWN +# Internal +# Action: report, log, clean shutdown + +testname=${0#$srcdir/} + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +rm -rf "$MH_TEST_DIR/panda" + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + [ -n "$imapd_pid" ] && kill $imapd_pid + + # TODO uh oops don't need two profiles for this one + for i in 1 2; do + set_mh_profile profile$i + "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + done + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} + +covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname" +install -d "$covdir" + +mkdir "$MH_TEST_DIR/panda" +mkdir "$MH_TEST_DIR/panda/test" + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD" +imapd_pid=$fakeinetd_pid + +start() { + rm -f "$MH_TEST_DIR/$profile/imap.d/socket" + status=0 + LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" "$MH_OBJ_DIR/target/debug/mhimap" imap start > "$actual" 2>&1 || status=$? +} + +# setup profile 2 and 1, leaving 1 as active profile +for i in 2 1; do + setup_test_without_data profile$i + trap imap_cleanup 0 + cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" + fi + start +done + +################################################################################ +start_test 'profile1: folder +imap/fold -create' +check_exit '-eq 0' folder +imap/fold -create + +################################################################################ +start_test 'profile2: folder +imap/fold -create' + +set_mh_profile profile2 + +# TODO Oops folder -create when folder is already known LOCALLY to exist tries to create over IMAP! + +cat > "$expected" < "$actual" 2>&1 || exitcode=$? +check "$actual" "$expected" +if [ $exitcode -ne 1 ]; then + fail "folder exited $exitcode rather than 1" +fi + +################################################################################ +start_test 'profile2: inc +imap leaves imap/fold SELECTed' +inc +imap + +################################################################################ +start_test 'profile1: rmf +imap/fold fails because profile1 has it SELECTED' + +set_mh_profile profile1 + +rmf +imap/fold -nointeractive || status=$? +if ! grep -q 'NO Mailbox test/fold is in use by another process' "$MH_TEST_DIR/$profile/imap.d/stderr"; then + cat "$MH_TEST_DIR/$profile/imap.d/stderr" >&2 + fail 'mailbox deletion not disallowed' +fi + +finish_test +exit ${failed:-0} diff --git a/test/imap/test-gmail-all b/test/imap/test-gmail-all new file mode 100755 index 00000000..5a5fedf0 --- /dev/null +++ b/test/imap/test-gmail-all @@ -0,0 +1,61 @@ +#!/bin/sh + +# TODO this must have bitrotted to oblivion... + +testname=${0#$srcdir/} + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +setup_test_without_data Mail + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} +trap imap_cleanup 0 + +# * LIST (\All \HasNoChildren) "/" "[Gmail]/All Mail" +cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname" +install -d "$covdir" +export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" + +start_test 'inc All Mail' +inc +imap +finish_test +exit ${failed:-0} diff --git a/test/imap/test-inc b/test/imap/test-inc new file mode 100755 index 00000000..ce6adc93 --- /dev/null +++ b/test/imap/test-inc @@ -0,0 +1,476 @@ +#!/bin/sh + +testname=${0#$srcdir/} + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +rm -rf "$MH_TEST_DIR/panda" + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + [ -n "$imapd_pid" ] && kill $imapd_pid + + for profile in $profiles; do + set_mh_profile $profile + "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + done + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} + +mkdir "$MH_TEST_DIR/panda" +mkdir "$MH_TEST_DIR/panda/test" + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +# TODO duplicated with test-scan +arith_val=1 +message_count=11 +cat > "$MH_TEST_DIR/panda/test/fold" < "$actual" 2>&1 || status=$? +} + +covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname" +install -d "$covdir" + +# setup profile 2 and 1, leaving 1 as active profile +for i in 3 2 1; do + setup_test_without_data profile$i + trap imap_cleanup 0 + + # TODO Stop using mailbox-root in all but live tests once we have a local + # panda hacked never to look at real mailboxes. + cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" + fi +done + +# 4 messages in profile 1 +echo 'imap-max-fetch-messages-in-flight: 4' >> "$MH" +start + +################################################################################ +start_test 'inc' + +echo 'Incorporating new mail into imap/fold...' > "$expected.inc.full" +echo >> "$expected.inc.full" +echo " $arith_val 10/24 me <>" >> "$expected.inc.full" + +while [ $arith_val -lt $message_count ]; do + arith_eval $arith_val + 1 + printf ' %2d U 10/24 me <>\n' $arith_val >> "$expected.inc.full" + cat >> $MH_TEST_DIR/panda/test/fold <>\n' $arith_val >> "$expected.inc.full" + cat >> $MH_TEST_DIR/panda/test/fold < "$actual" +check "$actual" "$expected.inc" + +################################################################################ +start_test 'inc after inc finds no new mail' + +cat /dev/null > "$expected.inc" +inc +imap -form scan.unseen -width 80 > "$actual" +check "$actual" "$expected.inc" + +################################################################################ +start_test 'inc after inc finds new mail' + +arith_eval $arith_val + 1 + cat >> $MH_TEST_DIR/panda/test/fold < "$expected.inc" +echo >> "$expected.inc" +printf ' %2d U 10/24 me <>\n' $arith_val >> "$expected.inc" + +# Kill the folder manager so inc will cause a re-SELECT. +"$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + +inc +imap -form scan.unseen -width 80 > "$actual" +check "$actual" "$expected.inc" + +################################################################################ +start_test 'child of excluded mailbox excluded' + +mkdir -p "$MH_TEST_DIR/panda/test/excluded" +cat > "$MH_TEST_DIR/panda/test/excluded/fold" < "$expected" +inc +imap > "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'explicitly included child of excluded mailbox included' + +cp "$MH_TEST_DIR/panda/test/excluded/fold" "$MH_TEST_DIR/panda/test/excluded/re-included" + +cat > "$expected" <> +EOF +inc +imap > "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'explicitly requested child of excluded mailbox included' + +cp "$MH_TEST_DIR/panda/test/excluded/fold" "$MH_TEST_DIR/panda/test/excluded/still-excluded" + +cat > "$expected" <> +EOF +inc +imap/excluded/still-excluded > "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'fetch messages all fit in first FETCH UID' + +set_mh_profile profile2 + +# 12 messages so go for 11 per fetch. +# This hits the '1d. no more UIDs to FETCH but we are still expecting FETCH UID responses from that initial fetch' case in do_inc. +echo 'imap-max-fetch-messages-in-flight: 11' >> "$MH" +start + +( + sed 's/ U / /; s| 1 10/24 me| 1 10/24 me|' "$expected.inc.full" + echo ' 12 10/24 me <>' +) > "$expected.inc" + +inc +imap/fold > "$actual" +check "$actual" "$expected.inc" + +################################################################################ +start_test '1d. just one more UID to FETCH' + +set_mh_profile profile3 + +cat >> $MH_TEST_DIR/panda/test/fold <> "$MH" +start + +( + sed 's/ U / /; s| 1 10/24 me| 1 10/24 me|' "$expected.inc.full" + echo ' 12 10/24 me <>' + echo ' 13 10/24 me <>' +) > "$expected.inc" +rm -f "$expected.inc.full" + +inc +imap/fold > "$actual" +check "$actual" "$expected.inc" + +################################################################################ +start_test 'one message in page 2' + +set_mh_profile profile1 + +# Still using max-fetch-messages-in-flight:4 +# The first FETCH UID requests 1:8 (pages 1 and 2). +# We provide a mailbox with messages 1-9. + +genmailbox() { + cat < $MH_TEST_DIR/panda/test/omp2 + +cat > "$expected.inc" <> + 2 U 10/24 me <> + 3 U 10/24 me <> + 4 U 10/24 me <> + 5 U 10/24 me <> + 6 U 10/24 me <> + 7 U 10/24 me <> + 8 U 10/24 me <> + 9 U 10/24 me <> + 10 U 10/24 me <> + 11 U 10/24 me <> + 12 U 10/24 me <> + 13 U 10/24 me <> +EOF + +inc +imap/omp2 -form scan.unseen -width 80 > "$actual" || : +check "$actual" "$expected.inc" + +################################################################################ +start_test 'UID gap with one remaining' + +set_mh_profile profile3 + +# Still using max-fetch-messages-in-flight:6 +# The first FETCH UID requests 1:12. +# We provide a mailbox with a UID gap though; only these are present: +# 1, 3, 13 + +cat > $MH_TEST_DIR/panda/test/uidgap1 < "$expected.inc" <> + 2 U 10/24 me <> + 3 U 10/24 me <> +EOF + +inc +imap/uidgap1 -form scan.unseen -width 80 > "$actual" || : +check "$actual" "$expected.inc" + +################################################################################ +start_test 'UID gap with two remaining' + +set_mh_profile profile3 + +# Still using max-fetch-messages-in-flight:6 +# The first FETCH UID requests 1:12. +# We provide a mailbox with many UID gaps though; only these are present: +# 1, 3, 20, 30 +# oh hey! what happens when there's HUGE gaps?! +# we'll keep making dumb requests for sequences like 13:18 for no gain, as this test demonstrates +# is there a better way to discover UIDs? +# even if there is, these tests are worth having +# nothing obvious. we can move the FLAGS request to the FETCH BODY though + +cat > $MH_TEST_DIR/panda/test/uidgap2 < "$expected.inc" <> + 2 U 10/24 me <> + 3 U 10/24 me <> + 4 U 10/24 me <> +EOF + +inc +imap/uidgap2 -form scan.unseen -width 80 > "$actual" || : +check "$actual" "$expected.inc" + +finish_test +exit ${failed:-0} diff --git a/test/imap/test-inc-missing-msg b/test/imap/test-inc-missing-msg new file mode 100755 index 00000000..b695ea6a --- /dev/null +++ b/test/imap/test-inc-missing-msg @@ -0,0 +1,130 @@ +#!/bin/sh + +# TODO FIX uh oh this and the other missing msg cases passed by accident after inc uses ESEARCH +# and now switching inc to leave mailbox open has broken it completely! + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +# common.sh sets this to something crazy; did i do that?! +testname=${0#$srcdir/} + +setup_test_without_data Mail + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + rm -f "$imapd" + + [ -n "$imapd_pid" ] && kill $imapd_pid + "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} +trap imap_cleanup 0 + +covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname" +install -d "$covdir" +export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" + +imapd="$MH_TEST_DIR/$$.imapd" +start_fakeinetd "$imapd" +imapd_pid=$fakeinetd_pid + +cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +start_test 'message disappears between FETCH UID and FETCH BODY' +# test the case where a message goes missing between the time inc first +# notices it and when it goes to download it, e.g. inc starts, sees a message, +# and then another client deletes it before inc can fetch it. + +# I'm kinda surprised this just worked on the first try... hmm or not. +# I see. The ServerData::Fetch loop doesn't know if it's procesinsg a +# response from our request for a range of UIDS or from our request of a +# message body. +# TODO Teach it the difference so we can log when a message goes missing. + +# Documented behaviors: +# https://datatracker.ietf.org/doc/html/rfc3501#section-6.4.8 says: +# A non-existent unique identifier is ignored without any error +# message generated. Thus, it is possible for a UID FETCH command +# to return an OK without any data or a UID COPY or UID STORE to +# return an OK without performing any operations. +# This seems to match the Panda behavior I see. + +# https://datatracker.ietf.org/doc/html/rfc2180#section-4.1.1 has an example: +# => B003 FETCH 4:7 RFC822 +# <= B003 NO Messages 4:7 are no longer available. +# and in 4.1.2: +# => B001 FETCH 3:5 ENVELOPE +# <= * 3 FETCH ENVELOPE . . . (ENVELOPE info returned) +# <= B001 NO Some of the requested messages no longer exist +# and in 4.1.3: +# If all of the messages in the subsequent FETCH command have been +# expunged, the server SHOULD return only a tagged NO. +# and mayb it can return NIL or empty string for BODY[] ? + +# Observed behaviors +# Panda: no data but just an OK! +# (tls-encrypted) => fetch1 UID FETCH 101 BODY.PEEK[] +# (tls-decrypted) <= fetch1 OK UID FETCH completed +# Dovecot: same +# => fetch1 UID FETCH 101 BODY.PEEK[] +# <= fetch1 OK Fetch completed (0.001 + 0.000 secs). +# Cyrus: same +# => fetch1 UID FETCH 101 BODY.PEEK[] +# <= fetch1 OK Completed (0.000 sec) +# Gandi: same +# (tls-encrypted) => fetch1 UID FETCH 101 BODY.PEEK[] +# (tls-decrypted) <= fetch1 OK Fetch completed (0.001 + 0.000 secs). +# Gmail +# (tls-encrypted) => fetch1 UID FETCH 101 BODY.PEEK[] +# (tls-decrypted) <= fetch1 OK Success + +cat > "$imapd" <> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +# TODO handle this case next: +# UIDNEXT 12 so FETCH 1:11 but nothing is returned; 11 was deleted but never seen. + +cat > "$imapd" < "$actual" 2>&1 || status=$? +} + +MH_template="$MH_TEST_DIR/template.mh_profile" +components_template="$MH_TEST_DIR/template.components" +scan_template="$MH_TEST_DIR/template.scan.nodate" + +rm -f "$MH_template" "$components_template" "$scan_template" +rm -rf "$MH_TEST_DIR/dovecot" "$MH_TEST_DIR/panda" + +rm -rf "$MH_TEST_DIR"/*/imap.d + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + if [ -z "$MH_TEST_NOCLEANUP" ]; then + for i in $test_folders; do + rmf +$i -nointeractive + done + fi + + [ -n "$imapd_pid" ] && kill $imapd_pid + + for profile in $profiles; do + set_mh_profile $profile + "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + done + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} + +if [ "$MH_IMAP_TEST_SERVER" = 'panda' ]; then + # TODO It seems drafts are born unseen on Cyrus and GMail. + MH_IMAP_SERVER_DRAFT_BORN_UNSEEN=' ' + # TODO Why is Cyrus eating the trailing CRLF? + # I'm planning to use refile -file instead of comp anyway; will that help? + MH_IMAP_SERVER_EATS_TRAILING_EOL=' ' + [ -z "$LOGNAME" ] && fail 'LOGNAME not set' + MH_IMAP_TEST_EMAIL_ADDRESS="$LOGNAME" + mkdir "$MH_TEST_DIR/panda" + # Thought creating this stopped Panda from looking at the developer's real + # inbox, but it doesn't stop it from mangling /var/mail! + # TODO Ship customized Panda for testing. + touch "$MH_TEST_DIR/panda/mbox" + start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD" + imapd_pid=$fakeinetd_pid + cat > "$MH_template" < "$MH_TEST_DIR/dovecot/cf" < "$MH_template" <> "$MH_template" + fi +elif [ -z "$MH_IMAP_TEST_PORT" ] \ + || [ -z "$MH_IMAP_TEST_TLS" ] \ + || [ -z "$MH_IMAP_TEST_USER" ] \ + || [ -z "$MH_IMAP_TEST_PASSWORD" ] \ + || [ -z "$MH_IMAP_TEST_EMAIL_ADDRESS" ]; then + fail 'MH_IMAP_TEST_PORT MH_IMAP_TEST_USER MH_IMAP_TEST_PASSWORD MH_IMAP_TEST_EMAIL_ADDRESS must all be set' +else + case $MH_IMAP_TEST_TLS in + no) + MH_IMAP_TEST_TLS="don't protect my data i know what i'm doing" + ;; + unverified-implicit) + MH_IMAP_TEST_TLS="implicit but don't verify the cert i already did out of band" + ;; + esac + cat > "$MH_TEST_DIR/netrc" <> "$MH_template" <> "$MH_template" + fi +fi + +# it seems with cyrus everything has to go under INBOX? i probably +# messed something up but whatever, let's roll with it. +# * LIST (\HasChildren) "/" INBOX +#* LIST (\HasNoChildren) "/" INBOX/foo + +imap_folder=imap/imap$(date '+%j%H%M%S') + +cat >> "$MH_template" < "$scan_template" <%<(unseen)U%| %>%<{replied}-%| %>\ +%<(mymbox{from})%<{to}To:%(unquote(decode(friendly{to})))%>%>\ +%<(zero)%(unquote(decode(friendly{from})))%> \ +%(decode{subject})%<{body}<<%{body}>>%> +EOF + +# TODO default components use '--------' to close header... +cat > "$components_template" <%(void(width))%(putaddr From: ) +%<{to}%(void(width))%(putaddr To: )%|To:%> +%<{cc}%(void(width))%(putaddr cc: )%|cc:%> +Fcc: %<{fcc}%(putstr)%|+outbox%> +Subject:%<{subject} %(putstr)%> + +body +EOF + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +################################################################################ +profile=profile1 +rm -rf "$MH_TEST_DIR/$profile" +setup_test_without_data $profile +trap imap_cleanup 0 + +cat "$MH_template" >> "$MH" +cp "$components_template" "$MH_TEST_DIR/$profile/components" +cp "$scan_template" "$MH_TEST_DIR/$profile/scan.nodate" +if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then + echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +# Set this for the EHLO command +echo "clientname: nosuchhost.example.com" >> "$MHMTSCONF" + +cat >> "$MH" < "$expected" < "$actual" +check "$actual" "$expected" + +check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts +check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/sent + +################################################################################ +start_test 'inc when nothing to fetch' + +cat /dev/null > "$expected" +check_exit '-eq 0' inc +imap -width 80 + +################################################################################ +start_test 'comp creates draft in empty draft folder' + +check_exit '-eq 0' comp -noedit -to "$MH_IMAP_TEST_EMAIL_ADDRESS" -fcc $imap_folder/sent -subject 'testing imap' -nowhatnowproc + +# comp creates a message, stores a UID/msgnum pair, runs the user's editor, +# creates a new message with the user's edits, and replaces the UID/msgnum pair. +# So verify the integrity of the database after that. +# TODO any other operations worth having this integrity check on? +check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts + +start_test 'scan drafts after comp' +cat > "$expected" <> +EOF +run_prog scan -form scan.nodate +$imap_folder/drafts -width 80 > "$actual" +check "$actual" "$expected" + +start_test 'show draft after comp' +cat > "$expected" < "$actual" +check "$actual" "$expected" + +start_test 'check .mh_sequences after show after comp' +cat > "$expected" < "$expected" <> + 2 To:$MH_IMAP_TEST_EMAIL_ADDRESS testing imap 2<> +EOF +run_prog scan -form scan.nodate +$imap_folder/drafts -width 80 > "$actual" +check "$actual" "$expected" + +start_test 'check .mh_sequences after non-empty comp' +cat > "$expected" < /dev/null +check_exit '-eq 0' send -server 127.0.0.1 -port $localport -draftfolder $imap_folder/drafts +rm -f "$actual" + +check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts +check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/sent + +# TODO what?! this test started out as this and evolved into something else? +start_test 'scan drafts finds no messages after send' + +# Check draft moved to FCC folder +cat > "$expected" <> +EOF +run_prog scan -form scan.nodate +$imap_folder/drafts -width 80 > "$actual" +check "$actual" "$expected" + +start_test 'scan sent finds the sent messages after send' + +cat > "$expected" <> +EOF +run_prog scan -form scan.nodate +$imap_folder/sent -width 80 > "$actual" +check "$actual" "$expected" + +################################################################################ +profile=profile2 +rm -rf "$MH_TEST_DIR/$profile" +setup_test_without_data $profile +trap imap_cleanup 0 + +cat "$MH_template" >> "$MH" +cp "$components_template" "$MH_TEST_DIR/$profile/components" +cp "$scan_template" "$MH_TEST_DIR/$profile/scan.nodate" +if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then + echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +cat >> "$MH" < "$expected" <> +Incorporating new mail into $imap_folder/sent... + + 1 To:$MH_IMAP_TEST_EMAIL_ADDRESS testing imap<> +EOF + +inc +imap -form scan.nodate -width 80 > "$actual" +check "$actual" "$expected" + +check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts +check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/sent + +################################################################################ +start_test 'show the sent message after inc' + +cat > "$expected" <> "$expected" +else + echo -n body >> "$expected" +fi + +run_prog show +$imap_folder/sent 1 > "$actual" +sed -e 's/^Date:.*/Date:/' "$actual" > "$actual".nodate +rm -f "$actual" +check "$actual.nodate" "$expected" + +################################################################################ +start_test 'mark the sent message unseen' + +check_exit '-eq 0' mark -add -seq unseen +$imap_folder/sent 1 + +start_test 'scan after mark unseen' + +cat > "$expected" <> +EOF +run_prog scan -form scan.nodate -width 80 > "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'show the now-unseen message' + +check_exit '-eq 0' show all + +start_test 'scan after show' + +cat > "$expected" <> +EOF +run_prog scan -form scan.nodate -width 80 > "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'refile -file' + +refiled_file="$MH_TEST_DIR/$$.refiled-file" +cat > "$refiled_file" < "$expected" <> + 2 me <> +EOF +scan -form scan.nodate -width 80 +$imap_folder/drafts > "$actual" +check "$actual" "$expected" + +check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts + +################################################################################ +start_test 'refile' + +check_exit '-eq 0' refile -src +$imap_folder/drafts 2 +$imap_folder/sent + +# TODO what?! this test started out as this and evolved into something else? +start_test 'scan src folder after refile finds no messages' + +cat > "$expected" <> +EOF +scan -form scan.nodate -width 80 +$imap_folder/drafts > "$actual" +check "$actual" "$expected" + +start_test 'scan dst folder after refile finds refiled message' + +cat > "$expected" <> + 2 me <> +EOF +scan -form scan.nodate -width 80 +$imap_folder/sent > "$actual" +check "$actual" "$expected" + +check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts +check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/sent + +################################################################################ +start_test 'mark unseen after refile' + +# This tests the UID update in the local database. + +check_exit '-eq 0' mark -add -seq unseen 2 +cat > "$expected" <> + 2 U me <> +EOF +scan -form scan.nodate -width 80 +$imap_folder/sent > "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'show all before rmm' + +cat > "$expected" <>> $imap_folder/sent:1 + + +Date: +To: $MH_IMAP_TEST_EMAIL_ADDRESS +From: $MH_IMAP_TEST_EMAIL_ADDRESS +Subject: testing imap + +MIME-Version: 1.0 + + +EOF +if [ -n "$MH_IMAP_SERVER_EATS_TRAILING_EOL" ]; then + echo 'body ' >> "$expected" +else + echo -n body >> "$expected" +fi +cat >> "$expected" <>> $imap_folder/sent:2 + + +Date: +To: you +From: me + + +EOF +# TODO Why is this one unaffected?! Really need to get to the bottom of this... +if [ -n "$MH_IMAP_SERVER_EATS_TRAILING_EOL" ]; then + echo refile-test >> "$expected" +else + echo refile-test >> "$expected" +fi + +run_prog show +$imap_folder/sent all > "$actual" +sed -e 's/^Date:.*/Date:/' "$actual" > "$actual.nodate" +rm -f "$actual" +check "$actual.nodate" "$expected" + +start_test 'check .mh_sequences after show before rmm' +cat > "$expected" < "$expected" <> "$expected" +else + echo refile-test >> "$expected" +fi + +run_prog show +$imap_folder/sent > "$actual" +check "$actual" "$expected" + +start_test 'check .mh_sequences after show cur shows 2 after show all' +cat > "$expected" < "$expected" <> "$expected" +else + echo -n body >> "$expected" +fi + +run_prog show +$imap_folder/sent 1 > "$actual" +sed -e 's/^Date:.*/Date:/' "$actual" > "$actual".nodate +rm -f "$actual" +check "$actual.nodate" "$expected" + +start_test 'check .mh_sequences after show 1 before rmm' +cat > "$expected" < "$expected" <> "$expected" +else + echo -n body >> "$expected" +fi + +run_prog show +$imap_folder/sent > "$actual" +sed -e 's/^Date:.*/Date:/' "$actual" > "$actual".nodate +rm -f "$actual" +check "$actual.nodate" "$expected" + +start_test 'check .mh_sequences after show cur shows 1 after show 1' +cat > "$expected" < "$expected" <> +EOF +scan -form scan.nodate -width 80 +$imap_folder/sent > "$actual" +check "$actual" "$expected" + +check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/sent + +################################################################################ +start_test 'inc finds messages to delete 1. create messages in profile2' + +for i in 2 3 4 5 6; do + refiled_file="$MH_TEST_DIR/$$.refiled-file" + cat > "$refiled_file" < "$expected" <> + 2 me refile 2<> + 3 me refile 3<> + 4 me refile 4<> + 5 me refile 5<> + 6 me refile 6<> +EOF +scan -form scan.nodate -width 80 +$imap_folder/drafts > "$actual" +check "$actual" "$expected" + +for i in $test_folders; do + check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $i +done + +################################################################################ +start_test 'inc finds messages to delete 2. inc new messages into profile1' + +set_mh_profile profile1 + +# Message numbers are one greater in profile1 than in profile2. +cat > "$expected" <> + 4 me refile 3<> + 5 me refile 4<> + 6 me refile 5<> + 7 me refile 6<> +EOF +inc +imap -form scan.nodate -width 80 > "$actual" || : +check "$actual" "$expected" + +################################################################################ +start_test 'inc finds messages to delete 3. rmm messages out of profile2' + +set_mh_profile profile2 + +check_exit '-eq 0' rmm -unlink 2 +check_exit '-eq 0' rmm -unlink 4 +check_exit '-eq 0' rmm -unlink 5 + +cat > "$expected" <> + 3 me refile 3<> + 6 me refile 6<> +EOF +scan -form scan.nodate -width 80 +$imap_folder/drafts > "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'inc finds messages to delete 4. inc deletes from profile1' + +set_mh_profile profile1 + +inc +imap -form scan.nodate -width 80 > "$actual" || : +check /dev/null "$actual" 'keep first' + +################################################################################ +start_test 'inc finds messages to delete 5. scan profile1 to see messages gone' + +# Message numbers are one greater in profile1 than in profile2. +cat > "$expected" <> + 4 me refile 3<> + 7 me refile 6<> +EOF +scan -form scan.nodate -width 80 +$imap_folder/drafts > "$actual" +check "$actual" "$expected" + +# TODO tests: +# - refile old message into a folder with newer messages: UID validity preserved? + +################################################################################ +start_test 'check all folder databases one last time' + +for profile in $profiles; do + set_mh_profile $profile + for i in $test_folders; do + check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $i + done +done + +finish_test +exit ${failed:-0} diff --git a/test/imap/test-login b/test/imap/test-login new file mode 100755 index 00000000..b4a8d0c3 --- /dev/null +++ b/test/imap/test-login @@ -0,0 +1,374 @@ +#!/bin/sh + +testname=${0#$srcdir/} + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +if [ $(uname) != FreeBSD ]; then + test_skip 'imap/test-login only working on FreeBSD for now' +fi + +# common.sh sets this to something crazy; did i do that?! +testname=${0#$srcdir/} + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + rm -f "$imapd" "$actual" + [ -n "$imapd_pid" ] && kill $imapd_pid + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} +trap imap_cleanup 0 + +covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname" +install -d "$covdir" + +cat > "$MH_TEST_DIR/netrc" < "$actual" 2>&1 || status=$? +} + +################################################################################ +# Tests with nothing even listening. + +start_test 'connection refused, explicit TLS' + +arith_eval $arith_val + 1 +setup_test_without_data profile$arith_val +trap imap_cleanup 0 + +cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +start +if ! grep -q 'Connection to "127.0.0.1:255" failed: Connection refused' "$actual"; then + cat "$actual" >&2 + fail 'ECONN not reported' +fi + +# Use fakeinetd for tests of the parts of explicit TLS handling before TLS negotiation. +start_fakeinetd "$imapd" +imapd_pid=$fakeinetd_pid + +arith_eval $arith_val + 1 +setup_test_without_data profile$arith_val +trap imap_cleanup 0 + +cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +################################################################################ +start_test 'rudely close pre-greeting, explicit TLS' + +cat > "$imapd" <&2 + fail 'EOF error not reported' +fi + +################################################################################ +start_test 'protocol violation in greeting, explicit TLS' + +# Greets with simply "trash\r\n". + +cat > "$imapd" <&2 + fail 'protocol error not reported' +fi + +################################################################################ +start_test 'STARTTLS capability not offered' + +cat > "$imapd" <&2 + fail 'missing STARTTLS not reported' +fi + +################################################################################ +start_test 'STARTTLS capability not offered after capabilities requested' + +cat > "$imapd" <&2 + fail 'missing STARTTLS not reported' +fi + +################################################################################ +start_test 'rudely closed after STARTTLS' + +cat > "$imapd" <&2 + fail 'EOF not reported' +fi + +################################################################################ +start_test 'trash response to STARTTLS' + +cat > "$imapd" <&2 + fail 'protocol error not reported' +fi + +kill $imapd_pid +imapd_pid= + +################################################################################ +start_test 'connection refused, implicit TLS' + +arith_eval $arith_val + 1 +setup_test_without_data profile$arith_val +trap imap_cleanup 0 + +cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +start +if ! grep -q 'Connection to "127.0.0.1:255" failed: Connection refused' "$actual"; then + cat "$actual" >&2 + fail 'ECONN not reported' +fi + +################################################################################ +# Start the server for the rest. + +start_stunnel imap "$imapd" + +arith_eval $arith_val + 1 +setup_test_without_data profile$arith_val +trap imap_cleanup 0 + +cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +################################################################################ +start_test 'rudely close pre-greeting, implicit TLS' + +cat > "$imapd" <&2 + fail 'EOF error not reported' +fi + +################################################################################ +start_test 'no SASL-IR capability' + +cat > "$imapd" <&2 + fail 'missing SASL-IR not reported' +fi + +################################################################################ +start_test 'no UIDPLUS capability' + +cat > "$imapd" <&2 + fail 'missing UIDPLUS not reported' +fi + +################################################################################ +start_test 'protocol violation in greeting, implicit TLS' + +# Greets with simply "trash\r\n". + +cat > "$imapd" <&2 + fail 'protocol error not reported' +fi + +################################################################################ +start_test 'protocol violation in AUTHENTICATE response' + +# Responds to the AUTHENTICATE command with simply "trash\r\n". + +cat > "$imapd" <&2 + fail 'protocol error not reported' +fi + +finish_test +exit $failed diff --git a/test/imap/test-mark b/test/imap/test-mark new file mode 100755 index 00000000..3092d595 --- /dev/null +++ b/test/imap/test-mark @@ -0,0 +1,105 @@ +#!/bin/sh + +testname=${0#$srcdir/} + +# setup copied from test-scan + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +setup_test_without_data Mail + +rm -rf "$MH_TEST_DIR/panda" + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + [ -n "$imapd_pid" ] && kill $imapd_pid + "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} +trap imap_cleanup 0 + +covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname" +install -d "$covdir" +export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" + +start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD" +imapd_pid=$fakeinetd_pid + +# TODO imap-manager: $MH_LIBEXEC_DIR/folder-managers/imap +# TODO imap-socket: relative path +# TODO can we come up with reasonable default of socket path? +cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +mkdir "$MH_TEST_DIR/panda" +mkdir "$MH_TEST_DIR/panda/test" + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +cat > "$MH_TEST_DIR/panda/test/folder" < "$expected" <> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +# It's the "* OK [HIGHESTMODSEQ 8869]" that's illegal: a space is required after the close bracket. +cat > "$imapd" <> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +mkdir "$MH_TEST_DIR/panda" +mkdir "$MH_TEST_DIR/panda/test" + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +for i in 1 2 3; do + cat > "$MH_TEST_DIR/panda/test/fold$i" < "$expected.inc" <> +Incorporating new mail into imap/fold2... + + 1 10/24 me <> +Incorporating new mail into imap/fold3... + + 1 10/24 me <> +EOF + +inc +imap > "$actual" +check "$actual" "$expected.inc" + +################################################################################ +start_test 'refile' + +check_exit '-eq 0' refile -src +imap/fold1 1 +imap/fold2 + +start_test 'scan src folder after refile finds no messages' + +cat > "$expected" < "$actual" 2>&1 || : +check "$actual" "$expected" + +start_test 'scan dst folder after refile finds refiled message' + +cat > "$expected" <> + 2+ 10/24 me <> +EOF +scan -width 80 +imap/fold2 > "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'refile -link -file' + +refiled_file="$MH_TEST_DIR/$$.refiled-file" +cat > "$refiled_file" < "$expected" <> +EOF +scan -width 80 +imap/fold1 > "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'refile -file' + +refiled_file="$MH_TEST_DIR/$$.refiled-file" +cat > "$refiled_file" < "$expected" <> + 2+ 10/24 me <> +EOF +scan -width 80 +imap/fold3 > "$actual" +check "$actual" "$expected" + +################################################################################ +start_test 'rebuild local from scratch' + +rm -rf "$MH_TEST_DIR/Mail/imap" "$MH_TEST_DIR/Mail/imap.d/imap" + +cat > "$expected.inc" <> +Incorporating new mail into imap/fold2... + + 1 10/24 me <> + 2 10/24 me <> +Incorporating new mail into imap/fold3... + + 1 10/24 me <> + 2 10/24 me <> +EOF + +inc +imap > "$actual" +check "$actual" "$expected.inc" + +finish_test +exit ${failed:-0} diff --git a/test/imap/test-rmm b/test/imap/test-rmm new file mode 100755 index 00000000..7a9f1e94 --- /dev/null +++ b/test/imap/test-rmm @@ -0,0 +1,98 @@ +#!/bin/sh + +testname=${0#$srcdir/} + +# setup copied from test-scan + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +setup_test_without_data Mail + +rm -rf "$MH_TEST_DIR/panda" + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + [ -n "$imapd_pid" ] && kill $imapd_pid + "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} +trap imap_cleanup 0 + +covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname" +install -d "$covdir" +export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" + +start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD" +imapd_pid=$fakeinetd_pid + +# TODO imap-manager: $MH_LIBEXEC_DIR/folder-managers/imap +# TODO imap-socket: relative path +# TODO can we come up with reasonable default of socket path? +cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +mkdir "$MH_TEST_DIR/panda" +mkdir "$MH_TEST_DIR/panda/test" + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +cat > "$MH_TEST_DIR/panda/test/fold1" < "$expected" < "$actual" 2>&1 || : +check "$actual" "$expected" + +finish_test +exit ${failed:-0} diff --git a/test/imap/test-scan b/test/imap/test-scan new file mode 100755 index 00000000..81669a3d --- /dev/null +++ b/test/imap/test-scan @@ -0,0 +1,259 @@ +#!/bin/sh + +testname=${0#$srcdir/} + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +setup_test_without_data Mail + +rm -rf "$MH_TEST_DIR/panda" + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + [ -n "$imapd_pid" ] && kill $imapd_pid + "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} +trap imap_cleanup 0 + +covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname" +install -d "$covdir" +export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" + +start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD" +imapd_pid=$fakeinetd_pid + +cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +mkdir "$MH_TEST_DIR/panda" +mkdir "$MH_TEST_DIR/panda/test" + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +# TODO duplicated with test-inc +removed_message=5 +arith_val=1 +message_count=11 +cat > $MH_TEST_DIR/panda/test/fold <>" > "$expected.scan-all" + +while [ $arith_val -lt $message_count ]; do + arith_eval $arith_val + 1 + printf ' %2d 10/24 me <>\n' $arith_val >> "$expected.scan-all" + printf ' %2d U 10/24 me <>\n' $arith_val >> "$expected.scan-unseen" + cat >> $MH_TEST_DIR/panda/test/fold <>\n' $arith_val >> "$expected.scan-unseen" + printf ' %2d 10/24 me <>\n' $arith_val >> "$expected.scan-all" + fi + cat >> $MH_TEST_DIR/panda/test/fold < "$actual" +check "$expected.scan-all" "$actual" 'keep first' + +################################################################################ +start_test 'scan all explicitly' + +run_prog scan +imap/fold -width 80 all > "$actual" +check "$expected.scan-all" "$actual" 'keep first' + +################################################################################ +start_test 'scan unseen' + +run_prog scan +imap/fold -form scan.unseen -width 80 unseen > "$actual" +check "$expected.scan-unseen" "$actual" + +cat > "$expected" < "$expected" <> + 3 U 10/24 me <> + 4 U 10/24 me <> + 6 U 10/24 me <> + 8 U 10/24 me <> + 9 U 10/24 me <> + 10 U 10/24 me <> +EOF +run_prog scan +imap/fold -form scan.unseen -width 80 8-9 9-10 2-6 > "$actual" +check "$expected" "$actual" + +cat > "$expected" < "$expected" < "$actual" 2>&1 || : +check "$expected" "$actual" + +################################################################################ +start_test 'mark -del testseq unseen' + +check_exit '-eq 0' mark -add -seq testseq 6 8 10 +# TODO check output +check_exit '-eq 0' scan testseq + +check_exit '-eq 0' mark -del -seq testseq unseen + +# TODO should be 'scan: bad message list testseq' +cat > "$expected" < "$actual" 2>&1 || : +check "$expected" "$actual" + +################################################################################ +start_test 'show unseen twice' + +sed 's/^ 11 / 11+/' "$expected.scan-all" > "$expected" + +check_exit '-eq 0' show unseen unseen + +run_prog scan +imap/fold -width 80 > "$actual" +check "$expected" "$actual" + +################################################################################ +start_test 'show 1-11' + +sed 's/^ 11 / 11+/' "$expected.scan-all" > "$expected" +rm "$expected.scan-all" + +check_exit '-eq 0' show 1-11 + +################################################################################ +start_test 'scan testseq' + +check_exit '-eq 0' mark -add -seq testseq 2-3 6-8 11 +cat > "$expected" <> + 3 10/24 me <> + 6 10/24 me <> + 7 10/24 me <> + 8 10/24 me <> + 11+ 10/24 me <> +EOF +run_prog scan +imap/fold -form scan.unseen -width 80 testseq > "$actual" +check "$expected" "$actual" + +################################################################################ +start_test 'mark -del testseq 1' + +check_exit '-eq 0' mark -del -seq testseq 2 + +cat > "$expected" <> + 6 10/24 me <> + 7 10/24 me <> + 8 10/24 me <> + 11+ 10/24 me <> +EOF +run_prog scan +imap/fold -form scan.unseen -width 80 testseq > "$actual" +check "$expected" "$actual" + +################################################################################ +start_test 'mark -del testseq all' + +check_exit '-eq 0' mark -del -seq testseq testseq + +################################################################################ +start_test 'scan testseq after mark -del' + +# TODO should be 'scan: bad message list testseq' +cat > "$expected" < "$actual" 2>&1 || : +check "$expected" "$actual" + +finish_test +exit ${failed:-0} diff --git a/test/imap/test-search b/test/imap/test-search new file mode 100755 index 00000000..252d3c28 --- /dev/null +++ b/test/imap/test-search @@ -0,0 +1,132 @@ +#!/bin/sh + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +# common.sh sets this to something crazy; did i do that?! +testname=${0#$srcdir/} + +setup_test_without_data Mail + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + rm -f "$imapd" + + [ -n "$imapd_pid" ] && kill $imapd_pid + "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} +trap imap_cleanup 0 + +covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname" +install -d "$covdir" +export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" + +imapd="$MH_TEST_DIR/$$.imapd" +start_fakeinetd "$imapd" +imapd_pid=$fakeinetd_pid + +cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +cat > "$imapd" < "$expected" < "$actual" +check "$actual" "$expected" + +finish_test +exit ${failed:-0} diff --git a/test/imap/test-server-drops b/test/imap/test-server-drops new file mode 100755 index 00000000..0c569e46 --- /dev/null +++ b/test/imap/test-server-drops @@ -0,0 +1,81 @@ +#!/bin/sh + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +# common.sh sets this to something crazy; did i do that?! +testname=${0#$srcdir/} + +setup_test_without_data Mail + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + rm -f "$imapd" + + [ -n "$imapd_pid" ] && kill $imapd_pid + "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} +trap imap_cleanup 0 + +covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname" +install -d "$covdir" +export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" + +imapd="$MH_TEST_DIR/$$.imapd" +start_fakeinetd "$imapd" +imapd_pid=$fakeinetd_pid + +cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +start_test 'unrecoverable error: server closes connection mid-LIST-response' + +cat > "$imapd" < "$expected" < "$actual" 2>&1 || status=$? +check "$actual" "$expected" +(exit $status) && fail 'inc exited 0 on error' + +finish_test +exit ${failed:-0} diff --git a/test/imap/test-uidvalidity b/test/imap/test-uidvalidity new file mode 100755 index 00000000..3cb43086 --- /dev/null +++ b/test/imap/test-uidvalidity @@ -0,0 +1,139 @@ +#!/bin/sh + +testname=${0#$srcdir/} + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +rm -rf "$MH_TEST_DIR/panda" + +imap_cleanup() { + #### Save exit status to use as status for this program. + status=$? + set +e + + [ -n "$imapd_pid" ] && kill $imapd_pid + + # TODO uh oops don't need two profiles for this one + for i in 1 2; do + set_mh_profile profile$i + "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown + done + + if [ -z "$MH_TEST_NOCLEANUP" ]; then + rm -rf "$MH_TEST_DIR/panda" + for i in 1 2; do + rm -rf "$MH_TEST_DIR/profile$i" + done + fi + + # TODO have cleanup allow additional cleanup registration? + # Or just allow it to accept status as argument. + (exit $status) + cleanup +} + +covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname" +install -d "$covdir" + +mkdir "$MH_TEST_DIR/panda" +mkdir "$MH_TEST_DIR/panda/test" + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +uidvalidity=1503585238 +cat > $MH_TEST_DIR/panda/test/fold < "$actual" 2>&1 || status=$? +} + +# setup profile 2 and 1, leaving 1 as active profile +for i in 2 1; do + setup_test_without_data profile$i + trap imap_cleanup 0 + cat >> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" + fi + start +done + +################################################################################ +start_test '1. inc from profile1' + +echo 'Incorporating new mail into imap/fold...' > "$expected.inc" +echo >> "$expected.inc" +printf ' 1 10/24 me <>\n' >> "$expected.inc" + +inc +imap -form scan.unseen -width 80 > "$actual" +check "$actual" "$expected.inc" + +################################################################################ +# 2. rebuild mailbox +uidvalidity=1603585238 +cat > $MH_TEST_DIR/panda/test/fold <&2 + fail "'UIDVALIDITY changed' not in $MH_TEST_DIR/profile$i/imap.d/stderr" +fi + +finish_test +exit ${failed:-0} diff --git a/test/imap/test-xoauth b/test/imap/test-xoauth new file mode 100755 index 00000000..21bda35f --- /dev/null +++ b/test/imap/test-xoauth @@ -0,0 +1,89 @@ +#!/bin/sh + +# TODO fold into test-login? + +if test -z "$MH_OBJ_DIR"; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "${srcdir}/test/oauth/common.sh" + +# common.sh sets this to something crazy; did i do that?! +testname=${0#$srcdir/} + +setup_test_without_data Mail + +setup_service +fake_creds <> "$MH" <> "$MH" + echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH" +fi + +# TODO OAUTHBEARER? oooooh +# TODO when imlementing capability put this after the greeting +# * CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 XYZZY SASL-IR AUTH=XOAUTH2 AUTH=PLAIN AUTH=PLAIN-CLIENTTOKEN AUTH=OAUTHBEARER AUTH=XOAUTH +# 1 OK +cat > "$imapd" < +#include +#include +#include +#include + +#include "h/folder_manager_protocol.h" +#include "sbr/m_getfld.h" + +static int +run_message_len(char const *const *const argv) +{ + // goto out to return this status and free these allocations + char *msg = NULL; + int status = 1; + + errno = 0; + size_t const size = strtol(*argv, NULL, 10); + if (errno != 0) { + perror(*argv); + goto out; + } + + msg = malloc(size); + if (read(0, msg, size) != (ssize_t)size) { + fprintf(stderr, "failed to read %ld bytes from stdin\n", size); + goto out; + } + + status = mh_folder_manager_protocol_write_message(stdout, msg, size) ? 0 : 1; + + out: + free(msg); + return status; +} + +static int +run_read_message(char const *const *argv) +{ + long size = 0, message_count = 0; + for (; *argv != NULL; argv++) { + if (strcmp(*argv, "-initial-vector-size") == 0) { + if (size > 0) { + fputs("1 protocol-tester -initial-vector-size N -message-count N\n", stderr); + return 1; + } + errno = 0; + size = strtol(*(++argv), NULL, 10); + if (errno != 0) { + perror(*argv); + return 1; + } + } else if (strcmp(*argv, "-message-count") == 0) { + if (message_count > 0) { + fputs("2 protocol-tester -initial-vector-size N -message-count N\n", stderr); + return 1; + } + errno = 0; + message_count = strtol(*(++argv), NULL, 10); + if (errno != 0) { + perror(*argv); + return 1; + } + } else { + fprintf(stderr, "what is this %s\n", *argv); + fputs("3 protocol-tester -initial-vector-size N -message-count N\n", stderr); + return 1; + } + } + if (size < 1 || message_count < 1) { + fputs("4 protocol-tester -initial-vector-size N -message-count N\n", stderr); + return 1; + } + + mh_folder_manager_protocol_read_message_t state; + state.getfld_ctx = m_getfld_state_init(stdin); + + svector_t message = NULL; + for (int i = 0; i < message_count; i++) { + free_svector_and_elements(&message); + message = svector_create(size); + if (!mh_folder_manager_protocol_read_message(message, &state)) { + printf("msg%d size=%zu mh_folder_manager_protocol_read_message failed\n", i, svector_size(message)); + return 2; + } + printf("msg%d size=%zu\n", i, svector_size(message)); + for (size_t j = 0; j < svector_size(message); j++) { + char *name = svector_at(message, j++); + char *value = svector_at(message, j); + printf("%s = %s\n", name, value); + } + } + + m_getfld_state_destroy(&state.getfld_ctx); + + return 0; +} + +int +main(int const argc, char const *const *const argv) +{ + if (argc < 2) { + fputs("too few arguments\n", stderr); + return 1; + } + if (strcmp(argv[1], "message-len") == 0) { + return run_message_len(argv + 2); + } + if (strcmp(argv[1], "read-message") == 0) { + return run_read_message(argv + 2); + } + fprintf(stderr, "unknown imap/protocol-tester command %s\n", argv[1]); + return 1; +} diff --git a/test/managed-folders/test-message-len b/test/managed-folders/test-message-len new file mode 100755 index 00000000..f0657ad2 --- /dev/null +++ b/test/managed-folders/test-message-len @@ -0,0 +1,318 @@ +#!/bin/sh + +if [ -z "$MH_OBJ_DIR" ]; then + srcdir=`dirname "$0"`/../.. + MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR +fi + +. "$MH_OBJ_DIR/test/common.sh" + +setup_test_without_data + +expected="$MH_TEST_DIR/$$.expected" +actual="$MH_TEST_DIR/$$.actual" + +################################################################################ +start_test 'ask protocol-tester to read too much' + +check_exit '-eq 1' "$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 1 < /dev/null + +################################################################################ +start_test 'len-only empty message' + +echo 'len:6' > "$expected" + +"$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 0 < /dev/null > "$actual" +check "$expected" "$actual" + +################################################################################ +start_test '7 characters' + +cat > "$expected" < "$actual" < "$expected" < "$actual" < "$expected" < "$actual" < "$expected" < "$actual" < "$expected" < "$actual" < "$expected" < "$actual" < "$expected" < "$actual" < "$expected" < "$actual" < "$expected" < "$actual" < "$expected" < "$actual" < $expected < $actual < $expected < $actual < $expected < $actual < $expected < $actual < $expected < $actual < $expected < $actual < $expected < $actual < $expected < $actual < $expected < $actual < $expected < $actual < $expected < $actual < $expected < $actual < $expected < $actual < "$expected" < "$actual" < "$expected" < "$actual" < "$expected" < "$actual" 2>&1 || :) <> ${MH} <> ${MH} < @@ -100,15 +102,20 @@ main (int argc, char **argv) bool build = false; int i, in = NOTOK, isdf = 0, out, dat[5], format_len = 0; int outputlinelen = OUTPUTLINELEN; - char *cp, *cwd, *maildir, *dfolder = NULL; + char *cp, *cwd, *maildir = NULL, *dfolder = NULL; char *ed = NULL, *file = NULL, *form = NULL; char *folder = NULL, *msg = NULL, buf[BUFSIZ]; char *to = NULL, *from = NULL, *cc = NULL, *fcc = NULL, *dp; char *subject = NULL; - char drft[BUFSIZ], **argp, **arguments; + char *drft; + int drft_msgnum; + char draft_fn_buf[BUFSIZ], **argp, **arguments; struct msgs *mp = NULL; struct format *fmt; struct stat st; + mh_managed_folders *mmfs = NULL; + mh_managed_folder *mmf; + mh_folder_manager_client *mfmc = NULL; if (nmh_init(argv[0], true, true)) { return 1; } @@ -254,12 +261,44 @@ main (int argc, char **argv) /* * Check if we are using a draft folder - * and have specified a message in it. */ - if ((dfolder || context_find ("Draft-Folder")) && !folder && msg && !file) { - file = msg; - msg = NULL; + if (dfolder == NULL ) { + // TODO m_draft redoes this check; stop it. + dfolder = context_find("Draft-Folder"); } + bool draft_folder_is_managed = false; + { + if (dfolder != NULL) { + if (!mh_managed_folders_open(&mmfs)) { + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + mmf = mh_managed_folder_lookup(mmfs, dfolder, strlen(dfolder)); + if (mmf == NULL) { + /* and have specified a message in it. */ + if (!folder && msg && !file) { + file = msg; + msg = NULL; + } + } else { + draft_folder_is_managed = true; + if (!nwhat || build || folder || msg || file) { + die("only simple comp supports managed folder"); + } + maildir = m_maildir (dfolder); + if (chdir (maildir) == NOTOK) + adios (maildir, "unable to change directory to"); + if (!mh_folder_manager_connect(&mfmc, mmf)) { + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + drft_msgnum = mh_folder_manager_create_draft(mfmc, dfolder); + if (drft_msgnum == 0) { + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + drft = m_name(drft_msgnum); + } + } + } + if (form && (folder || msg)) die("can't mix forms and folders/msgs"); @@ -338,9 +377,12 @@ main (int argc, char **argv) } } + if (!draft_folder_is_managed) { try_it_again: - strncpy (drft, build ? m_maildir ("draft") + strncpy (draft_fn_buf, build ? m_maildir ("draft") : m_draft (dfolder, file, use, &isdf), sizeof(drft) - 1); + drft = draft_fn_buf; + } /* * Check if we have an existing draft @@ -361,10 +403,10 @@ try_it_again: adios (drft, "unable to stat"); printf ("Draft \"%s\" exists (%ld bytes).", drft, (long) st.st_size); for (i = LISTDSW; i != YESW;) { - if (!(argp = read_switch_multiword ("\nDisposition? ", - isdf ? aqrunl : aqrul))) + char *const *words = read_switch_multiword ("\nDisposition? ", isdf ? aqrunl : aqrul); + if (words == NULL) done (1); - switch (i = smatch (*argp, isdf ? aqrunl : aqrul)) { + switch (i = smatch (*words, isdf ? aqrunl : aqrul)) { case NOSW: done (0); case NEWSW: @@ -377,10 +419,10 @@ try_it_again: use++; goto edit_it; case LISTDSW: - showfile (++argp, drft); + showfile (++words, drft); break; case REFILSW: - if (refile (++argp, drft) == 0) + if (refile (++words, drft) == 0) i = YESW; break; default: @@ -420,8 +462,21 @@ try_it_again: edit_it: context_save (); /* save the context file */ - if (nwhat) + if (nwhat) { + if (draft_folder_is_managed) { + if (!mh_folder_manager_connect(&mfmc, mmf)) { + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + if (!mh_folder_manager_update_draft( + mfmc, + dfolder, + drft_msgnum)) { + die("in trouble updating draft %s", mh_managed_folders_get_err_string(mmfs)); + } + } done (0); + } + what_now (ed, nedit, use, drft, NULL, 0, NULL, NULL, 0, cwd, 0); done (1); return 1; diff --git a/uip/dist.c b/uip/dist.c index 1c2b596a..becffb8b 100644 --- a/uip/dist.c +++ b/uip/dist.c @@ -255,12 +255,13 @@ main (int argc, char **argv) try_it_again: strncpy (drft, m_draft (dfolder, dmsg, NOUSE, &isdf), sizeof(drft) - 1); + // TODO(epg): Duplicated from comp. /* Check if draft already exists */ if (stat (drft, &st) != NOTOK) { printf ("Draft \"%s\" exists (%ld bytes).", drft, (long) st.st_size); for (i = LISTDSW; i != YESW;) { - if (!(argp = read_switch_multiword ("\nDisposition? ", - isdf ? aqrnl : aqrl))) + char *const *const words = read_switch_multiword ("\nDisposition? ", isdf ? aqrnl : aqrl); + if (words == NULL) done (1); switch (i = smatch (*argp, isdf ? aqrnl : aqrl)) { case NOSW: diff --git a/uip/flist.c b/uip/flist.c index 17dd6721..b1d6c3e9 100644 --- a/uip/flist.c +++ b/uip/flist.c @@ -245,11 +245,10 @@ main(int argc, char **argv) * all the sequences defined there. */ if (svector_size (sequencesToDo) == 0) { + // TODO(epg): How many times is this duplicated? if ((cp = context_find(usequence)) && *cp) { - char **ap, *dp; - - dp = mh_xstrdup(cp); - ap = brkstring (dp, " ", "\n"); + char *const dp = mh_xstrdup(cp); + char *const *ap = brkstring (dp, " ", "\n"); for (; ap && *ap; ap++) svector_push_back (sequencesToDo, *ap); } else { diff --git a/uip/fmttest.c b/uip/fmttest.c index 50804c9d..aaefb9e1 100644 --- a/uip/fmttest.c +++ b/uip/fmttest.c @@ -550,10 +550,8 @@ process_messages(struct format *fmt, struct msgs_array *comps, if (dat[4] == -1) { if ((cp = context_find(usequence)) && *cp) { - char **ap, *dp; - - dp = mh_xstrdup(cp); - ap = brkstring(dp, " ", "\n"); + char *const dp = mh_xstrdup(cp); + char *const *ap = brkstring(dp, " ", "\n"); for (i = 0; ap && *ap; i++, ap++) ivector_push_back (seqnum, seq_getnum(mp, *ap)); diff --git a/uip/folder.c b/uip/folder.c index 6706ef87..e2bf3b72 100644 --- a/uip/folder.c +++ b/uip/folder.c @@ -8,6 +8,10 @@ */ #include "h/mh.h" + +#include + +#include "h/managed_folders.h" #include "sbr/getarguments.h" #include "sbr/concat.h" #include "sbr/seq_setprev.h" @@ -135,7 +139,7 @@ main (int argc, char **argv) bool pushsw = false; bool popsw = false; char *cp, *dp, *msg = NULL, *argfolder = NULL; - char **ap, **argp, buf[BUFSIZ], **arguments; + char *const *ap, **argp, buf[BUFSIZ], **arguments; if (nmh_init(argv[0], true, true)) { return 1; } @@ -379,6 +383,11 @@ main (int argc, char **argv) crawl_folders (folder, get_folder_info_callback, NULL); } } else { + mh_managed_folders *mmfs; + if (!mh_managed_folders_open(&mmfs)) { + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + strncpy (folder, argfolder ? argfolder : getfolder (1), sizeof(folder) - 1); @@ -386,7 +395,19 @@ main (int argc, char **argv) * Check if folder exists. If not, then see if * we should create it, or just exit. */ - create_folder (m_maildir (folder), fcreat, nonexistent_folder); + + mh_folder_manager_client *mfmc = NULL; + mh_managed_folder *const mmf = mh_managed_folder_lookup(mmfs, folder, strlen(folder)); + if (mmf == NULL || !fcreat) { + create_folder (m_maildir (folder), fcreat, nonexistent_folder); + } else { + if (!mh_folder_manager_connect(&mfmc, mmf)) { + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + if (!mh_folder_manager_make_folder(mfmc, folder)) { + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + } if (get_folder_info (folder, msg) && argfolder) { /* update current folder */ diff --git a/uip/forw.c b/uip/forw.c index 530e61cd..9af71e78 100644 --- a/uip/forw.c +++ b/uip/forw.c @@ -341,12 +341,13 @@ try_it_again: strncpy (drft, buildsw ? m_maildir ("draft") : m_draft (dfolder, NULL, NOUSE, &isdf), sizeof(drft) - 1); + // TODO(epg): Duplicated from comp. /* Check if a draft already exists */ if (!buildsw && stat (drft, &st) != NOTOK) { printf ("Draft \"%s\" exists (%ld bytes).", drft, (long) st.st_size); for (i = LISTDSW; i != YESW;) { - if (!(argp = read_switch_multiword ("\nDisposition? ", - isdf ? aqrnl : aqrl))) + char *const *const words = read_switch_multiword ("\nDisposition? ", isdf ? aqrnl : aqrl); + if (words == NULL) done (1); switch (i = smatch (*argp, isdf ? aqrnl : aqrl)) { case NOSW: diff --git a/uip/imaptest.c b/uip/imaptest.c index 9ad22341..a6313678 100644 --- a/uip/imaptest.c +++ b/uip/imaptest.c @@ -490,11 +490,10 @@ static void parse_capability(const char *cap, unsigned int len) { char *str = mh_xmalloc(len + 1); - char **caplist; int i; trunccpy(str, cap, len + 1); - caplist = brkstring(str, " ", NULL); + char *const *const caplist = brkstring(str, " ", NULL); if (imap_capabilities) { svector_free(imap_capabilities); diff --git a/uip/inc.c b/uip/inc.c index 7ebb0581..802da32d 100644 --- a/uip/inc.c +++ b/uip/inc.c @@ -31,6 +31,9 @@ #endif #include "h/mh.h" + +#include + #include "sbr/fmt_new.h" #include "sbr/dtime.h" #include "scansbr.h" @@ -60,6 +63,7 @@ #include "h/dropsbr.h" #include "popsbr.h" #include "h/fmt_scan.h" +#include "h/managed_folders.h" #include "h/signals.h" #include "h/tws.h" #include "h/mts.h" @@ -116,8 +120,11 @@ DEFINE_SWITCH_ARRAY(INC, switches); /* * flags for the mail source */ -#define INC_FILE 0 -#define INC_POP 1 +typedef enum IncType { + INC_FILE, + INC_POP, + INC_MANAGED_FOLDER, +} IncType; static struct Maildir_entry *Maildir; static int num_maildir_entries; @@ -185,27 +192,23 @@ static int pop_action(void *closure, char *); int main (int argc, char **argv) { - static int inc_type; + IncType inc_type; bool chgflag; int trnflag = 1; bool noisy; int width = -1; int hghnum = 0, msgnum = 0; - FILE *pf = NULL; bool sasl, noverify; int tls = 0; - int incerr = 0; /* <0 if inc hits an error which means it should not truncate mailspool */ - char *cp, *maildir = NULL, *folder = NULL; + char *cp, *folder = NULL; char *format = NULL, *form = NULL; char *host = NULL, *port = NULL, *user = NULL, *proxy = NULL; char *audfile = NULL, *from = NULL, *saslmech = NULL, *auth_svc = NULL; - char buf[BUFSIZ], **argp, *nfs, **arguments; - struct msgs *mp = NULL; - struct stat st, s1; - FILE *aud = NULL; - char b[PATH_MAX + 1]; - char *maildir_copy = NULL; /* copy of mail directory because the static gets overwritten */ - charstring_t scanl = NULL; + char buf[BUFSIZ], **argp, **arguments; + struct stat s1; + mh_managed_folders *mmfs; + mh_folder_manager_client *mfmc; + mh_folder_manager_inc_context *mfinc; int nmsgs, nbytes; char *MAILHOST_env_variable; @@ -409,8 +412,19 @@ main (int argc, char **argv) /* guarantee dropping group privileges; we might not have done so earlier */ DROPGROUPPRIVS(); - /* Source of mail; -from overrides any -host. */ - inc_type = host && !from ? INC_POP : INC_FILE; + if (!mh_managed_folders_open(&mmfs)) { + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + mh_managed_folder *const mmf = mh_managed_folder_lookup(mmfs, folder, strlen(folder)); + if (mmf == NULL) { + /* Source of mail; -from overrides any -host. */ + inc_type = host && !from ? INC_POP : INC_FILE; + } else { + inc_type = INC_MANAGED_FOLDER; + if (!mh_folder_manager_connect(&mfmc, mmf)) { + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + } if (inc_type == INC_POP) { /* Mail from a POP server. */ @@ -474,10 +488,33 @@ main (int argc, char **argv) if (!context_find ("path")) free (path ("./", TFOLDER)); if (!folder) - folder = getfolder (0); - maildir = m_maildir (folder); - maildir_copy = mh_xstrdup(maildir); + folder = mh_xstrdup (getfolder (0)); + + // TODO the whole inc loop needs work; this is the least of the problems + int hack_loop_count = 0; + if (inc_type == INC_MANAGED_FOLDER) { + mfinc = mh_folder_manager_inc(mfmc, folder); + if (mfinc == NULL) { + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + } + /* We loop for INC_MANAGED_FOLDER only. */ + for (;;) { + FILE *pf = NULL; + int incerr = 0; /* <0 if inc hits an error which means it should not truncate mailspool */ + char *maildir = m_maildir (folder); + // TODO do we really need both? + /* copy of mail directory because the static gets overwritten */ + char *maildir_copy = mh_xstrdup(maildir); + char *nfs; + struct msgs *mp = NULL; + FILE *aud = NULL; + char b[PATH_MAX + 1]; + charstring_t scanl = NULL; + + + if (inc_type != INC_MANAGED_FOLDER) { if (!folder_exists(maildir)) { /* If the folder doesn't exist, and we're given the -silent flag, * just fail. @@ -494,6 +531,7 @@ main (int argc, char **argv) /* read folder and create message structure */ if (!(mp = folder_read (folder, 0))) die("unable to read folder %s", folder); + } /* inc_type != INC_MANAGED_FOLDER */ if (inc_type == INC_FILE && Maildir == NULL) { /* Mail from a spool file. */ @@ -525,6 +563,7 @@ main (int argc, char **argv) if (audfile) { int i; + struct stat st; if ((i = stat (audfile, &st)) == NOTOK) inform("Creating Receive-Audit: %s", audfile); if ((aud = fopen (audfile, "a")) == NULL) @@ -546,7 +585,7 @@ main (int argc, char **argv) /* Get new format string */ nfs = new_fs (form, format, FORMAT); - if (noisy) { + if (inc_type != INC_MANAGED_FOLDER && noisy) { printf ("Incorporating new mail into %s...\n\n", folder); fflush (stdout); } @@ -627,7 +666,10 @@ main (int argc, char **argv) if (pop_quit () == NOTOK) die("%s", response); - } else if (inc_type == INC_FILE && Maildir == NULL) { + /* Only loop for INC_MANAGED_FOLDER. */ + break; + } else if (inc_type == INC_FILE) { + if (Maildir == NULL) { /* Mail from a spool file. */ scan_detect_mbox_style (in); /* the MAGIC invocation... */ @@ -772,9 +814,92 @@ main (int argc, char **argv) charstring_free (scanl); scanl = NULL; } + /* Only loop for INC_MANAGED_FOLDER. */ + break; + } else { + const mh_managed_folder_inc_page *const page = mh_folder_manager_inc_next(mfinc); + if (page == NULL) { + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + if (page->done) { + break; + } + + // TODO this is the problem. I wrote this for this case: + // inc +imap + // Incorporating new mail into imap/child1... + // Incorporating new mail into imap/child2... + // not for this case: + // inc +imap/child1 + // Incorporating new mail into imap/child1... + // Because here I thought I was comparing 'imap' to 'imap/child1' on + // the first iteration, but in this case we are comparing + // 'imap/child1' to 'imap/child1' on the first iteration! I think + // the mistake was in reusing the 'folder' variable + if (++hack_loop_count == 1 || strcmp(page->folder, folder) != 0) { + free (folder); + folder = mh_xstrdup (page->folder); + printf ("Incorporating new mail into %s...\n\n", folder); + fflush (stdout); + maildir = m_maildir(folder); + free(maildir_copy); + maildir_copy = mh_xstrdup(maildir); + if (chdir (maildir) == NOTOK) { + adios(maildir, "unable to change directory to"); + } + } + + { + long TODO_FILE_SIZE = 32; + + msgnum = page->msg; + + cp = mh_xstrdup(m_name(msgnum)); + if ((pf = fopen (cp, "r")) == NULL) + adios (cp, "unable to scan"); + switch (incerr = scan (pf, msgnum, 0, nfs, width, 0, + page->unseen, NULL, TODO_FILE_SIZE, noisy, &scanl)) { + case SCNEOF: + printf ("%*d empty\n", DMAXFOLDER, msgnum); + break; + + case SCNFAT: + trnflag = 0; + noisy = true; + /* advise (cp, "unable to read"); already advised */ + break; + + case SCNERR: + case SCNNUM: + break; + + case SCNMSG: + default: + if (aud) + fputs (charstring_buffer (scanl), aud); + if (noisy) + fflush (stdout); + break; + } + + if (scanl) + charstring_clear (scanl); + + if (ferror(pf) || fclose (pf)) { + perror("TODO error during scan... do we really care?"); + } + free (cp); + + scan_finished(); + } + + charstring_free (scanl); + scanl = NULL; + } scan_finished (); + if (inc_type != INC_MANAGED_FOLDER) { if (incerr < 0) { /* error */ if (locked) { GETGROUPPRIVS(); /* Be sure we can unlock mail file */ @@ -785,6 +910,7 @@ main (int argc, char **argv) } die("failed"); } + } if (aud) fclose (aud); @@ -792,10 +918,13 @@ main (int argc, char **argv) if (noisy) fflush (stdout); + } /* loop for INC_MANAGED_FOLDER only */ + if (inc_type == INC_FILE && Maildir == NULL) { /* Mail from a spool file; truncate it. */ if (trnflag) { + struct stat st; if (stat (newmail, &st) != NOTOK && s1.st_mtime != st.st_mtime) inform("new messages have arrived!\007"); else { @@ -811,6 +940,7 @@ main (int argc, char **argv) } } + if (inc_type != INC_MANAGED_FOLDER) { if (msgnum == hghnum) { inform("no messages incorporated, continuing..."); } else { @@ -858,6 +988,7 @@ main (int argc, char **argv) seq_save(mp2); /* Save the sequence file */ folder_free(mp2); } + } skip: if (inc_type == INC_FILE && Maildir == NULL) { diff --git a/uip/mark.c b/uip/mark.c index 72328bac..a5c79cfd 100644 --- a/uip/mark.c +++ b/uip/mark.c @@ -8,6 +8,7 @@ */ #include "h/mh.h" +#include "h/managed_folders.h" #include "sbr/getarguments.h" #include "sbr/seq_save.h" #include "sbr/smatch.h" @@ -172,6 +173,33 @@ main (int argc, char **argv) app_msgarg(&msgs, listsw ? "all" :"cur"); if (!folder) folder = getfolder (1); + + { + mh_managed_folders *mmfs; + if (!mh_managed_folders_open(&mmfs)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + mh_managed_folder *const mmf = mh_managed_folder_lookup(mmfs, folder, strlen(folder)); + if (mmf != NULL) { + if (listsw || publicsw > 0 || zerosw) + die("unsupported switches for mark on managed folder"); + mh_folder_manager_client *mfmc; + if (!mh_folder_manager_connect(&mfmc, mmf)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + if (addsw) { + if (!mh_folder_manager_mark_add(mfmc, folder, msgs, seqs)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + if (deletesw) { + if (!mh_folder_manager_mark_del(mfmc, folder, msgs, seqs)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + if (!mh_folder_manager_close(mfmc)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + done (0); + return 1; + } + } + maildir = m_maildir (folder); if (chdir (maildir) == NOTOK) diff --git a/uip/mhbuildsbr.c b/uip/mhbuildsbr.c index 6795025f..6670e711 100644 --- a/uip/mhbuildsbr.c +++ b/uip/mhbuildsbr.c @@ -674,7 +674,7 @@ static int user_content (FILE *in, char *buf, CT *ctp, const char *infilename) { int extrnal, vrsn; - char *cp, **ap; + char *cp, *const *ap; char buffer[NMH_BUFSIZ]; struct multipart *m; struct part **pp; diff --git a/uip/mhlogin.c b/uip/mhlogin.c index 5e6d1615..9a427609 100644 --- a/uip/mhlogin.c +++ b/uip/mhlogin.c @@ -10,25 +10,28 @@ #include #include "h/mh.h" -#include "sbr/getarguments.h" -#include "sbr/concat.h" -#include "sbr/smatch.h" -#include "sbr/ambigsw.h" -#include "sbr/print_version.h" -#include "sbr/print_help.h" -#include "sbr/error.h" + #include "h/done.h" -#include "h/utils.h" #include "h/oauth.h" +#include "h/utils.h" +#include "sbr/ambigsw.h" +#include "sbr/base64.h" +#include "sbr/concat.h" +#include "sbr/error.h" +#include "sbr/getarguments.h" #include "sbr/lock_file.h" +#include "sbr/print_help.h" +#include "sbr/print_version.h" +#include "sbr/smatch.h" #define MHLOGIN_SWITCHES \ - X("user username", 0, USERSW) \ - X("saslmech", 0, SASLMECHSW) \ X("authservice", 0, AUTHSERVICESW) \ X("browser", 0, BROWSERSW) \ - X("snoop", 0, SNOOPSW) \ X("help", 0, HELPSW) \ + X("print-client-response", 0, PRINT_CLIENT_RES_SW) \ + X("saslmech", 0, SASLMECHSW) \ + X("snoop", 0, SNOOPSW) \ + X("user username", 0, USERSW) \ X("version", 0, VERSIONSW) \ #define X(sw, minchars, id) id, @@ -141,11 +144,25 @@ do_login(const char *svc, const char *user, const char *browser, int snoop) return 0; } + +static int +do_print_client_response(const char *svc, const char *user, const int snoop) +{ + unsigned char *oauth_res, *b64data; + size_t oauth_res_len; + mh_oauth_do_xoauth(user, svc, &oauth_res, &oauth_res_len, snoop ? stderr : NULL); + fwrite(oauth_res, 1, oauth_res_len, stdout); + b64data = mh_xmalloc(BASE64SIZE(oauth_res_len)); + writeBase64raw(oauth_res, oauth_res_len, b64data); + printf("\n%s\n", b64data); + return 0; +} #endif int main(int argc, char **argv) { + bool print_client_response = false; char *cp, **argp, **arguments; const char *user = NULL, *saslmech = NULL, *svc = NULL, *browser = NULL; int snoop = 0; @@ -194,6 +211,9 @@ main(int argc, char **argv) die("missing argument to %s", argp[-2]); continue; + case PRINT_CLIENT_RES_SW: + print_client_response = true; + continue; case SNOOPSW: snoop++; continue; @@ -209,6 +229,9 @@ main(int argc, char **argv) free(arguments); #ifdef OAUTH_SUPPORT + if (print_client_response) + return do_print_client_response(svc, user, snoop); + return do_login(svc, user, browser, snoop); #else NMH_UNUSED(svc); diff --git a/uip/mhlsbr.c b/uip/mhlsbr.c index ade7e1c2..1e78f2f7 100644 --- a/uip/mhlsbr.c +++ b/uip/mhlsbr.c @@ -620,14 +620,13 @@ mhl_format (char *file, int length, int width) * it to the end of the current "ignores" list. */ if (!strcasecmp (name, "ignores")) { - char **tmparray, **p; int n = 0; /* split the fields */ - tmparray = brkstring (mh_xstrdup(++parptr), ",", NULL); + char *const *const tmparray = brkstring (mh_xstrdup(++parptr), ",", NULL); /* count number of fields split */ - p = tmparray; + char *const *p = tmparray; while (*p++) n++; diff --git a/uip/mhstoresbr.c b/uip/mhstoresbr.c index 64b69bc0..1362519e 100644 --- a/uip/mhstoresbr.c +++ b/uip/mhstoresbr.c @@ -1012,7 +1012,7 @@ clobber_check (char *original_file, mhstoreinfo_t info) { "no", 0, NMH_NO }, { "rename", 0, NMH_RENAME }, { NULL, 0, 0 } }; - char **ans; + char *const *ans; if (isatty (fileno (stdin))) { char *prompt = diff --git a/uip/new.c b/uip/new.c index 8e61e772..0dee3c37 100644 --- a/uip/new.c +++ b/uip/new.c @@ -73,13 +73,13 @@ count_messages(char *field) { int total = 0; int j, k; - char *cp, **ap; field = getcpy(field); /* copied from seq_read.c:seq_init */ - for (ap = brkstring (field, " ", "\n"); *ap; ap++) { - if ((cp = strchr(*ap, '-'))) + for (char *const *ap = brkstring (field, " ", "\n"); *ap; ap++) { + char *cp = strchr(*ap, '-'); + if (cp != NULL) *cp++ = '\0'; if ((j = m_atoi (*ap)) > 0) { k = cp ? m_atoi (cp) : j; @@ -453,7 +453,7 @@ doit(char *cur, char *folders, char *sequences[]) int main(int argc, char **argv) { - char **ap, *cp, **argp, **arguments; + char *cp, **argp, **arguments; char help[BUFSIZ]; char *folders = NULL; svector_t sequences = svector_create (0); @@ -527,7 +527,7 @@ main(int argc, char **argv) if (unseen == NULL || unseen[0] == '\0') { die("must specify sequences or set %s", usequence); } - for (ap = brkstring(unseen, " ", "\n"); *ap; ap++) { + for (char *const *ap = brkstring(unseen, " ", "\n"); *ap; ap++) { svector_push_back (sequences, *ap); ++i; } diff --git a/uip/refile.c b/uip/refile.c index ab4cf9e2..0bb9ac00 100644 --- a/uip/refile.c +++ b/uip/refile.c @@ -32,6 +32,7 @@ #include "sbr/arglist.h" #include "sbr/error.h" #include "h/done.h" +#include "h/managed_folders.h" #include "h/utils.h" #include "sbr/m_maildir.h" #include "sbr/m_mktemp.h" @@ -82,11 +83,12 @@ static void copy_seqs (struct msgs *, int, struct msgs *, int); int main (int argc, char **argv) { + bool draft = false; bool linkf = false; bool preserve = false; bool retainseqs = false; int filep = 0; - int foldp = 0, isdf = 0; + int foldp = 0; bool unlink_msgs = false; int i, msgnum; char *cp, *folder = NULL, buf[BUFSIZ]; @@ -159,12 +161,14 @@ main (int argc, char **argv) folder = path (*cp == '+' || *cp == '@' ? cp + 1 : cp, *cp != '@' ? TFOLDER : TSUBCWF); continue; - case DRAFTSW: + case DRAFTSW: { if (filep > NFOLDERS) die("only %d files allowed!", NFOLDERS); - isdf = 0; + draft = true; + int isdf = 0; files[filep++] = mh_xstrdup(m_draft(NULL, NULL, 1, &isdf)); continue; + } case FILESW: if (filep > NFOLDERS) die("only %d files allowed!", NFOLDERS); @@ -196,6 +200,47 @@ main (int argc, char **argv) if (foldp == 0) die("no folder specified"); + mh_managed_folders *mmfs; + if (!mh_managed_folders_open(&mmfs)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + mh_managed_folder **const mmf = mh_xmalloc(foldp * sizeof *mmf); + *mmf = mh_managed_folder_lookup(mmfs, folders[0].f_name, strlen(folders[0].f_name)); + if (*mmf != NULL) { + if (foldp > 1) + die("TODO multiple destination folders not yet supported"); + if (draft || retainseqs) + die("TODO flags not yet supported"); + if (preserve) + die("-preserve not supported by managed folder"); + if (unlink_msgs) + die("-unlink not supported by managed folder"); + mh_folder_manager_client *mfmc; + if (filep > 0) { + if (filep > 1) + die("TODO multiple files not yet supported"); + if (!mh_folder_manager_connect(&mfmc, *mmf)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + if (!mh_folder_manager_refile_file(mfmc, files[0], folders[0].f_name)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + /* If -nolink, then "remove" files */ + if (!linkf) + remove_files (filep, filevec); + } else { + if (linkf) + die("TODO -link not yet supported for folder-to-folder refile"); + if (mh_managed_folder_lookup(mmfs, folder, strlen(folder)) != *mmf) + die("TODO refile only supported on one folder manager for now"); + if (!mh_folder_manager_connect(&mfmc, *mmf)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + if (!mh_folder_manager_refile_move(mfmc, folder, msgs, folders[0].f_name)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + } + if (!mh_folder_manager_close(mfmc)) + die("TODO mh_folder_manager_close"); + done (0); + return 1; + } + /* * We are refiling a file to the folders */ @@ -216,6 +261,7 @@ main (int argc, char **argv) app_msgarg(&msgs, "cur"); if (!folder) folder = getfolder (1); + strncpy (maildir, m_maildir (folder), sizeof(maildir) - 1); if (chdir (maildir) == NOTOK) diff --git a/uip/repl.c b/uip/repl.c index 0563c0a0..36cffafc 100644 --- a/uip/repl.c +++ b/uip/repl.c @@ -385,14 +385,15 @@ try_it_again: strncpy (drft, buildsw ? m_maildir ("reply") : m_draft (dfolder, NULL, NOUSE, &isdf), sizeof(drft) - 1); + // TODO(epg): Duplicated from comp. /* Check if a draft exists */ if (!buildsw && stat (drft, &st) != NOTOK) { printf ("Draft \"%s\" exists (%ld bytes).", drft, (long) st.st_size); for (i = LISTDSW; i != YESW;) { - if (!(argp = read_switch_multiword ("\nDisposition? ", - isdf ? aqrnl : aqrl))) + char *const *words = read_switch_multiword ("\nDisposition? ", isdf ? aqrnl : aqrl); + if (words == NULL) done (1); - switch (i = smatch (*argp, isdf ? aqrnl : aqrl)) { + switch (i = smatch (*words, isdf ? aqrnl : aqrl)) { case NOSW: done (0); case NEWSW: @@ -401,10 +402,10 @@ try_it_again: case YESW: break; case LISTDSW: - showfile (++argp, drft); + showfile (++words, drft); break; case REFILSW: - if (refile (++argp, drft) == 0) + if (refile (++words, drft) == 0) i = YESW; break; default: diff --git a/uip/rmf.c b/uip/rmf.c index ee4abae8..dda77a8f 100644 --- a/uip/rmf.c +++ b/uip/rmf.c @@ -6,6 +6,7 @@ */ #include "h/mh.h" +#include "h/managed_folders.h" #include "sbr/getarguments.h" #include "sbr/read_yes_or_no_if_tty.h" #include "sbr/concat.h" @@ -49,6 +50,40 @@ DEFINE_SWITCH_ARRAY(RMF, switches); static int rmf(char *); static void rma (char *); +static int +managed_rmf (mh_managed_folder *const mmf, char *const folder) +{ + char *const maildir = m_maildir (folder); + + /* chdir is not required for managed folders but may be expected + * by external delete hook. */ + if (chdir (maildir) < 0) + adios (maildir, "chdir"); + + mh_folder_manager_client *mfmc; + if (!mh_folder_manager_connect(&mfmc, mmf)) + die("TODO: mh_folder_manager_connect"); + + /* Run the external delete hook program. */ + (void)ext_hook("del-hook", maildir, NULL); + + if (!mh_folder_manager_rmf(mfmc, folder)) + die("TODO mh_folder_manager_rmf"); + if (!mh_folder_manager_close(mfmc)) + die("TODO mh_folder_manager_close"); + + /* + * Remove any relevant private sequences + * or attributes from context file. + */ + rma (folder); + + if (chdir ("..") < 0) { + advise ("..", "chdir"); + } + + return OK; +} int main (int argc, char **argv) @@ -131,7 +166,13 @@ main (int argc, char **argv) free (cp); } - if (rmf (folder) == OK) { + mh_managed_folders *mmfs; + if (!mh_managed_folders_open(&mmfs)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + mh_managed_folder *const mmf = mh_managed_folder_lookup(mmfs, folder, strlen(folder)); + + const int status = mmf == NULL ? rmf (folder) : managed_rmf (mmf, folder); + if (status == OK) { char *cfolder = context_find(pfolder); if (cfolder && strcmp (cfolder, newfolder)) { printf ("[+%s now current]\n", newfolder); diff --git a/uip/rmm.c b/uip/rmm.c index df28926f..2f383f44 100644 --- a/uip/rmm.c +++ b/uip/rmm.c @@ -22,6 +22,7 @@ #include "sbr/print_help.h" #include "sbr/error.h" #include "h/done.h" +#include "h/managed_folders.h" #include "h/utils.h" #include "sbr/m_maildir.h" @@ -107,6 +108,25 @@ main (int argc, char **argv) app_msgarg(&msgs, "cur"); if (!folder) folder = getfolder (1); + + mh_managed_folders *mmfs; + if (!mh_managed_folders_open(&mmfs)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + mh_managed_folder *const mmf = mh_managed_folder_lookup(mmfs, folder, strlen(folder)); + if (mmf != NULL) { + if (!unlink_msgs) + die("%s", "managed folder only implemented for -unlink currently"); + if (rmmproc != NULL) + die("%s", "managed folder only implemented without rmmproc currently"); + mh_folder_manager_client *mfmc; + if (!mh_folder_manager_connect(&mfmc, mmf)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + if (!mh_folder_manager_rmm(mfmc, folder, msgs)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + done (0); + return 1; + } + maildir = m_maildir (folder); if (chdir (maildir) == NOTOK) diff --git a/uip/scan.c b/uip/scan.c index c1f27194..16efca1e 100644 --- a/uip/scan.c +++ b/uip/scan.c @@ -21,7 +21,6 @@ #include "sbr/context_save.h" #include "sbr/context_replace.h" #include "sbr/context_find.h" -#include "sbr/brkstring.h" #include "sbr/ambigsw.h" #include "sbr/path.h" #include "sbr/print_version.h" @@ -29,6 +28,8 @@ #include "sbr/seq_getnum.h" #include "sbr/error.h" #include "h/fmt_scan.h" +#include "h/managed_folders.h" +#include "h/folder.h" #include "h/tws.h" #include "h/mts.h" #include "h/done.h" @@ -68,15 +69,13 @@ main (int argc, char **argv) int ontty = 0; int width = -1; bool revflag = false; - int i, state, msgnum; - ivector_t seqnum = ivector_create (0); + int state, msgnum; bool unseen; - int num_unseen_seq = 0; - char *cp, *maildir, *file = NULL, *folder = NULL; + char *cp, *file = NULL, *folder = NULL; char *form = NULL, *format = NULL, buf[BUFSIZ]; char **argp, *nfs, **arguments; struct msgs_array msgs = { 0, 0, NULL }; - struct msgs *mp; + const struct msgs *mp; charstring_t scanl = NULL; FILE *in; @@ -266,44 +265,15 @@ main (int argc, char **argv) app_msgarg(&msgs, "all"); if (!folder) folder = getfolder (1); - maildir = m_maildir (folder); - - if (chdir (maildir) == NOTOK) - adios (maildir, "unable to change directory to"); - - /* read folder and create message structure */ - if (!(mp = folder_read (folder, 1))) - die("unable to read folder %s", folder); - - /* check for empty folder */ - if (mp->nummsg == 0) - die("no messages in %s", folder); - - /* parse all the message ranges/sequences and set SELECTED */ - for (msgnum = 0; msgnum < msgs.size; msgnum++) - if (!m_convert (mp, msgs.msgs[msgnum])) - done(1); - seq_setprev (mp); /* set the Previous-Sequence */ - - context_replace (pfolder, folder); /* update current folder */ - seq_save (mp); /* synchronize message sequences */ - context_save (); /* save the context file */ - - /* - * Get the sequence number for each sequence - * specified by Unseen-Sequence - */ - if ((cp = context_find (usequence)) && *cp) { - char **ap, *dp; - - dp = mh_xstrdup(cp); - ap = brkstring (dp, " ", "\n"); - for (i = 0; ap && *ap; i++, ap++) - ivector_push_back (seqnum, seq_getnum (mp, *ap)); - - num_unseen_seq = i; - free(dp); + mh_managed_folders *mmfs; + if (!mh_managed_folders_open(&mmfs)) { + die("%s", mh_managed_folders_get_err_string(mmfs)); } + mh_folder *mhf; + if (!mh_folder_open(&mhf, folder, mmfs)) + die("%s", mh_folder_get_err_string(mhf)); + if (!(mp = mh_folder_select_and_chdir_for_scan(mhf, msgs))) + die("%s", mh_folder_get_err_string(mhf)); ontty = isatty (fileno (stdout)); @@ -325,13 +295,7 @@ main (int argc, char **argv) * Check if message is in any sequence given * by Unseen-Sequence profile entry. */ - unseen = false; - for (i = 0; i < num_unseen_seq; i++) { - if (in_sequence(mp, ivector_at (seqnum, i), msgnum)) { - unseen = true; - break; - } - } + unseen = mh_folder_message_is_unseen_for_scan(mhf, msgnum); switch (state = scan (in, msgnum, 0, nfs, width, msgnum == mp->curmsg, unseen, @@ -358,8 +322,7 @@ main (int argc, char **argv) } charstring_free (scanl); - ivector_free (seqnum); - folder_free (mp); /* free folder/message structure */ + mh_folder_close (mhf); if (clearflag) nmh_clear_screen (); diff --git a/uip/send.c b/uip/send.c index c12908f4..eec9102e 100644 --- a/uip/send.c +++ b/uip/send.c @@ -31,6 +31,8 @@ #include "sbr/error.h" #include #include "h/done.h" +#include "h/managed_folders.h" +#include "h/folder.h" #include "h/utils.h" #ifdef OAUTH_SUPPORT #include "h/oauth.h" @@ -137,11 +139,10 @@ main (int argc, char **argv) int msgp = 0, vecp; int isdf = 0; int msgnum, status; - char *cp, *dfolder = NULL, *maildir = NULL; - char buf[BUFSIZ], **ap, **argp, **arguments, *program; + char *cp, *dfolder = NULL; + char buf[BUFSIZ], **argp, **arguments, *program; char *msgs[MAXARGS], **vec; const char *user = NULL, *saslmech = NULL; - struct msgs *mp; struct stat st; char *auth_svc = NULL; @@ -321,12 +322,15 @@ main (int argc, char **argv) if ((cp = context_find ("Aliasfile"))) { char *dp = NULL; - for (ap = brkstring(dp = mh_xstrdup(cp), " ", "\n"); ap && *ap; ap++) { + for (char *const *ap = brkstring(dp = mh_xstrdup(cp), " ", "\n"); ap && *ap; ap++) { vec[vecp++] = "-alias"; vec[vecp++] = *ap; } } + /* TODO WHY treat -draftfolder different from profile Draft-Folder?! And + * one branch uses m_draft and one doesn't? comp seems to duplicate some + * of m_draft too... draft management clearly needs a lot of work. */ if (dfolder == NULL) { if (msgp == 0) { msgs[msgp++] = mh_xstrdup(m_draft(NULL, NULL, 1, &isdf)); @@ -334,15 +338,16 @@ main (int argc, char **argv) adios (msgs[0], "unable to stat draft file"); cp = concat ("Use \"", msgs[0], "\"? ", NULL); for (status = LISTDSW; status != YESW;) { - if (!(argp = read_switch_multiword (cp, anyl))) - done (1); - switch (status = smatch (*argp, anyl)) { + char *const *words = read_switch_multiword (cp, anyl); + if (words == NULL) + done (1); + switch (status = smatch (*words, anyl)) { case NOSW: done (0); case YESW: break; case LISTDSW: - showfile (++argp, msgs[0]); + showfile (++words, msgs[0]); break; default: inform("say what?"); @@ -359,40 +364,25 @@ main (int argc, char **argv) if (!msgp) msgs[msgp++] = "cur"; - maildir = m_maildir (dfolder); - - if (chdir (maildir) == NOTOK) - adios (maildir, "unable to change directory to"); - - /* read folder and create message structure */ - if (!(mp = folder_read (dfolder, 1))) - die("unable to read folder %s", dfolder); - - /* check for empty folder */ - if (mp->nummsg == 0) - die("no messages in %s", dfolder); - - /* parse all the message ranges/sequences and set SELECTED */ - for (msgnum = 0; msgnum < msgp; msgnum++) - if (!m_convert (mp, msgs[msgnum])) - done (1); - seq_setprev (mp); /* set the previous-sequence */ + mh_managed_folders *mmfs; + if (!mh_managed_folders_open(&mmfs)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + mh_folder *mhf; + if (!mh_folder_open(&mhf, dfolder, mmfs)) + die("%s", mh_folder_get_err_string(mhf)); + const struct msgs *const mp = mh_folder_select_and_chdir_for_send(mhf, msgs, msgp); + if (mp == NULL) + die("%s", mh_folder_get_err_string(mhf)); for (msgp = 0, msgnum = mp->lowsel; msgnum <= mp->hghsel; msgnum++) { if (is_selected (mp, msgnum)) { msgs[msgp++] = mh_xstrdup(m_name (msgnum)); - unset_exists (mp, msgnum); } } - mp->msgflags |= SEQMOD; - seq_save (mp); + mh_folder_close (mhf); } -#ifdef WHATNOW -go_to_it: -#endif /* WHATNOW */ - if ((cp = getenv ("SIGNATURE")) == NULL || *cp == 0) if ((cp = context_find ("signature")) && *cp) setenv("SIGNATURE", cp, 1); @@ -476,15 +466,24 @@ go_to_it: status = 0; - for (msgnum = 0; msgnum < msgp; msgnum++) { - switch (sendsbr (vec, vecp, program, msgs[msgnum], &st, 1, auth_svc)) { + for (int i = 0; i < msgp; i++) { + char *const msgnum = msgs[i]; + switch (sendsbr (vec, vecp, program, msgnum, &st, dfolder == NULL, auth_svc)) { case DONE: done (++status); - /* FALLTHRU */ + break; case NOTOK: status++; - /* FALLTHRU */ + break; case OK: + if (dfolder != NULL) { + char *const rmm_command = concat ("rmm +", dfolder, " ", msgnum, NULL); + const int status = system (rmm_command); + free (rmm_command); + if (!WIFEXITED (status) || WEXITSTATUS (status) != 0) { + adios (msgnum, "failed to rmm after send"); + } + } break; } } diff --git a/uip/sendsbr.c b/uip/sendsbr.c index 628b99d2..d0f324b6 100644 --- a/uip/sendsbr.c +++ b/uip/sendsbr.c @@ -434,7 +434,7 @@ annoaux (int fd) { int fd2, fd3, msgnum; char *cp, *folder, *maildir; - char buffer[BUFSIZ], **ap; + char buffer[BUFSIZ]; FILE *fp; struct msgs *mp; @@ -476,7 +476,7 @@ annoaux (int fd) } else fd2 = NOTOK; - for (ap = brkstring (cp = mh_xstrdup(cp), " ", NULL); *ap; ap++) + for (char *const *ap = brkstring (cp = mh_xstrdup(cp), " ", NULL); *ap; ap++) m_convert (mp, *ap); free (cp); if (fd2 != NOTOK) diff --git a/uip/show.c b/uip/show.c index 699b77b0..7aedb5f6 100644 --- a/uip/show.c +++ b/uip/show.c @@ -6,6 +6,8 @@ */ #include "h/mh.h" +#include "h/managed_folders.h" +#include "h/folder.h" #include "mhlsbr.h" #include "sbr/m_name.h" #include "sbr/m_getfld.h" @@ -97,9 +99,8 @@ main (int argc, char **argv) bool checkmime = true; bool mime = false; int isdf = 0, mode = SHOW, msgnum; - char *cp, *maildir, *file = NULL, *folder = NULL, *proc, *program; + char *cp, *file = NULL, *folder = NULL, *proc, *program; char buf[BUFSIZ], **argp, **arguments; - struct msgs *mp = NULL; struct msgs_array msgs = { 0, 0, NULL }; struct msgs_array vec = { 0, 0, NULL }, non_mhl_vec = { 0, 0, NULL }; @@ -254,42 +255,16 @@ usage: if (!folder) folder = getfolder (1); - maildir = m_maildir (folder); + mh_managed_folders *mmfs; + if (!mh_managed_folders_open(&mmfs)) + die("%s", mh_managed_folders_get_err_string(mmfs)); + mh_folder *mhf; + if (!mh_folder_open(&mhf, folder, mmfs)) + die("%s", mh_folder_get_err_string(mhf)); + const struct msgs *const mp = mh_folder_select_and_chdir_and_mark_seen(&vec, mhf, msgs); + if (mp == NULL) + die("%s", mh_folder_get_err_string(mhf)); - if (chdir (maildir) == NOTOK) - adios (maildir, "unable to change directory to"); - - /* read folder and create message structure */ - if (!(mp = folder_read (folder, 1))) - die("unable to read folder %s", folder); - - /* check for empty folder */ - if (mp->nummsg == 0) - die("no messages in %s", folder); - - /* parse all the message ranges/sequences and set SELECTED */ - for (msgnum = 0; msgnum < msgs.size; msgnum++) - if (!m_convert (mp, msgs.msgs[msgnum])) - done (1); - - /* - * Set the SELECT_UNSEEN bit for all the SELECTED messages, - * since we will use that as a tag to know which messages - * to remove from the "unseen" sequence. - */ - for (msgnum = mp->lowsel; msgnum <= mp->hghsel; msgnum++) - if (is_selected(mp, msgnum)) - set_unseen (mp, msgnum); - - seq_setprev (mp); /* set the Previous-Sequence */ - seq_setunseen (mp, 1); /* unset the Unseen-Sequence */ - - for (msgnum = mp->lowsel; msgnum <= mp->hghsel; msgnum++) - if (is_selected(mp, msgnum)) - app_msgarg(&vec, mh_xstrdup(m_name (msgnum))); - - seq_setcur (mp, mp->hghsel); /* update current message */ - seq_save (mp); /* synchronize sequences */ context_replace (pfolder, folder); /* update current folder */ context_save (); /* save the context file */ diff --git a/uip/whatnowsbr.c b/uip/whatnowsbr.c index 7f28f34a..897525c7 100644 --- a/uip/whatnowsbr.c +++ b/uip/whatnowsbr.c @@ -256,10 +256,13 @@ WhatNow (int argc, char **argv) done (1); for (;;) { + // read_switch_multiword(_via_readline) return pointer to brkstring's + // private data. Writing into it is rude, but as long as we don't free + // it, there's no practical harm. This is the least of our problems... #ifdef READLINE_SUPPORT - if (!(argp = read_switch_multiword_via_readline(myprompt, aleqs))) { + if (!(argp = (char**)read_switch_multiword_via_readline(myprompt, aleqs))) { #else /* ! READLINE_SUPPORT */ - if (!(argp = read_switch_multiword(myprompt, aleqs))) { + if (!(argp = (char**)read_switch_multiword(myprompt, aleqs))) { #endif /* READLINE_SUPPORT */ (void) m_unlink (LINK); done (1); @@ -1202,10 +1205,8 @@ sendit (char *sp, char **arg, char *file, int pushed) /* allow Aliasfile: profile entry */ if ((cp = context_find ("Aliasfile"))) { - char **ap, *dp; - - dp = mh_xstrdup(cp); - for (ap = brkstring (dp, " ", "\n"); ap && *ap; ap++) { + char *const dp = mh_xstrdup(cp); + for (char *const *ap = brkstring (dp, " ", "\n"); ap && *ap; ap++) { vec[vecp++] = "-alias"; vec[vecp++] = *ap; } diff --git a/uip/whom.c b/uip/whom.c index 7a74cf2b..de5f5733 100644 --- a/uip/whom.c +++ b/uip/whom.c @@ -74,7 +74,7 @@ main (int argc, char **argv) int vecp = 0; bool distsw; char *cp, *dfolder = NULL, *dmsg = NULL; - char *msg = NULL, **ap, **argp, backup[BUFSIZ]; + char *msg = NULL, **argp, backup[BUFSIZ]; char buf[BUFSIZ], **arguments, *vec[MAXARGS]; if (nmh_init(argv[0], true, false)) { return 1; } @@ -169,7 +169,7 @@ main (int argc, char **argv) if ((cp = context_find ("Aliasfile"))) { char *dp = NULL; - for (ap = brkstring(dp = mh_xstrdup(cp), " ", "\n"); ap && *ap; ap++) { + for (char *const *ap = brkstring(dp = mh_xstrdup(cp), " ", "\n"); ap && *ap; ap++) { vec[vecp++] = "-alias"; vec[vecp++] = *ap; } -- 2.56.0