Add tests, write man page

This commit is contained in:
David Bürgin 2020-02-08 10:43:52 +01:00
parent 9d26e0cb90
commit e6817c03e7
17 changed files with 834 additions and 151 deletions

16
Cargo.lock generated
View file

@ -69,9 +69,9 @@ dependencies = [
[[package]]
name = "ipnet"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f4b06b21db0228860c8dfd17d2106c49c7c6bd07477a4036985347d84def04"
checksum = "a859057dc563d1388c1e816f98a1892629075fc046ed06e845b883bb8b2916fb"
[[package]]
name = "libc"
@ -81,7 +81,9 @@ checksum = "d515b1f41455adea1313a4a2ac8a8a477634fbae63cc6100e3aebb207ce61558"
[[package]]
name = "milter"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf37b1c8105474d1c2b1eef110ded017f097543f09dddf97323b3a320b540e6b"
dependencies = [
"bitflags",
"libc",
@ -92,7 +94,9 @@ dependencies = [
[[package]]
name = "milter-callback"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bd474a4ccfa8782f6a3f8696e3c4f409bc360544b8ba30fbe4104c2844196d"
dependencies = [
"proc-macro2",
"quote",
@ -101,9 +105,9 @@ dependencies = [
[[package]]
name = "milter-sys"
version = "0.1.4"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1b559636bc813ff22619c3a9910ab8a7abfcfede7defe1d2f70b0b5cfba1f76"
checksum = "baabd032f5ed0fdbe52da16a442b0339951c9e538d93ad8d1662243c90da2c12"
dependencies = [
"libc",
"pkg-config",

View file

@ -1,11 +1,11 @@
[package]
name = "spamassassin-milter"
version = "0.0.1"
version = "0.0.1" # remember to update html_root_url
description = "Milter for spam filtering with SpamAssassin"
authors = ["David Bürgin <dbuergin@gluet.ch>"]
edition = "2018"
keywords = ["email", "milter", "spam", "spamassassin"]
categories = ["email"]
keywords = ["email", "milter", "spam", "spamassassin"]
license = "GPL-3.0-or-later"
repository = "https://gitlab.com/glts/spamassassin-milter"
readme = "README.md"
@ -15,9 +15,15 @@ chrono = "0.4"
clap = "2"
ipnet = "2"
libc = "0.2"
milter = { version = "0.1", path = "../milter" }
milter = "0.1"
once_cell = "1"
[badges]
gitlab = { repository = "glts/spamassassin-milter" }
maintenance = { status = "actively-developed" }
# Temporary hack to make the build work on docs.rs, see
# https://github.com/rust-lang/docs.rs/issues/191.
[package.metadata.docs.rs]
rustc-args = ["--cfg", "milter_sys_docs_rs"]
rustdoc-args = ["--cfg", "milter_sys_docs_rs"]

View file

@ -1,26 +1,135 @@
.TH SPAMASSASSIN-MILTER 8 2020-01-30
.TH SPAMASSASSIN-MILTER 8 2020-02-07
.SH NAME
spamassassin-milter \- milter for spam filtering with SpamAssassin
.SH SYNOPSIS
.B spamassassin-milter
[\fB\-\-trusted-networks\fR \fIBITS\fR]
[\fB\-a\fR]
[\fB\-B\fR]
[\fB\-H\fR]
[\fB\-n\fR]
[\fB\-r\fR]
[\fB\-s\fR \fIBYTES\fR]
[\fB\-t\fR \fINETS\fR]
[\fB\-v\fR]
.IR SOCKET
[\fB\-\-\fR \fISPAMC_ARGS\fR...]
.SH DESCRIPTION
.B spamassassin-milter
is a milter that filters email through a SpamAssassin server.
is a milter that filters email through SpamAssassin server using the spamc
client.
It reads the response from SpamAssassin and adds its
.B X-Spam-
headers to the message, and can optionally apply header and body rewriting to
messages flagged as spam, or reject them at the SMTP level.
A message flagged as spam is a message with a header
.BR "X-Spam-Flag: YES" .
.PP
This is currently being developed.
Start every sentence on a new line apparently.
To do.
The mandatory
.I SOCKET
argument specifies the listening socket to open.
.I SOCKET
can be either a TCP socket in the form
.BR inet: "\fIPORT\fR" : "\fIHOST\fR"
or
.BR inet6: "\fIPORT\fR" : "\fIHOST\fR"
(for example,
.BR inet:3000:localhost ),
or a UNIX domain socket in the form
.BR unix: "\fIPATH\fR"
(for example,
.BR unix:/run/spamassassin-milter.sock ).
After the options and argument, additional arguments
.IR SPAMC_ARGS ...
listed after
.B \-\-
are gathered as arguments to pass to the
.B spamc
invocation.
.B spamassassin-milter
has reasonable defaults and if run with no options, will apply modifications
received from SpamAssassin server.
.\" 3) description of larger context, spamc spamd mta (eg spamc config file!)
.PP
.B spamassassin-milter
is a light-weight integration component, enabling use of SpamAssassin with a
milter-capable MTA.
As such, users must first be familiar with the setup and configuration options
of the components participating, namely, the SpamAssassin programs
.B spamd
(SpamAssassin server) and
.BR spamc ,
and the MTA (Postfix).
.SH OPTIONS
.TP
.BR \-a ", " \-\-auth-untrusted
Treat authenticated senders as untrusted.
If this option is not used, authenticated senders are trusted and their messages
are not processed with SpamAssassin.
.TP
.BR \-n ", " \-\-dry-run
Process messages as usual, but do not take action or apply any modifications.
Combined with
.BR \-\-verbose ,
this gives accurate insight into what would happen if run without
.BR \-\-dry-run .
.TP
.BR \-h ", " \-\-help
Print usage information.
.TP
.BR \-s ", " \-\-max-message-size " \fIBYTES\fR"
Maximum message size in bytes to pass to
.BR spamc .
.I BYTES
must be equal to or greater than the max size configured for
.BR spamc ,
else it is possible that the body of spam messages is truncated to the size
configured for
.BR spamc .
Defaults to the
.B spamc
default, 512000.
.TP
.BR \-B ", " \-\-preserve-body
Suppress rewriting of spam message body.
If this option is not used, the message body of messages flagged as spam (as
well as the values of related headers
.B MIME-Version
and
.BR Content-Type ,
if necessary)
is replaced with the body received from SpamAssassin.
.TP
.BR \-H ", " \-\-preserve-headers
Suppress rewriting of headers
.B Subject
.B From
.B To
of spam messages.
If this option is not used, these headers of messages flagged as spam will be
replaced with the values received from SpamAssassin, if necessary.
.TP
.BR \-r ", " \-\-reject-spam
Reject messages flagged as spam at the SMTP level.
Rejection results in a permanent SMTP error being returned to the client, and
the message is not delivered.
.TP
.BR \-t ", " \-\-trusted-networks " \fINETS\fR"
Trust connections coming from the IP networks or addresses
.IR NETS .
Connections from IP addresses contained in trusted networks are not processed
with SpamAssassin.
.I NETS
is a comma-separated list of IP network addresses, for example,
.BR ::1/128,127.0.0.0/8,192.168.1.39 .
If this option is not used, all connections from loopback addresses are trusted.
.TP
.BR \-v ", " \-\-verbose
Enable verbose operation logging.
If this option is not used, only unexpected error conditions are logged (that
is, printed to stderr).
.TP
.BR \-\-max-message-size =\fISIZE\fR
Maximum message size in bytes to pass to spamc.
.BR \-V ", " \-\-version
Print version information.
.SH SEE ALSO
.BR spamassassin (1p),
.BR spamd (8p),

View file

@ -34,7 +34,6 @@ fn handle_negotiate(
let mut req_actions = Actions::empty();
if !config::get().dry_run() {
req_actions |= Actions::ADD_HEADER | Actions::REPLACE_HEADER;
if !config::get().preserve_body() {
req_actions |= Actions::REPLACE_BODY;
}
@ -93,7 +92,7 @@ fn handle_helo(mut ctx: Context<Connection>, helo_host: &str) -> milter::Result<
fn handle_mail(mut ctx: Context<Connection>, smtp_args: Vec<&str>) -> milter::Result<Status> {
let conn = ctx.data.borrow_mut().unwrap();
if !config::get().filter_authenticated() {
if !config::get().auth_untrusted() {
if let Some(login) = ctx.api.macro_value("{auth_authen}")? {
verbose!("accepted message from sender authenticated as \"{}\"", login);
return Ok(Status::Accept);
@ -122,9 +121,15 @@ fn handle_data(mut ctx: Context<Connection>) -> milter::Result<Status> {
let conn = ctx.data.borrow_mut().unwrap();
let client = conn.client.as_mut().unwrap();
let id = queue_id(&ctx.api)?;
if let Err(e) = client.connect() {
eprintln!("{}: cannot connect to spamc: {}", id, e);
return Ok(Status::Tempfail);
}
let client_ip = conn.client_ip.to_string();
client.connect()?;
client.send_envelope_sender()?;
client.send_envelope_recipients()?;
client.send_forged_received_header(
@ -135,7 +140,7 @@ fn handle_data(mut ctx: Context<Connection>) -> milter::Result<Status> {
.and_then(|v| v.split_ascii_whitespace().next())
.unwrap_or("Postfix"),
ctx.api.macro_value("{tls_version}")?.is_some(),
queue_id(&ctx.api)?,
id,
&Local::now().to_rfc2822(),
)?;
@ -170,13 +175,15 @@ fn handle_body(mut ctx: Context<Connection>, bytes: &[u8]) -> milter::Result<Sta
client.send_body_chunk(bytes)?;
let max = config::get().max_message_size();
Ok(if client.bytes_written() >= max {
let status = if client.bytes_written() >= max {
let id = queue_id(&ctx.api)?;
verbose!("{}: skipping rest of message larger than {} bytes", id, max);
Status::Skip
} else {
Status::Continue
})
};
Ok(status)
}
#[on_eom(eom_callback)]
@ -185,12 +192,14 @@ fn handle_eom(mut ctx: Context<Connection>) -> milter::Result<Status> {
let client = conn.client.take().unwrap();
let id = queue_id(&ctx.api)?;
client.process(id, &ctx.api)
let config = config::get();
client.process(id, &ctx.api, config)
}
#[on_abort(abort_callback)]
fn handle_abort(mut ctx: Context<Connection>) -> milter::Result<Status> {
ctx.data.borrow_mut().unwrap().client.take();
ctx.data.borrow_mut().unwrap().client = None;
Ok(Status::Continue)
}

View file

@ -1,9 +1,9 @@
use crate::{
config,
config::Config,
email::{self, Email, HeaderMap, HeaderRewriter},
error::{Error, Result},
};
use milter::{ActionMethods, Status};
use milter::{ActionContext, SetErrorReply, Status};
use std::{
any::Any,
io::Write,
@ -184,7 +184,12 @@ impl Client {
Ok(self.bytes += writer.write(bytes)?)
}
pub fn process(mut self, id: &str, actions: &impl ActionMethods) -> milter::Result<Status> {
pub fn process(
mut self,
id: &str,
actions: &(impl ActionContext + SetErrorReply),
config: &Config,
) -> milter::Result<Status> {
let output = match self.process.finish() {
Ok(output) => output,
Err(e) => {
@ -201,60 +206,70 @@ impl Client {
}
};
let mut rewriter = HeaderRewriter::new(self.headers);
let mut rewriter = HeaderRewriter::new(self.headers, config);
for header in email.header {
rewriter.process_header(header.name, header.value);
}
let spam = rewriter.is_flagged_spam();
let dry_run = config::get().dry_run();
if spam && config::get().reject_spam() {
let status = if dry_run {
verbose!("{}: rejected message flagged as spam [dry-run, not done]", id);
Status::Accept
} else {
verbose!("{}: rejected message flagged as spam", id);
Status::Reject
};
return Ok(status);
if spam && config.reject_spam() {
return reject_spam(id, actions, config);
}
let actions = if dry_run { None } else { Some(actions) };
rewriter.rewrite_spam_assassin_headers(id, actions)?;
if spam {
if !config::get().preserve_headers() {
if !config.preserve_headers() {
rewriter.rewrite_rewrite_headers(id, actions)?;
}
if !config::get().preserve_body() {
if !config.preserve_body() {
rewriter.rewrite_report_headers(id, actions)?;
email::replace_body(id, email.body, actions)?;
email::replace_body(id, email.body, actions, config)?;
}
}
verbose!("{}: finished processing", id);
verbose!(config, "{}: finished processing", id);
Ok(Status::Accept)
}
}
fn reject_spam(
id: &str,
actions: &impl SetErrorReply,
config: &Config,
) -> milter::Result<Status> {
Ok(if config.dry_run() {
verbose!(config, "{}: rejected message flagged as spam [dry-run, not done]", id);
Status::Accept
} else {
// TODO Message text needed? For example "Rejected spam."?
actions.set_error_reply("550", Some("5.7.1"), vec![])?;
verbose!(config, "{}: rejected message flagged as spam", id);
Status::Reject
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use std::{cell::RefCell, collections::HashSet};
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
struct MockSpamc {
buf: Vec<u8>,
output: Option<Vec<u8>>,
}
impl MockSpamc {
pub fn new() -> Self {
fn new() -> Self {
Default::default()
}
fn with_output(output: Vec<u8>) -> Self {
Self { output: Some(output), ..Default::default() }
}
}
impl Process for MockSpamc {
@ -267,7 +282,11 @@ mod tests {
}
fn finish(&mut self) -> Result<Vec<u8>> {
Ok(self.buf.clone())
Ok(if let Some(output) = &self.output {
output.clone()
} else {
self.buf.clone()
})
}
fn as_any(&self) -> &dyn Any {
@ -279,22 +298,167 @@ mod tests {
process.as_any().downcast_ref().unwrap()
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
enum Action {
AddHeader(String, String),
ReplaceHeader(String, usize, Option<String>),
AppendBodyChunk(Vec<u8>),
SetErrorReply(String, Option<String>, Vec<String>),
}
#[derive(Clone, Debug, Default)]
struct MockActionContext {
called: RefCell<Vec<Action>>,
}
impl MockActionContext {
fn new() -> Self {
Default::default()
}
}
impl ActionContext for MockActionContext {
fn add_header(&self, name: &str, value: &str) -> milter::Result<()> {
Ok(self.called.borrow_mut().push(
Action::AddHeader(name.to_owned(), value.to_owned())
))
}
fn replace_header(&self, name: &str, index: usize, value: Option<&str>) -> milter::Result<()> {
Ok(self.called.borrow_mut().push(
Action::ReplaceHeader(name.to_owned(), index, value.map(|v| v.to_owned()))
))
}
fn append_body_chunk(&self, bytes: &[u8]) -> milter::Result<()> {
Ok(self.called.borrow_mut().push(
Action::AppendBodyChunk(bytes.to_owned())
))
}
fn replace_sender(&self, _: &str, _: Option<&str>) -> milter::Result<()> {
unimplemented!();
}
fn add_recipient(&self, _: &str, _: Option<&str>) -> milter::Result<()> {
unimplemented!();
}
fn remove_recipient(&self, _: &str) -> milter::Result<()> {
unimplemented!();
}
fn insert_header(&self, _: usize, _: &str, _: &str) -> milter::Result<()> {
unimplemented!();
}
fn quarantine(&self, _: &str) -> milter::Result<()> {
unimplemented!();
}
fn signal_progress(&self) -> milter::Result<()> {
unimplemented!();
}
}
impl SetErrorReply for MockActionContext {
fn set_error_reply(
&self,
code: &str,
ext_code: Option<&str>,
msg_lines: Vec<&str>,
) -> milter::Result<()> {
Ok(self.called.borrow_mut().push(Action::SetErrorReply(
code.to_owned(),
ext_code.map(|c| c.to_owned()),
msg_lines.into_iter().map(|l| l.to_owned()).collect(),
)))
}
}
#[test]
fn client_flow() {
let config = Config::builder().build();
config::init(config);
fn client_send_writes_bytes() {
let spamc = MockSpamc::new();
let sender = String::from("sender");
let mut client = Client::new(spamc, sender);
client.send_header("name", "value").unwrap();
let mut client = Client::new(spamc, String::from("sender"));
client.send_header("name1", "value1").unwrap();
client.send_header("name2", "value2\n\tcontinued").unwrap();
client.send_eoh().unwrap();
client.send_body_chunk(b"body").unwrap();
assert_eq!(client.bytes_written(), 19);
assert_eq!(client.bytes_written(), 48);
assert_eq!(
as_mock_spamc(client.process.as_ref()).buf,
Vec::from(b"name1: value1\r\nname2: value2\r\n\tcontinued\r\n\r\nbody" as &[_])
);
}
let spamc = as_mock_spamc(client.process.as_ref());
assert_eq!(&spamc.buf, b"name: value\r\n\r\nbody");
#[test]
fn client_send_no_spam_assassin_headers() {
let spamc = MockSpamc::new();
let mut client = Client::new(spamc, String::from("sender"));
client.send_header("x-spam-flag", "YES").unwrap();
client.send_header("x-spam-bogus", "x").unwrap();
assert_eq!(client.bytes_written(), 0);
}
#[test]
fn client_process_invalid_response() {
let spamc = MockSpamc::with_output(b"invalid message response".to_vec());
let actions = MockActionContext::new();
let config = Config::builder().build();
let client = Client::new(spamc, String::from("sender"));
let status = client.process("id", &actions, &config).unwrap();
assert_eq!(status, Status::Tempfail);
}
#[test]
fn client_process_reject_spam() {
let spamc = MockSpamc::with_output(b"X-Spam-Flag: YES\r\n\r\n".to_vec());
let actions = MockActionContext::new();
let mut builder = Config::builder();
builder.set_reject_spam(true);
let config = builder.build();
let client = Client::new(spamc, String::from("sender"));
let status = client.process("id", &actions, &config).unwrap();
assert_eq!(status, Status::Reject);
assert_eq!(
actions.called.borrow().first(),
Some(&Action::SetErrorReply("550".into(), Some("5.7.1".into()), vec![]))
);
}
#[test]
fn client_process_rewrite_spam() {
let spamc = MockSpamc::with_output(b"X-Spam-Flag: YES\r\nX-Spam-Level: *****\r\n\r\nReport".to_vec());
let actions = MockActionContext::new();
let config = Config::builder().build();
let mut client = Client::new(spamc, String::from("sender"));
client.send_header("x-spam-level", "*").unwrap();
client.send_header("x-spam-report", "...").unwrap();
let status = client.process("id", &actions, &config).unwrap();
assert_eq!(status, Status::Accept);
let called = actions.called.borrow();
assert_eq!(called.len(), 4);
let mut expected = HashSet::new();
expected.insert(Action::AddHeader("X-Spam-Flag".into(), "YES".into()));
expected.insert(Action::ReplaceHeader("X-Spam-Level".into(), 1, Some("*****".into())));
expected.insert(Action::ReplaceHeader("X-Spam-Report".into(), 1, None));
expected.insert(Action::AppendBodyChunk(b"Report".to_vec()));
for a in called.iter() {
assert!(expected.contains(&a));
}
}
}

View file

@ -2,11 +2,11 @@ use std::mem;
/// An array map with ASCII-case-insensitive `&str` keys.
#[derive(Clone, Debug, Default)]
pub struct StrArrayMap<'k, V> {
pub struct StrVecMap<'k, V> {
entries: Vec<(&'k str, V)>,
}
impl<'k, V> StrArrayMap<'k, V> {
impl<'k, V> StrVecMap<'k, V> {
pub fn new() -> Self {
Self { entries: Default::default() }
}
@ -53,11 +53,11 @@ impl<'k, V> StrArrayMap<'k, V> {
/// An array set containing ASCII-case-insensitive `&str` elements.
#[derive(Clone, Debug, Default)]
pub struct StrArraySet<'e> {
map: StrArrayMap<'e, ()>,
pub struct StrVecSet<'e> {
map: StrVecMap<'e, ()>,
}
impl<'e> StrArraySet<'e> {
impl<'e> StrVecSet<'e> {
pub fn new() -> Self {
Default::default()
}
@ -85,7 +85,7 @@ mod tests {
#[test]
fn map_contains_key() {
let mut map = StrArrayMap::new();
let mut map = StrVecMap::new();
map.insert("KEY1", 1);
assert_eq!(map.contains_key("key1"), true);
@ -94,7 +94,7 @@ mod tests {
#[test]
fn map_get_key_value() {
let mut map = StrArrayMap::new();
let mut map = StrVecMap::new();
map.insert("KEY1", 1);
assert_eq!(map.get_key_value("key1"), Some(("KEY1", &1)));
@ -103,7 +103,7 @@ mod tests {
#[test]
fn map_insert() {
let mut map = StrArrayMap::new();
let mut map = StrVecMap::new();
assert_eq!(map.insert("KEY1", 1), None);
assert_eq!(map.insert("KEY2", 2), None);
@ -117,7 +117,7 @@ mod tests {
#[test]
fn map_insert_if_absent() {
let mut map = StrArrayMap::new();
let mut map = StrVecMap::new();
assert_eq!(map.insert_if_absent("KEY1", 1), None);
assert_eq!(map.insert_if_absent("KEY2", 2), None);
@ -131,7 +131,7 @@ mod tests {
#[test]
fn set_get() {
let mut set = StrArraySet::new();
let mut set = StrVecSet::new();
set.insert("KEY1");
assert_eq!(set.get("key1"), Some("KEY1"));
@ -140,7 +140,7 @@ mod tests {
#[test]
fn set_insert() {
let mut set = StrArraySet::new();
let mut set = StrVecSet::new();
assert_eq!(set.insert("KEY1"), true);
assert_eq!(set.insert("KEY2"), true);

View file

@ -7,7 +7,7 @@ use std::{collections::HashSet, net::IpAddr};
/// [`Config`]: struct.Config.html
#[derive(Clone, Debug, Default)]
pub struct ConfigBuilder {
filter_authenticated: bool,
auth_untrusted: bool,
reject_spam: bool,
preserve_headers: bool,
preserve_body: bool,
@ -31,8 +31,8 @@ impl ConfigBuilder {
self
}
pub fn set_filter_authenticated(&mut self, value: bool) -> &mut Self {
self.filter_authenticated = value;
pub fn set_auth_untrusted(&mut self, value: bool) -> &mut Self {
self.auth_untrusted = value;
self
}
@ -56,8 +56,8 @@ impl ConfigBuilder {
self
}
pub fn set_spamc_args(&mut self, spamc_args: Vec<String>) -> &mut Self {
self.spamc_args = spamc_args;
pub fn set_spamc_args(&mut self, value: Vec<String>) -> &mut Self {
self.spamc_args = value;
self
}
@ -73,7 +73,7 @@ impl ConfigBuilder {
pub fn build(self) -> Config {
Config {
filter_authenticated: self.filter_authenticated,
auth_untrusted: self.auth_untrusted,
reject_spam: self.reject_spam,
preserve_headers: self.preserve_headers,
preserve_body: self.preserve_body,
@ -90,7 +90,7 @@ impl ConfigBuilder {
/// A configuration object for SpamAssassin Milter.
#[derive(Clone, Debug, Default)]
pub struct Config {
filter_authenticated: bool,
auth_untrusted: bool,
reject_spam: bool,
preserve_headers: bool,
preserve_body: bool,
@ -113,8 +113,8 @@ impl Config {
}
}
pub fn filter_authenticated(&self) -> bool {
self.filter_authenticated
pub fn auth_untrusted(&self) -> bool {
self.auth_untrusted
}
pub fn reject_spam(&self) -> bool {

View file

@ -1,8 +1,9 @@
use crate::{
collections::{StrArrayMap, StrArraySet},
collections::{StrVecMap, StrVecSet},
config::Config,
error::{Error, Result},
};
use milter::ActionMethods;
use milter::ActionContext;
use once_cell::sync::Lazy;
use std::{
cmp,
@ -65,7 +66,7 @@ fn header_lines(header: &[u8]) -> Vec<&[u8]> {
}
if start != i {
lines.push(&header[start..]);
lines.push(&header[start..i]);
}
lines
@ -86,7 +87,7 @@ fn parse_header_line(bytes: &[u8]) -> Result<Header<'_>> {
}
pub fn ensure_crlf(s: &str) -> String {
// Ensure existing occurrences of "\r\n" remain unchanged.
// For symmetry, ensure existing occurrences of b"\r\n" remain unchanged.
s.split('\n')
.map(|line| match line.as_bytes().last() {
Some(&last) if last == b'\r' => &line[..(line.len() - 1)],
@ -107,9 +108,10 @@ pub fn is_spam_assassin_header(name: &str) -> bool {
name[..cmp::min(prefix.len(), name.len())].eq_ignore_ascii_case(prefix)
}
pub type HeaderMap<'k> = StrArrayMap<'k, String>; // values use CRLF line breaks
pub type HeaderSet<'e> = StrArraySet<'e>;
pub type HeaderMap<'k> = StrVecMap<'k, String>; // values use CRLF line breaks
pub type HeaderSet<'e> = StrVecSet<'e>;
// Selected subset of X-Spam- headers for which we assume responsibility.
pub static SPAM_ASSASSIN_HEADERS: Lazy<HeaderSet<'static>> = Lazy::new(|| {
let mut h = HeaderSet::new();
h.insert("X-Spam-Checker-Version");
@ -138,24 +140,30 @@ pub static REPORT_HEADERS: Lazy<HeaderSet<'static>> = Lazy::new(|| {
/// A header rewriter going between incoming headers, and headers returned from
/// the SpamAssassin service. Operates only on the first occurrence of headers
/// with the same name.
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug)]
pub struct HeaderRewriter<'a> {
original: HeaderMap<'a>,
processed: HeaderSet<'a>,
spam_assassin_mods: Vec<HeaderMod<'a>>,
rewrite_mods: Vec<HeaderMod<'a>>,
report_mods: Vec<HeaderMod<'a>>,
config: &'a Config,
}
impl<'a> HeaderRewriter<'a> {
pub fn new(original: HeaderMap<'a>) -> Self {
pub fn new(original: HeaderMap<'a>, config: &'a Config) -> Self {
Self {
original,
..Default::default()
processed: Default::default(),
spam_assassin_mods: Default::default(),
rewrite_mods: Default::default(),
report_mods: Default::default(),
config,
}
}
pub fn process_header(&mut self, name: &'a str, value: &'a str) {
// Assumes that the value is normalised to using CRLF line breaks.
if is_spam_assassin_header(name) {
if let Some(m) = self.convert_to_header_mod(name, value) {
self.spam_assassin_mods.push(m);
@ -202,25 +210,25 @@ impl<'a> HeaderRewriter<'a> {
pub fn rewrite_report_headers(
&self,
id: &str,
actions: Option<&impl ActionMethods>,
actions: &impl ActionContext,
) -> milter::Result<()> {
execute_mods(id, self.report_mods.iter(), actions)
execute_mods(id, self.report_mods.iter(), actions, self.config)
}
pub fn rewrite_rewrite_headers(
&self,
id: &str,
actions: Option<&impl ActionMethods>,
actions: &impl ActionContext,
) -> milter::Result<()> {
execute_mods(id, self.rewrite_mods.iter(), actions)
execute_mods(id, self.rewrite_mods.iter(), actions, self.config)
}
pub fn rewrite_spam_assassin_headers(
&self,
id: &str,
actions: Option<&impl ActionMethods>,
actions: &impl ActionContext,
) -> milter::Result<()> {
execute_mods(id, self.spam_assassin_mods.iter(), actions)?;
execute_mods(id, self.spam_assassin_mods.iter(), actions, self.config)?;
// Delete incoming SpamAssassin headers not returned by SpamAssassin.
let deletions = SPAM_ASSASSIN_HEADERS.iter()
@ -228,24 +236,25 @@ impl<'a> HeaderRewriter<'a> {
.map(|name| HeaderMod::Delete { name })
.collect::<Vec<_>>();
execute_mods(id, deletions.iter(), actions)
execute_mods(id, deletions.iter(), actions, self.config)
}
}
fn execute_mods<'a, I>(
id: &str,
mods: I,
actions: Option<&impl ActionMethods>,
actions: &impl ActionContext,
config: &Config,
) -> milter::Result<()>
where
I: IntoIterator<Item = &'a HeaderMod<'a>>,
{
Ok(for m in mods.into_iter() {
if let Some(actions) = actions {
verbose!("{}: rewriting header: {}", id, m);
m.execute(actions)?;
if config.dry_run() {
verbose!(config, "{}: rewriting header: {} [dry-run, not done]", id, m);
} else {
verbose!("{}: rewriting header: {} [dry-run, not done]", id, m);
verbose!(config, "{}: rewriting header: {}", id, m);
m.execute(actions)?;
}
})
}
@ -253,13 +262,14 @@ where
pub fn replace_body(
id: &str,
body: &[u8],
actions: Option<&impl ActionMethods>,
actions: &impl ActionContext,
config: &Config,
) -> milter::Result<()> {
Ok(if let Some(actions) = actions {
verbose!("{}: replacing message body", id);
actions.append_body_chunk(body)?;
Ok(if config.dry_run() {
verbose!(config, "{}: replacing message body [dry-run, not done]", id);
} else {
verbose!("{}: replacing message body [dry-run, not done]", id);
verbose!(config, "{}: replacing message body", id);
actions.append_body_chunk(body)?;
})
}
@ -273,9 +283,11 @@ enum HeaderMod<'a> {
}
impl HeaderMod<'_> {
fn execute(&self, actions: &impl ActionMethods) -> milter::Result<()> {
fn execute(&self, actions: &impl ActionContext) -> milter::Result<()> {
use HeaderMod::*;
// Experiment shows that the milter library is smart enough to treat the
// name in case-insensitive manner, eg Subject may replace sUbject.
match self {
Add { name, value } => actions.add_header(name, &ensure_lf(value)),
Replace { name, value } => actions.replace_header(name, 1, Some(&ensure_lf(value))),
@ -322,4 +334,109 @@ mod tests {
assert_eq!(header_lines(b"x\r\ny"), vec![b"x" as &[_], b"y" as &[_]]);
assert_eq!(header_lines(b"x\r\ny\r\n"), vec![b"x" as &[_], b"y" as &[_]]);
}
#[test]
fn email_header_lines_multi() {
assert_eq!(header_lines(b"x\r\n\t"), vec![b"x\r\n\t" as &[_]]);
assert_eq!(header_lines(b"x\r\n\ty"), vec![b"x\r\n\ty" as &[_]]);
assert_eq!(header_lines(b"x\r\n\ty\r\n"), vec![b"x\r\n\ty" as &[_]]);
assert_eq!(header_lines(b"x\r\n\ty\r\n\tz\r\nq"), vec![b"x\r\n\ty\r\n\tz" as &[_], b"q" as &[_]]);
}
#[test]
fn email_parse_header_line() {
assert_eq!(parse_header_line(b"no colon"), Err(Error::ParseEmail));
assert_eq!(parse_header_line(b":empty name"), Err(Error::ParseEmail));
assert_eq!(parse_header_line(b"\t : whitespace name"), Err(Error::ParseEmail));
assert_eq!(parse_header_line(b"name:value"), Ok(Header { name: "name", value: "value" }));
assert_eq!(parse_header_line(b"name: value"), Ok(Header { name: "name", value: "value" }));
assert_eq!(parse_header_line(b"name:\r\n\tvalue"), Ok(Header { name: "name", value: "\r\n\tvalue" }));
}
#[test]
fn ensure_crlf_ok() {
assert_eq!(&ensure_crlf(""), "");
assert_eq!(&ensure_crlf("\n"), "\r\n");
assert_eq!(&ensure_crlf("\r\n"), "\r\n");
assert_eq!(&ensure_crlf("a\nb"), "a\r\nb");
assert_eq!(&ensure_crlf("a\n\nb"), "a\r\n\r\nb");
assert_eq!(&ensure_crlf("a\r\n\nb"), "a\r\n\r\nb");
assert_eq!(&ensure_crlf("a\n\r\nb"), "a\r\n\r\nb");
assert_eq!(&ensure_crlf("a\r\nb\n"), "a\r\nb\r\n");
}
#[test]
fn ensure_lf_ok() {
assert_eq!(&ensure_lf(""), "");
assert_eq!(&ensure_lf("\n"), "\n");
assert_eq!(&ensure_lf("\r\n"), "\n");
assert_eq!(&ensure_lf("a\nb"), "a\nb");
assert_eq!(&ensure_lf("a\n\nb"), "a\n\nb");
assert_eq!(&ensure_lf("a\r\n\nb"), "a\n\nb");
assert_eq!(&ensure_lf("a\n\r\nb"), "a\n\nb");
assert_eq!(&ensure_lf("a\r\nb\n"), "a\nb\n");
}
#[test]
fn spam_assassin_header_predicate() {
assert!(is_spam_assassin_header("x-spam-status"));
assert!(is_spam_assassin_header("x-spam-bogus"));
assert!(is_spam_assassin_header("x-spam-"));
assert!(!is_spam_assassin_header("x-spam"));
assert!(!is_spam_assassin_header("bogus"));
}
#[test]
fn header_rewriter_flags_spam() {
let config = Config::builder().build();
let mut headers = HeaderMap::new();
headers.insert("x-spam-flag", String::from("no"));
let mut rewriter = HeaderRewriter::new(headers, &config);
rewriter.process_header("X-Spam-Flag", "YES");
assert!(rewriter.is_flagged_spam());
}
#[test]
fn header_rewriter_processes_first_occurrence_only() {
let config = Config::builder().build();
let headers = HeaderMap::new();
let mut rewriter = HeaderRewriter::new(headers, &config);
rewriter.process_header("X-Spam-Flag", "NO");
rewriter.process_header("X-Spam-Flag", "YES");
let mut mods = rewriter.spam_assassin_mods.into_iter();
match mods.next().unwrap() {
HeaderMod::Add { name, value } => {
assert_eq!(name, "X-Spam-Flag");
assert_eq!(value, "NO");
}
_ => panic!(),
}
assert!(mods.next().is_none());
}
#[test]
fn header_rewriter_replaces_different_values() {
let config = Config::builder().build();
let mut headers = HeaderMap::new();
headers.insert("x-spam-level", String::from("***"));
headers.insert("x-spam-report", String::from("original"));
let mut rewriter = HeaderRewriter::new(headers, &config);
rewriter.process_header("X-Spam-Level", "***");
rewriter.process_header("X-Spam-Report", "new");
let mut mods = rewriter.spam_assassin_mods.into_iter();
match mods.next().unwrap() {
HeaderMod::Replace { name, value } => {
assert_eq!(name, "X-Spam-Report");
assert_eq!(value, "new");
}
_ => panic!(),
}
assert!(mods.next().is_none());
}
}

View file

@ -1,8 +1,14 @@
//! The SpamAssassin Milter application library.
#![doc(html_root_url = "https://docs.rs/spamassassin-milter/0.0.1")]
#![macro_use]
macro_rules! verbose {
($config:ident, $($arg:tt)*) => {
if $config.verbose() {
::std::eprintln!($($arg)*);
}
};
($($arg:tt)*) => {
if $crate::config::get().verbose() {
::std::eprintln!($($arg)*);
@ -31,6 +37,11 @@ pub const VERSION: &str = "0.0.1";
/// configuration.
///
/// This is a blocking call.
///
/// # Errors
///
/// If execution of the milter fails, an error variant of type `milter::Error`
/// is returned.
pub fn run(socket: &str, config: Config) -> milter::Result<()> {
config::init(config);

View file

@ -1,14 +1,10 @@
use clap::{App, Arg, ArgMatches};
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
use spamassassin_milter::Config;
use std::{
net::{AddrParseError, IpAddr},
process,
};
use std::{net::IpAddr, process};
const ARG_SOCKET: &str = "SOCKET";
const ARG_TRUSTED_NETWORKS: &str = "TRUSTED_NETWORKS";
const ARG_FILTER_AUTHENTICATED: &str = "FILTER_AUTHENTICATED";
const ARG_AUTH_UNTRUSTED: &str = "AUTH_UNTRUSTED";
const ARG_DRY_RUN: &str = "DRY_RUN";
const ARG_PRESERVE_HEADERS: &str = "PRESERVE_HEADERS";
const ARG_PRESERVE_BODY: &str = "PRESERVE_BODY";
@ -24,30 +20,36 @@ fn main() {
.help("Listening socket of the milter")
.required(true))
.arg(Arg::with_name(ARG_TRUSTED_NETWORKS)
.long("trusted-networks")
.short("t")
.long("trusted-networks")
.value_name("NETS")
.use_delimiter(true)
.help("Trust connections from comma-separated networks"))
.arg(Arg::with_name(ARG_FILTER_AUTHENTICATED)
.long("filter-authenticated")
.help("Also filter messages of authenticated senders"))
.help("Trust connections from these networks"))
.arg(Arg::with_name(ARG_AUTH_UNTRUSTED)
.short("a")
.long("auth-untrusted")
.help("Treat authenticated senders as untrusted"))
.arg(Arg::with_name(ARG_DRY_RUN)
.short("n")
.long("dry-run")
.help("Process message but preserve message exactly as-is"))
.help("Process messages without applying any changes"))
.arg(Arg::with_name(ARG_PRESERVE_HEADERS)
.short("H")
.long("preserve-headers")
.help("Suppress Subject, To, From header rewriting"))
.help("Suppress rewriting of Subject/From/To headers"))
.arg(Arg::with_name(ARG_PRESERVE_BODY)
.short("B")
.long("preserve-body")
.help("Suppress rewriting of message body"))
.arg(Arg::with_name(ARG_REJECT_SPAM)
.short("r")
.long("reject-spam")
.help("Reject messages flagged as spam"))
.arg(Arg::with_name(ARG_MAX_MESSAGE_SIZE)
.short("s")
.long("max-message-size")
.value_name("SIZE")
.help("Maximum message size in bytes to pass to spamc"))
.value_name("BYTES")
.help("Maximum message size to process"))
.arg(Arg::with_name(ARG_VERBOSE)
.short("v")
.long("verbose")
@ -59,10 +61,13 @@ fn main() {
.get_matches();
let socket = matches.value_of(ARG_SOCKET).unwrap();
let config = build_config(&matches);
// println!("{:?}", i);
// println!("{:?}", matches.values_of("spamc"));
let config = match build_config(&matches) {
Ok(config) => config,
Err(msg) => {
eprintln!("error: {}", msg);
process::exit(1);
}
};
if let Err(e) = spamassassin_milter::run(socket, config) {
eprintln!("error: {}", e);
@ -70,19 +75,37 @@ fn main() {
}
}
fn build_config(matches: &ArgMatches<'_>) -> Config {
fn build_config(matches: &ArgMatches<'_>) -> Result<Config, String> {
let mut config = Config::builder();
if let Some(t) = matches.values_of(ARG_TRUSTED_NETWORKS) {
config.set_has_trusted_networks(true);
for x in t.filter(|x| !x.trim().is_empty()) {
config.add_trusted_network(parse_ip_net(x).unwrap());
for n in t.filter(|n| !n.trim().is_empty()) {
match n.parse().or_else(|_| n.parse::<IpAddr>().map(From::from)) {
Ok(net) => {
config.add_trusted_network(net);
}
Err(_) => {
return Err(format!("invalid trusted network address \"{}\"", n));
}
}
}
};
}
if matches.is_present(ARG_FILTER_AUTHENTICATED) {
config.set_filter_authenticated(true);
if let Some(s) = matches.value_of(ARG_MAX_MESSAGE_SIZE) {
match s.parse() {
Ok(bytes) => {
config.set_max_message_size(bytes);
}
Err(_) => {
return Err(format!("invalid max message size \"{}\"", s));
}
}
}
if matches.is_present(ARG_AUTH_UNTRUSTED) {
config.set_auth_untrusted(true);
}
if matches.is_present(ARG_REJECT_SPAM) {
config.set_reject_spam(true);
@ -100,26 +123,9 @@ fn build_config(matches: &ArgMatches<'_>) -> Config {
config.set_verbose(true);
}
if let Some(s) = matches.value_of(ARG_MAX_MESSAGE_SIZE) {
config.set_max_message_size(s.parse().unwrap());
}
if let Some(s) = matches.values_of(ARG_SPAMC_ARGS) {
config.set_spamc_args(s.map(|arg| arg.to_owned()).collect::<Vec<_>>());
};
config.build()
Ok(config.build())
}
fn parse_ip_net(i: &str) -> Result<IpNet, AddrParseError> {
i.parse::<IpNet>().or_else(|_| {
i.parse::<IpAddr>().map(|addr| match addr {
IpAddr::V4(addr) => IpNet::V4(Ipv4Net::new(addr, 32).unwrap()),
IpAddr::V6(addr) => IpNet::V6(Ipv6Net::new(addr, 128).unwrap()),
})
})
}
// fn parse_ip_net(i: &str) -> Result<IpNet, AddrParseError> {
// i.parse::<IpNet>().or_else(|_| i.parse::<IpAddr>().map(From::from))
// }

81
tests/common/mod.rs Normal file
View file

@ -0,0 +1,81 @@
use std::{
ffi::OsString,
io::{Read, Write},
net::{Ipv4Addr, Shutdown, SocketAddrV4, TcpListener},
path::PathBuf,
process::{Command, ExitStatus},
thread::{self, JoinHandle},
time::Duration,
};
const MILTERTEST: &str = "miltertest";
pub fn spawn_echo_server(port: u16) -> JoinHandle<()> {
let socket_addr = SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), port);
thread::spawn(move || {
let listener = TcpListener::bind(socket_addr).unwrap();
// TODO do not run for ever?
for stream in listener.incoming() {
let mut s = stream.unwrap();
let mut v = Vec::new();
s.read_to_end(&mut v).unwrap();
s.write_all(&v).unwrap();
s.shutdown(Shutdown::Write).unwrap();
}
})
}
pub fn spawn_miltertest_runner(test_file_name: &str) -> JoinHandle<ExitStatus> {
let _timeout_thread = thread::spawn(|| {
thread::sleep(Duration::from_secs(15));
eprintln!("miltertest runner timed out");
milter::shutdown();
});
let file_name = to_miltertest_file_name(test_file_name);
thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
let output = Command::new(MILTERTEST)
.arg("-s")
.arg(&file_name)
.output()
.expect("miltertest execution failed");
print_output_stream("STDOUT", output.stdout);
print_output_stream("STDERR", output.stderr);
milter::shutdown();
output.status
})
}
fn to_miltertest_file_name(file_name: &str) -> OsString {
let mut path = PathBuf::from(file_name);
path.set_extension("lua");
path.into_os_string()
}
fn print_output_stream(name: &str, output: Vec<u8>) {
if !output.is_empty() {
let output = String::from_utf8(output).unwrap();
eprintln!("{}:", name);
if output.ends_with('\n') {
eprint!("{}", &output)
} else {
eprintln!("{}", &output)
}
}
}

71
tests/ham_flow.lua Normal file
View file

@ -0,0 +1,71 @@
conn = mt.connect("inet:3783@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.example.com", "123.123.123.123")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.helo(conn, "mail.example.com")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
-- local err = mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "my-auth-authen")
-- assert(err == nil, err)
local err = mt.mailfrom(conn, "from@example.com")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.rcptto(conn, "to@example.com")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
-- SMFIC_DATA not exported by miltertest:
SMFIC_DATA = string.byte("T")
local err = mt.macro(conn, SMFIC_DATA,
"i", "B45003F07A",
"j", "localhost",
"_", "client.example.com [123.123.123.123]",
"{tls_version}", "TLSv1.2",
"v", "Postfix 3.3.0")
assert(err == nil, err)
local err = mt.data(conn)
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "From", "from@example.com")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "To", "to@example.com")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "Subject", "Test message")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.header(conn, "Message-Id", "<1234567890@example.com>")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
-- TODO format current date?
local err = mt.header(conn, "Date", "Tue, 21 Jan 2020 20:19:22 +0100")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.eoh(conn)
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.bodystring(conn, "Test message body")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE)
local err = mt.eom(conn)
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_ACCEPT)
local err = mt.disconnect(conn)
assert(err == nil, err)

20
tests/ham_flow.rs Normal file
View file

@ -0,0 +1,20 @@
mod common;
use spamassassin_milter::*;
#[test]
fn ham_flow() {
let port = 4444;
let mut builder = Config::builder();
builder.set_spamc_args(vec![format!("--port={}", port)]);
let config = builder.build();
let _server = common::spawn_echo_server(port);
let miltertest = common::spawn_miltertest_runner(file!());
run("inet:3783@localhost", config).expect("spamassassin-milter failed");
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
}

View file

@ -0,0 +1,23 @@
-- Connection from loopback IP address.
conn = mt.connect("inet:3030@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, nil, "127.0.0.1")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_ACCEPT)
local err = mt.disconnect(conn)
assert(err == nil, err)
-- Connection from unknown IP address (for example, UNIX domain socket).
conn = mt.connect("inet:3030@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, nil, "unspec")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_ACCEPT)
local err = mt.disconnect(conn)
assert(err == nil, err)

View file

@ -0,0 +1,21 @@
mod common;
use spamassassin_milter::*;
#[test]
fn loopback_connection() {
let config = Config::builder()
// TODO spamc_args for echo server
.build();
let _server = common::spawn_echo_server(4444);
let miltertest = common::spawn_miltertest_runner(file!());
run("inet:3030@localhost", config).expect("spamassassin-milter failed");
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
// TODO detect panic in server?
// server.join().expect("panic in server");
}

View file

@ -0,0 +1,23 @@
-- Connection from loopback IP address.
conn = mt.connect("inet:3030@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, nil, "127.0.0.1")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE) -- connection not accepted
local err = mt.disconnect(conn)
assert(err == nil, err)
-- Connection from unknown IP address (for example, UNIX domain socket).
conn = mt.connect("inet:3030@localhost")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, nil, "unspec")
assert(err == nil, err)
assert(mt.getreply(conn) == SMFIR_CONTINUE) -- connection not accepted
local err = mt.disconnect(conn)
assert(err == nil, err)

View file

@ -0,0 +1,18 @@
mod common;
use spamassassin_milter::*;
#[test]
fn loopback_connection_untrusted() {
let mut builder = Config::builder();
builder.set_has_trusted_networks(true); // empty trusted networks, none trusted
let config = builder.build();
let _server = common::spawn_echo_server(4444);
let miltertest = common::spawn_miltertest_runner(file!());
run("inet:3030@localhost", config).expect("spamassassin-milter failed");
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
}