fix: fail honestly on unreadable TLS modes and mis-sized mail #425

Merged
KofTwentyTwo merged 2 commits from fix/defabricate-tls-and-size into dev 2026-08-15 01:33:15 +00:00
3 changed files with 233 additions and 20 deletions

No files matched your search

+2 -1
View File
@@ -17,7 +17,8 @@ pub enum FilterField {
Header(String),
/// Attachment presence indicator (`true`/`false`).
HasAttachment,
/// Message size in bytes.
/// Message size in bytes: all stored body parts plus all attachment
/// payloads. Excludes headers and MIME framing, which are not retained.
Size,
/// Current folder location (e.g. `INBOX`).
Folder,
+123 -5
View File
@@ -97,6 +97,42 @@ impl CompiledFilter {
}
}
/// Total byte size of the message content Nuncio holds: every body part
/// plus every attachment payload.
///
/// This is what `WHERE size > N` compares against. Counting only the
/// plaintext body would report `0` for the very common HTML-only message
/// and would ignore attachments entirely, so a size rule would quietly
/// return the wrong verdict on exactly the mail it is usually written to
/// catch (the big ones). Both body parts are summed rather than one being
/// preferred: a `multipart/alternative` message genuinely carries the
/// plaintext *and* the HTML on the wire, so summing them tracks the real
/// message rather than one arbitrary view of it.
///
/// The figure is the size of the decoded content as stored, not the exact
/// RFC822 octet count of the original transmission -- headers, MIME
/// boundaries, and transfer encoding are not counted, because the raw
/// octets are not retained and no stored column carries their length. It is
/// therefore a consistent lower bound on the wire size, never a guess: for
/// base64-encoded attachments the transmitted form is roughly a third
/// larger again. Rules are written against what the store actually has.
fn message_size_bytes(email: &Email) -> i64 {
let mut total: u64 = 0;
for part in [email.body_plain.as_deref(), email.body_html.as_deref()]
.into_iter()
.flatten()
{
total = total.saturating_add(part.len() as u64);
}
for attachment in &email.attachments {
total = total.saturating_add(attachment.content.len() as u64);
}
// Saturate rather than wrap: a size past `i64::MAX` is not a real
// message, and a wrapped negative would make `size > N` answer the
// opposite of the truth.
i64::try_from(total).unwrap_or(i64::MAX)
}
fn eval_leaf(
leaf: &ConditionLeaf,
placed: PlacedEmail<'_>,
@@ -146,11 +182,7 @@ impl CompiledFilter {
}
}
FilterField::Size => {
let size = email
.body_plain
.as_ref()
.map(|b| b.len() as i64)
.unwrap_or(0);
let size = Self::message_size_bytes(email);
if let FilterValue::Number(target) = leaf.value {
match leaf.operator {
FilterOperator::Equals => size == target,
@@ -526,6 +558,92 @@ mod tests {
assert_eq!(preview.matched_rule_name, Some("Urgent".to_string()));
}
/// Evaluate one NSQL rule against one message, returning whether it fired.
fn size_rule_matches(nsql: &str, email: &Email) -> bool {
let rule = crate::parser::NsqlParser::parse_rule("Size Rule", 1, nsql).unwrap();
let engine = FilterEngine::new(vec![rule]).unwrap();
let (_, placement) = test_placed_email(&email.account_id, "", "inbox");
!engine
.evaluate(PlacedEmail {
email,
placement: &placement,
})
.is_empty()
}
/// `size` must reflect every body part and every attachment, not the
/// plaintext body alone. An HTML-only message has no plaintext body at
/// all, so it used to measure zero bytes and silently fell on the wrong
/// side of every size comparison -- `size > 100` said no for a 400-byte
/// message, and `size < 100` said yes.
#[test]
fn size_counts_html_body_of_html_only_message() {
let (mut email, _) = test_placed_email("acct-1", "HTML only", "inbox");
email.body_plain = None;
email.body_html = Some(format!("<p>{}</p>", "x".repeat(400)));
assert!(
size_rule_matches("WHERE size > 100 ACTION MARK READ", &email),
"a 407-byte HTML-only message must satisfy size > 100"
);
assert!(
!size_rule_matches("WHERE size < 100 ACTION MARK READ", &email),
"a 407-byte HTML-only message must not satisfy size < 100"
);
}
/// Attachment payloads are part of the message's size. A short note
/// carrying a large attachment is exactly the mail a size rule is written
/// to catch, and counting only the note's text missed it entirely.
#[test]
fn size_counts_attachment_payloads() {
let (mut email, _) = test_placed_email("acct-1", "Invoice", "inbox");
email.body_plain = Some("See attached.".to_string());
email.attachments = vec![nuncio_core::model::Attachment {
filename: "invoice.pdf".to_string(),
mime_type: "application/pdf".to_string(),
content: vec![0u8; 5_000].into(),
}];
assert!(
size_rule_matches("WHERE size > 4096 ACTION MARK READ", &email),
"a 13-byte note with a 5000-byte attachment must satisfy size > 4096"
);
}
/// A `multipart/alternative` message carries both renderings on the wire,
/// so both are counted rather than one being preferred -- this pins the
/// definition `size` is documented to have.
#[test]
fn size_sums_both_body_parts_and_attachments() {
let (mut email, _) = test_placed_email("acct-1", "Both parts", "inbox");
email.body_plain = Some("a".repeat(100));
email.body_html = Some("b".repeat(250));
email.attachments = vec![nuncio_core::model::Attachment {
filename: "logo.png".to_string(),
mime_type: "image/png".to_string(),
content: vec![0u8; 650].into(),
}];
assert_eq!(CompiledFilter::message_size_bytes(&email), 1_000);
assert!(size_rule_matches(
"WHERE size = 1000 ACTION MARK READ",
&email
));
}
/// A message with neither body part nor attachments genuinely measures
/// zero, and must still compare as a number rather than being skipped.
#[test]
fn size_of_empty_message_is_zero() {
let (mut email, _) = test_placed_email("acct-1", "Empty", "inbox");
email.body_plain = None;
email.body_html = None;
assert_eq!(CompiledFilter::message_size_bytes(&email), 0);
assert!(size_rule_matches("WHERE size < 1 ACTION MARK READ", &email));
}
#[test]
fn test_webhook_signature() {
let sig =
+108 -14
View File
@@ -413,13 +413,36 @@ fn tls_mode_to_db(mode: nuncio_core::TlsMode) -> &'static str {
}
/// Parse a persisted `accounts` TLS-mode column back into
/// [`nuncio_core::TlsMode`]. An unrecognized value falls back to the safe
/// default (implicit TLS) rather than failing the whole account load.
fn tls_mode_from_db(value: &str) -> nuncio_core::TlsMode {
/// [`nuncio_core::TlsMode`].
///
/// Fails closed. TLS modes are validated before they are written, so the only
/// way an unrecognized token reaches this function is corruption or tampering
/// of the row. Coercing such a value to a default would silently hand the
/// caller a transport the operator never configured -- reading `"start_tls"`
/// as implicit TLS, or an unreadable value as *any* concrete mode, changes the
/// account's security posture with no signal at all. The account's real mode is
/// unknown at that point, and an unknown security posture is not something this
/// layer may guess at, so the read is refused and the offending account, column,
/// and raw value are named in the error.
///
/// This is deliberately stricter than the best-effort recovery path in
/// [`crate::recovery`]: salvage exists to rescue what it can from an already
/// broken file and has no caller to return an error to, whereas this is the
/// path every ordinary account read takes.
fn tls_mode_from_db(
account_id: &str,
column: &str,
value: &str,
) -> Result<nuncio_core::TlsMode, DatabaseError> {
match value {
"start_tls" => nuncio_core::TlsMode::StartTls,
"plain" => nuncio_core::TlsMode::Plain,
_ => nuncio_core::TlsMode::ImplicitTls,
"implicit_tls" => Ok(nuncio_core::TlsMode::ImplicitTls),
"start_tls" => Ok(nuncio_core::TlsMode::StartTls),
"plain" => Ok(nuncio_core::TlsMode::Plain),
other => Err(DatabaseError::Corrupted(format!(
"account {account_id} has an unrecognized {column} value {other:?}; refusing to \
load it under a guessed TLS mode -- re-save the account with a valid transport \
security setting"
))),
}
}
@@ -1369,8 +1392,7 @@ impl DatabaseEngine {
.await
.map_err(DatabaseError::Query)?;
Ok(rows
.into_iter()
rows.into_iter()
.map(
|(
id,
@@ -1387,7 +1409,8 @@ impl DatabaseEngine {
smtp_tls_mode,
collection_url,
filters_enabled,
)| {
)|
-> Result<nuncio_core::AccountConfig, DatabaseError> {
let protocol = serde_json::from_str(&protocol_str)
.unwrap_or(nuncio_core::AccountProtocol::ImapSmtp);
// Reconstruct the exact transport variant from the
@@ -1410,10 +1433,18 @@ impl DatabaseEngine {
nuncio_core::Transport::ImapSmtp(nuncio_core::ImapSmtpTransport {
imap_host: server_host,
imap_port: server_port as u16,
imap_tls_mode: tls_mode_from_db(&imap_tls_mode),
imap_tls_mode: tls_mode_from_db(
&id,
"imap_tls_mode",
&imap_tls_mode,
)?,
smtp_host: resolved_smtp_host,
smtp_port: resolved_smtp_port,
smtp_tls_mode: tls_mode_from_db(&smtp_tls_mode),
smtp_tls_mode: tls_mode_from_db(
&id,
"smtp_tls_mode",
&smtp_tls_mode,
)?,
})
}
nuncio_core::AccountProtocol::Jmap => {
@@ -1427,7 +1458,7 @@ impl DatabaseEngine {
})
}
};
nuncio_core::AccountConfig {
Ok(nuncio_core::AccountConfig {
id,
name,
email_address,
@@ -1435,10 +1466,10 @@ impl DatabaseEngine {
sync_interval_secs: sync_interval_secs as u64,
filters_enabled: filters_enabled != 0,
transport,
}
})
},
)
.collect())
.collect()
}
/// Fetch the persisted inbound-sync checkpoint for a folder, or `None` if
@@ -4289,6 +4320,69 @@ mod tests {
assert_eq!(fetched, acct);
}
/// An unreadable stored TLS mode must fail the read rather than resolve to
/// a guessed one. The account below is configured for STARTTLS; its stored
/// mode is then overwritten out-of-band with a token this build does not
/// know. Previously that token was coerced to implicit TLS, so every
/// ordinary account read handed the caller a transport the operator never
/// chose, with nothing anywhere to say so. Both the IMAP and the SMTP
/// column are checked, and `get_account` is checked alongside
/// `list_accounts` because it reads through the same path.
#[tokio::test]
async fn unrecognized_stored_tls_mode_fails_the_read_instead_of_defaulting() {
for column in ["imap_tls_mode", "smtp_tls_mode"] {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
let acct = nuncio_core::AccountConfig {
id: "acct-tls-corrupt".to_string(),
name: "Tampered TLS Account".to_string(),
email_address: "tampered@nuncio.mx".to_string(),
keyring_secret_key: "nuncio/acct-tls-corrupt".to_string(),
sync_interval_secs: 60,
filters_enabled: false,
transport: nuncio_core::Transport::ImapSmtp(nuncio_core::ImapSmtpTransport {
imap_host: "imap.nuncio.mx".to_string(),
imap_port: 143,
imap_tls_mode: nuncio_core::TlsMode::StartTls,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 587,
smtp_tls_mode: nuncio_core::TlsMode::StartTls,
}),
};
engine.save_account(&acct).await.expect("save succeeds");
// Simulates corruption or tampering of the row: writes are
// validated, so this value could never arrive through the API.
sqlx::query(&format!("UPDATE accounts SET {column} = ? WHERE id = ?"))
.bind("tls")
.bind("acct-tls-corrupt")
.execute(&engine.pool)
.await
.expect("corrupting update succeeds");
let err = engine
.list_accounts()
.await
.expect_err("an unreadable TLS mode must not resolve to a default");
let message = err.to_string();
assert!(
matches!(err, DatabaseError::Corrupted(_)),
"expected a corruption error, got: {message}"
);
for expected in ["acct-tls-corrupt", column, "tls"] {
assert!(
message.contains(expected),
"error must name {expected}, got: {message}"
);
}
engine
.get_account("acct-tls-corrupt")
.await
.expect_err("get_account must fail closed on the same row");
}
}
/// A CalDAV account's `collection_url` must survive save/reload, and a
/// mail account's empty `collection_url` must round-trip as empty (not
/// NULL-mangled), proving the backfill-safe column persists both.