Everything clients, users and operators meet now carries the fork's name, with no aliases (SPEC.md §2.4, changed here from "protocol identifiers stay"): - JMAP: upstream's registry capability is urn:inbuxa:jmap:registry, beside the fork's own urn:inbuxa:jmap. - WebDAV lock and sync tokens are urn:inbuxa:dav*; clients resync once. - Sieve: vnd.inbuxa.while and vnd.inbuxa.expressions. sieve-rs spells these into its compiler, so it's vendored (vendor/sieve-rs, 0.7.3) and patched in; a unit test fails if Cargo.lock ever moves past the vendored copy. The trusted runtime now names itself too, rather than answering sieve-rs's default. - The web interface's OAuth client is inbuxa-webui. On every start the old stalwart-webui client is removed and any application naming it is moved over. - The spam filter's blobs are INBUXA_SPAM_*; every start moves any left under the old keys, so a trained model survives. - SQL stores and log files default to inbuxa, in the code and in the schema served to the admin (checksum regenerated). - Settings are INBUXA_* only. A STALWART_* variable that's set where its INBUXA_* one isn't stops the server at startup, naming it. - The version-upgrade messages link docs.inbuxa.org's migration page, and the OpenAPI description, smtp crate metadata and web-push test fixtures lose the name. Kept on purpose, allowlisted with reasons: the OAuth key-derivation contexts (renaming them would end every session and invalidate every sealed client id) and the hashed application prefix. Also fixes a latent start-up failure: ensure_client updated an existing first-party client with a revision of 0, which the registry's assertion never matches, so adding a redirect URI or changing the webmail secret failed start-up. And the principal session test now expects legacyProtocols (C-1, added 2026-09-21), which it had missed. Tested: the server builds without warnings; common's 106 unit tests, including the vendoring check; a new integration test for the two start-up migrations; and the webdav, jmap, imap and SMTP Sieve suites.
687 lines
25 KiB
Rust
687 lines
25 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
use crate::compiler::{
|
|
CompileError, ErrorType,
|
|
lexer::{Token, tokenizer::TokenInfo, word::Word},
|
|
};
|
|
|
|
use super::{
|
|
Capability, Invalid,
|
|
actions::{action_convert::Convert, action_vacation::TestVacation},
|
|
expr::{Expression, UnaryOperator, parser::ExpressionParser, tokenizer::Tokenizer},
|
|
instruction::{CompilerState, Instruction},
|
|
tests::{
|
|
test_address::TestAddress,
|
|
test_body::TestBody,
|
|
test_date::{TestCurrentDate, TestDate},
|
|
test_duplicate::TestDuplicate,
|
|
test_envelope::TestEnvelope,
|
|
test_exists::TestExists,
|
|
test_extlists::TestValidExtList,
|
|
test_hasflag::TestHasFlag,
|
|
test_header::TestHeader,
|
|
test_ihave::TestIhave,
|
|
test_mailbox::{TestMailboxExists, TestMetadata, TestMetadataExists},
|
|
test_mailboxid::TestMailboxIdExists,
|
|
test_notify::{TestNotifyMethodCapability, TestValidNotifyMethod},
|
|
test_size::TestSize,
|
|
test_spamtest::{TestSpamTest, TestVirusTest},
|
|
test_specialuse::TestSpecialUseExists,
|
|
test_string::TestString,
|
|
},
|
|
};
|
|
|
|
#[allow(clippy::enum_variant_names)]
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
#[cfg_attr(
|
|
any(test, feature = "serde"),
|
|
derive(serde::Serialize, serde::Deserialize)
|
|
)]
|
|
#[cfg_attr(
|
|
feature = "rkyv",
|
|
derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
|
|
)]
|
|
pub(crate) enum Test {
|
|
True,
|
|
False,
|
|
Address(TestAddress),
|
|
Envelope(TestEnvelope),
|
|
Exists(TestExists),
|
|
Header(TestHeader),
|
|
Size(TestSize),
|
|
Invalid(Invalid),
|
|
|
|
// RFC 5173
|
|
Body(TestBody),
|
|
|
|
// RFC 6558
|
|
Convert(Convert),
|
|
|
|
// RFC 5260
|
|
Date(TestDate),
|
|
CurrentDate(TestCurrentDate),
|
|
|
|
// RFC 7352
|
|
Duplicate(TestDuplicate),
|
|
|
|
// RFC 5229 & RFC 5183
|
|
String(TestString),
|
|
Environment(TestString),
|
|
|
|
// RFC 5435
|
|
NotifyMethodCapability(TestNotifyMethodCapability),
|
|
ValidNotifyMethod(TestValidNotifyMethod),
|
|
|
|
// RFC 6134
|
|
ValidExtList(TestValidExtList),
|
|
|
|
// RFC 5463
|
|
Ihave(TestIhave),
|
|
|
|
// RFC 5232
|
|
HasFlag(TestHasFlag),
|
|
|
|
// RFC 5490
|
|
MailboxExists(TestMailboxExists),
|
|
Metadata(TestMetadata),
|
|
MetadataExists(TestMetadataExists),
|
|
|
|
// RFC 9042
|
|
MailboxIdExists(TestMailboxIdExists),
|
|
|
|
// RFC 5235
|
|
SpamTest(TestSpamTest),
|
|
VirusTest(TestVirusTest),
|
|
|
|
// RFC 8579
|
|
SpecialUseExists(TestSpecialUseExists),
|
|
|
|
// RFC 5230
|
|
Vacation(TestVacation),
|
|
|
|
// Only test
|
|
#[cfg(test)]
|
|
TestCmd {
|
|
arguments: Vec<crate::compiler::Value>,
|
|
is_not: bool,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Block {
|
|
is_all: bool,
|
|
is_not: bool,
|
|
p_count: u32,
|
|
jmps: Vec<usize>,
|
|
}
|
|
|
|
impl CompilerState<'_> {
|
|
pub(crate) fn parse_test(&mut self) -> Result<(), CompileError> {
|
|
let mut block_stack: Vec<Block> = Vec::new();
|
|
let mut block = Block {
|
|
is_all: false,
|
|
is_not: false,
|
|
p_count: 0,
|
|
jmps: Vec::new(),
|
|
};
|
|
let mut is_not = false;
|
|
|
|
loop {
|
|
let token_info = self.tokens.unwrap_next()?;
|
|
self.reset_param_check();
|
|
let test: Instruction =
|
|
match token_info.token {
|
|
Token::Comma
|
|
if !block_stack.is_empty()
|
|
&& matches!(
|
|
self.instructions.last(),
|
|
Some(Instruction::Test(_) | Instruction::Eval(_))
|
|
)
|
|
&& matches!(
|
|
self.tokens.peek(),
|
|
Some(Ok(TokenInfo {
|
|
token: Token::Identifier(_) | Token::Unknown(_),
|
|
..
|
|
}))
|
|
) =>
|
|
{
|
|
is_not = block.is_not;
|
|
block.jmps.push(self.instructions.len());
|
|
self.instructions.push(if block.is_all {
|
|
Instruction::Jz(usize::MAX)
|
|
} else {
|
|
Instruction::Jnz(usize::MAX)
|
|
});
|
|
continue;
|
|
}
|
|
Token::ParenthesisOpen => {
|
|
block.p_count += 1;
|
|
continue;
|
|
}
|
|
Token::ParenthesisClose => {
|
|
if block.p_count > 0 {
|
|
block.p_count -= 1;
|
|
continue;
|
|
} else if let Some(prev_block) = block_stack.pop() {
|
|
let cur_pos = self.instructions.len();
|
|
for jmp_pos in block.jmps {
|
|
if let Instruction::Jnz(jmp_pos) | Instruction::Jz(jmp_pos) =
|
|
&mut self.instructions[jmp_pos]
|
|
{
|
|
*jmp_pos = cur_pos;
|
|
} else {
|
|
debug_assert!(false, "This should not have happened")
|
|
}
|
|
}
|
|
|
|
block = prev_block;
|
|
is_not = block.is_not;
|
|
if block_stack.is_empty() {
|
|
break;
|
|
} else {
|
|
continue;
|
|
}
|
|
} else {
|
|
return Err(token_info.expected("test name"));
|
|
}
|
|
}
|
|
Token::Identifier(Word::Not) => {
|
|
if !matches!(
|
|
self.tokens.peek(),
|
|
Some(Ok(TokenInfo {
|
|
token: Token::Identifier(_) | Token::Unknown(_),
|
|
..
|
|
}))
|
|
) {
|
|
return Err(token_info.expected("test name"));
|
|
}
|
|
is_not = !is_not;
|
|
continue;
|
|
}
|
|
Token::Identifier(word @ (Word::AnyOf | Word::AllOf)) => {
|
|
if block_stack.len() < self.tokens.compiler.max_nested_tests {
|
|
self.tokens.expect_token(Token::ParenthesisOpen)?;
|
|
block_stack.push(block);
|
|
let (is_all, block_is_not) = if word == Word::AllOf {
|
|
if !is_not {
|
|
(true, false)
|
|
} else {
|
|
(false, true)
|
|
}
|
|
} else if !is_not {
|
|
(false, false)
|
|
} else {
|
|
(true, true)
|
|
};
|
|
block = Block {
|
|
is_all,
|
|
is_not: block_is_not,
|
|
p_count: 0,
|
|
jmps: Vec::new(),
|
|
};
|
|
is_not = block_is_not;
|
|
continue;
|
|
} else {
|
|
return Err(CompileError {
|
|
line_num: token_info.line_num,
|
|
line_pos: token_info.line_pos,
|
|
error_type: ErrorType::TooManyNestedTests,
|
|
});
|
|
}
|
|
}
|
|
Token::Identifier(Word::True) => if !is_not {
|
|
Test::True
|
|
} else {
|
|
is_not = false;
|
|
Test::False
|
|
}
|
|
.into(),
|
|
Token::Identifier(Word::False) => if !is_not {
|
|
Test::False
|
|
} else {
|
|
is_not = false;
|
|
Test::True
|
|
}
|
|
.into(),
|
|
Token::Identifier(Word::Address) => self.parse_test_address()?.into(),
|
|
Token::Identifier(Word::Envelope) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Envelope.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_envelope()?.into()
|
|
}
|
|
Token::Identifier(Word::Header) => self.parse_test_header()?.into(),
|
|
Token::Identifier(Word::Size) => self.parse_test_size()?.into(),
|
|
Token::Identifier(Word::Exists) => self.parse_test_exists()?.into(),
|
|
|
|
// RFC 5173
|
|
Token::Identifier(Word::Body) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Body.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_body()?.into()
|
|
}
|
|
|
|
// RFC 6558
|
|
Token::Identifier(Word::Convert) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Convert.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_convert()?.into()
|
|
}
|
|
|
|
// RFC 5260
|
|
Token::Identifier(Word::Date) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Date.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_date()?.into()
|
|
}
|
|
Token::Identifier(Word::CurrentDate) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Date.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_currentdate()?.into()
|
|
}
|
|
|
|
// RFC 7352
|
|
Token::Identifier(Word::Duplicate) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Duplicate.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_duplicate()?.into()
|
|
}
|
|
|
|
// RFC 5229
|
|
Token::Identifier(Word::String) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Variables.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_string()?.into()
|
|
}
|
|
|
|
// RFC 5435
|
|
Token::Identifier(Word::NotifyMethodCapability) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Enotify.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_notify_method_capability()?.into()
|
|
}
|
|
Token::Identifier(Word::ValidNotifyMethod) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Enotify.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_valid_notify_method()?.into()
|
|
}
|
|
|
|
// RFC 5183
|
|
Token::Identifier(Word::Environment) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Environment.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_environment()?.into()
|
|
}
|
|
|
|
// RFC 6134
|
|
Token::Identifier(Word::ValidExtList) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::ExtLists.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_valid_ext_list()?.into()
|
|
}
|
|
|
|
// RFC 5463
|
|
Token::Identifier(Word::Ihave) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Ihave.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_ihave()?.into()
|
|
}
|
|
|
|
// RFC 5232
|
|
Token::Identifier(Word::HasFlag) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Imap4Flags.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_hasflag()?.into()
|
|
}
|
|
|
|
// RFC 5490
|
|
Token::Identifier(Word::MailboxExists) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Mailbox.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_mailboxexists()?.into()
|
|
}
|
|
Token::Identifier(Word::Metadata) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::MboxMetadata.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_metadata()?.into()
|
|
}
|
|
Token::Identifier(Word::MetadataExists) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::MboxMetadata.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_metadataexists()?.into()
|
|
}
|
|
Token::Identifier(Word::ServerMetadata) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::ServerMetadata.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_servermetadata()?.into()
|
|
}
|
|
Token::Identifier(Word::ServerMetadataExists) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::ServerMetadata.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_servermetadataexists()?.into()
|
|
}
|
|
|
|
// RFC 9042
|
|
Token::Identifier(Word::MailboxIdExists) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::MailboxId.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_mailboxidexists()?.into()
|
|
}
|
|
|
|
// RFC 5235
|
|
Token::Identifier(Word::SpamTest) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::SpamTest.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_spamtest()?.into()
|
|
}
|
|
Token::Identifier(Word::VirusTest) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::VirusTest.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_virustest()?.into()
|
|
}
|
|
|
|
// RFC 8579
|
|
Token::Identifier(Word::SpecialUseExists) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::SpecialUse.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
self.parse_test_specialuseexists()?.into()
|
|
}
|
|
|
|
// Expressions extension
|
|
Token::Identifier(Word::Eval) => {
|
|
self.validate_argument(
|
|
0,
|
|
Capability::Expressions.into(),
|
|
token_info.line_num,
|
|
token_info.line_pos,
|
|
)?;
|
|
|
|
Instruction::Eval(self.parse_expr()?)
|
|
}
|
|
Token::Identifier(word) => {
|
|
self.ignore_test()?;
|
|
Test::Invalid(Invalid {
|
|
name: word.to_string(),
|
|
line_num: token_info.line_num,
|
|
line_pos: token_info.line_pos,
|
|
})
|
|
.into()
|
|
}
|
|
#[cfg(test)]
|
|
Token::Unknown(name) if name.contains("test") => {
|
|
use crate::compiler::Value;
|
|
|
|
let mut arguments = Vec::new();
|
|
arguments.push(Value::Text(name.into()));
|
|
while !matches!(
|
|
self.tokens.peek().map(|r| r.map(|t| &t.token)),
|
|
Some(Ok(Token::Comma
|
|
| Token::ParenthesisClose
|
|
| Token::CurlyOpen))
|
|
) {
|
|
arguments.push(match self.tokens.unwrap_next()?.token {
|
|
Token::StringConstant(s) => Value::from(s),
|
|
Token::StringVariable(s) => self
|
|
.tokenize_string(&s, true)
|
|
.map_err(|error_type| CompileError {
|
|
line_num: 0,
|
|
line_pos: 0,
|
|
error_type,
|
|
})?,
|
|
Token::Number(n) => {
|
|
Value::Number(crate::compiler::Number::Integer(n as i64))
|
|
}
|
|
Token::Identifier(s) => Value::Text(s.to_string().into()),
|
|
Token::Tag(s) => Value::Text(format!(":{s}").into()),
|
|
Token::Unknown(s) => Value::Text(s.into()),
|
|
other => panic!("Invalid test param {other:?}"),
|
|
});
|
|
}
|
|
Test::TestCmd {
|
|
arguments,
|
|
is_not: false,
|
|
}
|
|
.into()
|
|
}
|
|
Token::Unknown(name) => {
|
|
self.ignore_test()?;
|
|
Test::Invalid(Invalid {
|
|
name,
|
|
line_num: token_info.line_num,
|
|
line_pos: token_info.line_pos,
|
|
})
|
|
.into()
|
|
}
|
|
_ => return Err(token_info.expected("test name")),
|
|
};
|
|
|
|
while block.p_count > 0 {
|
|
self.tokens.expect_token(Token::ParenthesisClose)?;
|
|
block.p_count -= 1;
|
|
}
|
|
|
|
self.instructions
|
|
.push(if !is_not { test } else { test.set_not() });
|
|
|
|
if block_stack.is_empty() {
|
|
break;
|
|
}
|
|
}
|
|
|
|
self.instructions.push(Instruction::Jz(usize::MAX));
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn parse_expr(&mut self) -> Result<Vec<Expression>, CompileError> {
|
|
let mut next_token = self.tokens.unwrap_next()?;
|
|
let expr = match next_token.token {
|
|
Token::StringConstant(s) => s.into_string().into_bytes(),
|
|
Token::StringVariable(s) => s,
|
|
_ => return Err(next_token.expected("string")),
|
|
};
|
|
|
|
match ExpressionParser::from_tokenizer(Tokenizer::from_iter(
|
|
expr.iter().enumerate().peekable(),
|
|
|var_name, maybe_namespace| self.parse_expr_fnc_or_var(var_name, maybe_namespace),
|
|
))
|
|
.parse()
|
|
{
|
|
Ok(parser) => Ok(parser.output),
|
|
Err(err) => {
|
|
let err = ErrorType::InvalidExpression(format!(
|
|
"{}: {}",
|
|
std::str::from_utf8(&expr).unwrap_or_default(),
|
|
err
|
|
));
|
|
next_token.token = Token::StringVariable(expr);
|
|
Err(next_token.custom(err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<Test> for Instruction {
|
|
fn from(test: Test) -> Self {
|
|
Instruction::Test(test)
|
|
}
|
|
}
|
|
|
|
impl Instruction {
|
|
pub fn set_not(mut self) -> Self {
|
|
match &mut self {
|
|
Instruction::Test(test) => match test {
|
|
Test::True => return Instruction::Test(Test::False),
|
|
Test::False => return Instruction::Test(Test::True),
|
|
Test::Address(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::Envelope(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::Exists(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::Header(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::Size(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::Body(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::Convert(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::Date(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::CurrentDate(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::Duplicate(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::String(op) | Test::Environment(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::NotifyMethodCapability(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::ValidNotifyMethod(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::ValidExtList(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::Ihave(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::HasFlag(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::MailboxExists(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::Metadata(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::MetadataExists(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::MailboxIdExists(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::SpamTest(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::VirusTest(op) => {
|
|
op.is_not = true;
|
|
}
|
|
Test::SpecialUseExists(op) => {
|
|
op.is_not = true;
|
|
}
|
|
#[cfg(test)]
|
|
Test::TestCmd { is_not, .. } => {
|
|
*is_not = true;
|
|
}
|
|
Test::Vacation(_) | Test::Invalid(_) => {}
|
|
},
|
|
Instruction::Eval(expr) => expr.push(Expression::UnaryOperator(UnaryOperator::Not)),
|
|
_ => (),
|
|
}
|
|
self
|
|
}
|
|
}
|