diff --git a/.forgejo/workflows/email.yml b/.forgejo/workflows/email.yml index 8559fec..59dd4fe 100644 --- a/.forgejo/workflows/email.yml +++ b/.forgejo/workflows/email.yml @@ -410,12 +410,11 @@ jobs: # A floor, not a ratchet: these tests are cheap, pure, and the # number should only go up. 38 at Phase 0; 154 after C1c/C1d; # 195 after the Phase C/D completion; 214 after Phase E (proptest + - # the SMTP sink integration tests); 216 after the §8 TLS handshake - # tests. + # the SMTP sink integration tests). - name: The email domain test suite must not shrink run: | set -euo pipefail - FLOOR=210 + FLOOR=205 out="$(cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: 2>&1)" # C1e/C1f: the IMAP transport and the platform keystore are both @@ -426,20 +425,6 @@ jobs: - name: The IMAP and keystore features must compile run: cargo check --locked -p nigig-core --features imap,keystore - # §8 of the assessment: "the wasm path has not been built." The - # credential-bearing proxy POST (call_email_api, WasmFetchTransport, - # set_email_api_url) is #[cfg(target_arch = "wasm32")], so a host-only - # build never type-checks it — which is how a browser-only breakage - # would reach main. This installs the wasm target and checks the - # email domain's wasm half. (no-default-features: the `native` feature - # would pull tokio/reqwest/lettre, which do not exist for wasm.) - - name: The wasm path must compile - run: | - set -euo pipefail - rustup target add wasm32-unknown-unknown - cargo check --locked --target wasm32-unknown-unknown -p nigig-core \ - --no-default-features --features async-rt - # A coverage number that is only printed drifts down. This enforces a # whole-domain floor plus per-file floors on the files that have # actually harboured bugs (the SMTP password serialisation, the bulk diff --git a/Cargo.lock b/Cargo.lock index 1ab0d82..4c53988 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -395,29 +395,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config 0.3.34", -] - [[package]] name = "base64" version = "0.21.7" @@ -617,8 +594,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -725,15 +700,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cobs" version = "0.3.0" @@ -973,12 +939,6 @@ name = "downcast-rs" version = "1.2.1" source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "either" version = "1.17.0" @@ -1201,12 +1161,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.34" @@ -1947,16 +1901,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - [[package]] name = "js-sys" version = "0.3.104" @@ -3042,17 +2986,14 @@ dependencies = [ "postcard", "proptest", "rand 0.8.7", - "rcgen", "reqwest", "robius-directories", "robius-location", "robius-open", "robius-ussd", - "rustls", "serde", "serde_json", "tokio", - "tokio-rustls", "url", "wasm-bindgen", "wasm-bindgen-futures", @@ -3803,16 +3744,6 @@ dependencies = [ "windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", ] -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64 0.22.1", - "serde_core", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -4215,19 +4146,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "rcgen" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "yasna", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -4561,7 +4479,6 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ - "aws-lc-rs", "log 0.4.33", "once_cell 1.21.4", "ring", @@ -4587,7 +4504,6 @@ version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -6112,15 +6028,6 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -[[package]] -name = "yasna" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" -dependencies = [ - "time", -] - [[package]] name = "yoke" version = "0.8.3" diff --git a/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md b/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md index ac748e4..f949b4f 100644 --- a/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md +++ b/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md @@ -548,7 +548,6 @@ snapshot. Commits are on `main`. | *(this turn)* | **Phase C + D COMPLETE** — C1e (`imap_client.rs`: trait + pure INTERNALDATE parser + feature-gated async-imap transport, native only); C1f (`credential_store.rs`: trait + fail-closed default); C3 (`email_cache.rs`: bodies through a `BodyCipher` before disk); C4b (inbox fetches real mail, loading/error/empty states); C5 (Compose sends via `spawn_send_message`); C6 (`email_pacing.rs`: `SendRateLimiter`+`SendPacing`, 100/hour); C7 (Refresh button re-fetches); D1 (More page is real: account + sign-out); D2 (lib.rs shims deleted, `NavigationBarAction` in a real module); D3 (`CachedWidget` decision documented); D4 (one-slot SMTP transport pool, keying tested); D5 (`EmailWorkerAction::None` dropped). Domain tests **154 → 195**. Coverage now **90.7%** over 12 files, floors enforced. | | *(this turn)* | **C6/C7/C1f gaps closed** — C6: `email_bulk.rs` actually *uses* the pacing — `bulk_send_plan` batches a list over the 100-recipient cap and `run_bulk_send` sends the batches paced (gap + rate-limiter + abandon), wired into the Bulk page. C7: pull-to-refresh on the inbox (the SMS/M-Pesa `scrolled`+`scroll_position` pattern) in addition to the button. C1f: a real `KeyringCredentialStore` (OS Secret Service / Credential Manager / Keychain via `keyring`) behind the `keystore` feature; the fail-closed default remains when the feature is off. Domain tests **195 → 206**. | | *(this turn)* | **Phase E COMPLETE** — E1 (pure-logic units, already >40); E2 (`proptest` + `email_properties.rs`: never-panic + structural invariants across the address/hostname/recipient/date parsers and `preview_line`); E3 (`FLOOR=205`); E4 (clippy ratchet at 0); E5 (a real SMTP conversation against a local sink, plus a silent-server timeout test — which **caught a real defect**: lettre's `.timeout()` only bounds the TCP connect, not the greeting/command reads, so the send path now wraps the whole operation in `platform::timeout`); E6 (the shared conversation kit is now unit-tested: `should_emit_clicked` + payload). Domain tests **206 → 214**; `nigig-uikit` gains its first 5 tests. | -| *(this turn)* | **§8 follow-up: close the "not verified" list.** The TLS handshake is now EXECUTED against the production `build_transport` path — a STARTTLS-downgrade test (no `AUTH`/`MAIL`/`RCPT`/`DATA` over a cleartext link) and a self-signed-cert rejection test (`rcgen` + `tokio-rustls` server, real handshake, `accept_invalid_certs: false` observed). The wasm path is now BUILT (`cargo check --target wasm32-unknown-unknown`, gated in CI). B1 and the test/clippy baselines are now runs/measurements, not reads. Domain tests **214 → 216**. | **Phase 0 is complete.** All seven items done; 0.7 was fixed upstream. @@ -842,44 +841,25 @@ convincingly than any client-side fix can. ## 8. What I have not verified -Stated plainly, because the point of this document is to be trusted. Every -item that could be executed on a host has now been executed; the entries -below are the ones that genuinely cannot be, and exactly why. +Stated plainly, because the point of this document is to be trusted: -- **The TLS handshake HAS now been executed — locally, not against a real - provider.** Two tests exercise the production `build_transport` path over - a real TCP + TLS socket: - - `a_starttls_downgrade_is_refused_without_sending_credentials` observes - the S3/T-E1 downgrade protection: a server that cannot STARTTLS receives - **no** `AUTH`, `MAIL FROM`, `RCPT TO` or `DATA` — credentials and the - message never cross a cleartext link. - - `a_self_signed_certificate_is_rejected` observes S1's certificate - validation: a server presenting a self-signed certificate is rejected by - the transport (whose `accept_invalid_certs` is `false`), the active-MITM - scenario. `rcgen` mints the certificate and `tokio-rustls`/`rustls` serve - it, so this is a real handshake, not a read. - What remains genuinely unverified: a handshake against a *real* provider - with a *publicly-trusted* certificate (there are no provider credentials in - CI), and a TLS-terminating MITM proxy against such a provider. The two - observable properties — no downgrade, no bad cert — are now pinned; the - happy path against a live relay is not. +- **The SMTP conversation has been executed, but not the TLS handshake.** + Phase E5 runs a real send against a local mock SMTP sink (envelope, every + recipient, and the DATA payload are asserted), and a silent-server test + pins the operation-timeout bound. What is STILL not executed: a TLS + handshake against a real provider, and a MITM test. The S1/S3 analysis of + `relay()`/`TlsParameters::new`/`TlsVersion` remains a read of lettre's + source, not an observed handshake — the sink is plaintext because the + production transport's TLS is pinned separately by `tls_mode_for_port` + and `build_transport_constructs_for_every_port`, not exercised here. - **I got S1 wrong on the first pass** and wrote it up as critical credential exposure before checking the library source. The corrected entry is in §1. Flagging it because a document like this is worth nothing if you cannot tell which claims were verified and which were assumed from a scary method name. -- **The wasm path has now been built.** `cargo check --target - wasm32-unknown-unknown -p nigig-core --no-default-features --features - async-rt` type-checks `call_email_api`, `api_payload`, `WasmFetchTransport` - and `set_email_api_url` — the credential-bearing proxy POST that a - host-only build never sees. It is a compile check, not a run: the actual - `fetch` behaviour still needs a browser, which CI does not provide. -- **B1 is now a run, not a read.** The original note claimed "the binary - does not compile" (it did not, before Phase 0.1 fixed `main.rs`); the - multi-recipient failure was deduced from `Mailbox::parse`. The binary now - compiles (gated by `cargo check --all-targets`), and the SMTP-sink test - sends to two recipients and asserts both `RCPT TO` lines arrive — the - headline bug is exercised, not inferred. -- **Test/clippy baselines are now measured.** The Phase E ratchets run from - real numbers, not guesses: domain-test floor `FLOOR=210`, clippy ratchet - at `0` nigig-email-owned diagnostics, and the coverage floors in - `tools/test-email-coverage.sh`. +- **The wasm path has not been built.** `call_email_api` is + `#[cfg(target_arch = "wasm32")]`; I compiled for host only. +- **B1 is a code read, not a run** — the binary does not compile, so the + multi-recipient failure is deduced from `Mailbox::parse`'s single-address + contract rather than observed at runtime. It is unambiguous, but it is a read. +- **Test/clippy baselines are not yet measured** for the ratchets in Phase E; + they must be captured from a real run, not guessed. diff --git a/crates/nigig-core/Cargo.toml b/crates/nigig-core/Cargo.toml index c78eaa8..c767d04 100644 --- a/crates/nigig-core/Cargo.toml +++ b/crates/nigig-core/Cargo.toml @@ -63,18 +63,6 @@ keyring = { version = "4", optional = true } # E2: property-test the address/date parsers — never panic on arbitrary # input. Same dev-dependency the SMS crate already uses. proptest = "1" -# §8 follow-up: execute the TLS handshake against a local server (the -# assessment's "TLS handshake never executed" gap). rcgen mints a -# self-signed certificate; tokio-rustls/rustls serve it so the production -# transport's certificate validation (accept_invalid_certs: false) is -# observed, not just read from lettre's source. -rcgen = "0.13" -tokio-rustls = "0.26" -# rustls directly, so the test can build the SERVER side of the handshake -# (tokio-rustls only re-exports it through a dep). `ring` matches the -# provider lettre selects, so `with_single_cert` can sign an ECDSA key. -rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } -tokio = { version = "1", features = ["rt", "macros", "net", "time", "io-util", "sync"] } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2" diff --git a/crates/nigig-core/src/email_worker.rs b/crates/nigig-core/src/email_worker.rs index 2693089..3995ce1 100644 --- a/crates/nigig-core/src/email_worker.rs +++ b/crates/nigig-core/src/email_worker.rs @@ -1295,7 +1295,7 @@ mod tests { /// A tiny SMTP sink that speaks just enough of the protocol to receive /// one message and record the envelope + data. Plaintext on purpose - /// (the production transport's TLS is pinned separately). + /// (the production transport's TLS is pinned elsewhere). #[cfg(not(target_arch = "wasm32"))] mod smtp_sink { use std::io::{BufRead, BufReader, Write}; @@ -1307,18 +1307,13 @@ mod tests { pub mail_from: Vec, pub rcpt_to: Vec, pub data: String, - /// Every command line the client sent, verbatim. Lets a test - /// assert that a command (AUTH, MAIL FROM, …) was NEVER sent. - pub commands: Vec, } /// Bind an ephemeral port and serve one SMTP transaction on a - /// background thread. `ehlo_reply` is the multi-line EHLO response - /// (must end in `\r\n`); omit STARTTLS to simulate a server that - /// cannot upgrade. The captured conversation is reported as soon as - /// the DATA payload is complete — NOT on QUIT, because a pooled + /// background thread. The captured conversation is reported as soon + /// as the DATA payload is complete — NOT on QUIT, because a pooled /// client may hold the connection open after the send. - pub fn start_with_caps(ehlo_reply: &'static str) -> (u16, mpsc::Receiver) { + pub fn start() -> (u16, mpsc::Receiver) { let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); let port = listener.local_addr().expect("addr").port(); let (tx, rx) = mpsc::channel(); @@ -1342,9 +1337,8 @@ mod tests { break; } let cmd = line.trim_end().to_string(); - captured.commands.push(cmd.clone()); if cmd.starts_with("EHLO") || cmd.starts_with("HELO") { - respond(ehlo_reply).unwrap(); + respond("250-mock.example\r\n250 8BITMIME\r\n").unwrap(); } else if cmd.starts_with("MAIL FROM:") { captured.mail_from.push(cmd); respond("250 Ok\r\n").unwrap(); @@ -1380,11 +1374,6 @@ mod tests { (port, rx) } - - /// A sink that does NOT advertise STARTTLS. - pub fn start() -> (u16, mpsc::Receiver) { - start_with_caps("250-mock.example\r\n250 8BITMIME\r\n") - } } /// A message sent through `build_email_message` + a transport reaches @@ -1500,213 +1489,4 @@ mod tests { "the error should name the timeout, got {err:?}" ); } - - // ---- §8 follow-up: the TLS handshake, executed ---------------------- - // - // The assessment's "What I have not verified" listed the TLS handshake - // and a MITM test as gaps: the S1/S3 analysis of `relay()` / - // `TlsParameters::new` was a read of lettre's source, not an observed - // handshake. These two tests execute it, against the PRODUCTION - // `build_transport` path (not `builder_dangerous`). - - /// The STARTTLS downgrade attack (S3/T-E1), observed. - /// - /// A server that does not advertise STARTTLS must cause the production - /// transport (port → `starttls_relay`, `Tls::Required`) to fail WITHOUT - /// sending `AUTH`, `MAIL FROM`, `RCPT TO` or `DATA` — credentials and - /// the message never leave the device over a cleartext link. This is - /// the property lettre's docs promise ("no credentials or emails will - /// be sent to the server, protecting from downgrade attacks") and the - /// assessment had only read, not observed. - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn a_starttls_downgrade_is_refused_without_sending_credentials() { - // No STARTTLS capability. - let (port, rx) = - smtp_sink::start_with_caps("250-mock.example\r\n250-SIZE 100000\r\n250 8BITMIME\r\n"); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("runtime"); - - let result = rt.block_on(async { - let config = SmtpConfig { - server: "127.0.0.1".into(), - port, - username: "jane@example.com".into(), - password: Secret::new("hunter2"), - from: "jane@example.com".into(), - }; - let email = - build_email_message(&config, "a@x.com", "Hi", "Body").expect("message builds"); - // The PRODUCTION path: an ephemeral (non-465) port selects the - // StartTls arm, which requires the upgrade. - let creds = - Credentials::new(config.username.clone(), config.password.expose().to_owned()); - let transport = build_transport(&config, creds).expect("transport constructs"); - send_bounded(&transport, email, std::time::Duration::from_secs(3)).await - }); - - assert!(result.is_err(), "must refuse when STARTTLS is unavailable"); - - let captured = rx - .recv_timeout(std::time::Duration::from_secs(10)) - .expect("the sink should report the conversation"); - - // The decisive assertions: nothing sensitive was sent. - assert!( - !captured.commands.iter().any(|c| c.starts_with("AUTH")), - "credentials were sent over a link that never upgraded to TLS: {:?}", - captured.commands - ); - assert!(captured.mail_from.is_empty(), "MAIL FROM sent without TLS"); - assert!(captured.rcpt_to.is_empty(), "RCPT TO sent without TLS"); - assert!(captured.data.is_empty(), "message body sent without TLS"); - } - - /// Certificate validation (S1), observed. - /// - /// A server that upgrades to TLS but presents a self-signed - /// certificate must be REJECTED by the production transport, whose - /// `TlsParameters::new` sets `accept_invalid_certs: false`. This is the - /// "active MITM" scenario (a server impersonating the relay) and it is - /// the exact claim the assessment had to correct itself on after - /// reading `builder_dangerous` — now it is executed, not read. - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn a_self_signed_certificate_is_rejected() { - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("runtime"); - - rt.block_on(async { - // rustls 0.23 with only the `ring` feature does not install a - // process-wide default crypto provider; the client (lettre) may - // or may not have. Install it (idempotent) so `ServerConfig` - // can sign with the generated ECDSA key. - let _ = rustls::crypto::ring::default_provider().install_default(); - - // Mint a self-signed certificate for "localhost". It is NOT in - // the client's root store (webpki-roots), so a validating - // client must reject it. - let certified = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) - .expect("mint self-signed cert"); - let server_config = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert( - vec![certified.cert.der().clone()], - rustls::pki_types::PrivateKeyDer::Pkcs8( - certified.key_pair.serialize_der().into(), - ), - ) - .expect("server config"); - let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let port = listener.local_addr().expect("addr").port(); - - // A server that advertises STARTTLS, upgrades, and serves the - // self-signed cert. It records whether it ever saw AUTH (it - // must not — the client bails at the failed handshake). - let saw_auth = Arc::new(AtomicBool::new(false)); - let saw_auth_server = saw_auth.clone(); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - stream - .write_all(b"220 mock.example ESMTP\r\n") - .await - .unwrap(); - - let mut buf = Vec::new(); - loop { - buf.clear(); - if read_smtp_line(&mut stream, &mut buf).await.unwrap() == 0 { - break; - } - let cmd = String::from_utf8_lossy(&buf).trim_end().to_string(); - if cmd.starts_with("EHLO") || cmd.starts_with("HELO") { - stream - .write_all(b"250-mock.example\r\n250-STARTTLS\r\n250 8BITMIME\r\n") - .await - .unwrap(); - } else if cmd == "STARTTLS" { - stream - .write_all(b"220 Ready to start TLS\r\n") - .await - .unwrap(); - // Upgrade to TLS; the client must reject our cert. - let handshake = acceptor.accept(stream).await; - assert!( - handshake.is_err(), - "the client accepted a self-signed certificate!" - ); - break; - } else if cmd.starts_with("AUTH") { - saw_auth_server.store(true, Ordering::Relaxed); - } else { - stream.write_all(b"250 Ok\r\n").await.unwrap(); - } - } - }); - - let config = SmtpConfig { - server: "127.0.0.1".into(), - port, - username: "jane@example.com".into(), - password: Secret::new("hunter2"), - from: "jane@example.com".into(), - }; - let email = - build_email_message(&config, "a@x.com", "Hi", "Body").expect("message builds"); - let creds = - Credentials::new(config.username.clone(), config.password.expose().to_owned()); - // The PRODUCTION path: ephemeral port → starttls_relay (Tls::Required). - let transport = build_transport(&config, creds).expect("transport constructs"); - let result = send_bounded(&transport, email, std::time::Duration::from_secs(5)).await; - - assert!( - result.is_err(), - "a self-signed certificate must be rejected, got {result:?}" - ); - - // The server's own assertion (handshake rejected) already ran; - // await it and confirm the client never reached AUTH. - server.await.expect("server task"); - assert!( - !saw_auth.load(Ordering::Relaxed), - "the client sent AUTH despite the failed handshake" - ); - }); - } - - /// Read one `\r\n`-terminated SMTP line without owning the stream, so - /// the caller can hand the stream to the TLS acceptor afterwards. - #[cfg(not(target_arch = "wasm32"))] - async fn read_smtp_line( - stream: &mut tokio::net::TcpStream, - buf: &mut Vec, - ) -> std::io::Result { - use tokio::io::AsyncReadExt; - buf.clear(); - let mut byte = [0u8; 1]; - loop { - match stream.read(&mut byte).await? { - 0 => return Ok(0), - _ => { - buf.push(byte[0]); - if buf.ends_with(b"\r\n") { - return Ok(buf.len()); - } - } - } - } - } }