Initial commit

This commit is contained in:
Maurus Decimus
2026-05-29 18:02:15 +02:00
commit 576073f8c9
263 changed files with 78638 additions and 0 deletions
+463
View File
@@ -0,0 +1,463 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashSet;
use std::time::Duration;
use rusqlite::params;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::{SyncBuilder, SyncRunner};
use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt};
use super::data::{RawFixture, load_icals, load_vcards, malformed_ical, rewrite_uid};
use super::dav_client::DavSeed;
use super::error::{ContainerError, ContainerResult};
use super::layouts::{self, Layout};
use super::{Account, Endpoint};
const BASE_IMAGE: &str = "ckulka/baikal:0.10.1-nginx";
const HTTP_PORT: u16 = 80;
const REALM: &str = "BaikalDAV";
const ADMIN_PASSWORD_HASH: &str = "004b53ae84286ba6003b34e8ef404826";
const DIGEST_USER1: &str = "b7a47e657a4a966d0d844b1211416024";
const DIGEST_USER2: &str = "9fc67834c5a099f0a2eccea3f6918169";
const DIGEST_USER3: &str = "e1b30b108dad01b323a999b0bb1252ab";
const SCHEMA: &str = include_str!("baikal_schema.sql");
const BAIKAL_YAML: &str = "system:
configured_version: '0.10.1'
timezone: 'UTC'
card_enabled: true
cal_enabled: true
invite_from: '[email protected]'
dav_auth_type: 'Basic'
admin_passwordhash: '004b53ae84286ba6003b34e8ef404826'
failed_access_message: 'user %u authentication failure for Baikal'
auth_realm: 'BaikalDAV'
base_uri: ''
database:
backend: 'sqlite'
sqlite_file: '/var/www/baikal/Specific/db/db.sqlite'
";
const DOCKERFILE: &str = r#"FROM ckulka/baikal:0.10.1-nginx
COPY baikal.yaml /var/www/baikal/config/baikal.yaml
COPY db.sqlite /var/www/baikal/Specific/db/db.sqlite
RUN touch /var/www/baikal/Specific/INSTALL_DISABLED && \
chown -R www-data:www-data /var/www/baikal/config /var/www/baikal/Specific && \
chmod 0640 /var/www/baikal/config/baikal.yaml /var/www/baikal/Specific/db/db.sqlite
"#;
pub struct Baikal {
container: Container<GenericImage>,
pub http: Endpoint,
pub accounts: Vec<Account>,
}
impl Baikal {
pub fn start() -> ContainerResult<Self> {
let db_bytes = build_sqlite_db()?;
let image: GenericImage = GenericBuildableImage::new("vandelay-baikal", "test")
.with_dockerfile_string(DOCKERFILE.to_owned())
.with_data(BAIKAL_YAML.as_bytes().to_vec(), "baikal.yaml")
.with_data(db_bytes, "db.sqlite")
.build_image()
.map_err(|e| ContainerError::Seed(format!("baikal build: {e}")))?;
let _ = BASE_IMAGE;
let request = image
.with_exposed_port(HTTP_PORT.tcp())
.with_wait_for(WaitFor::message_on_stderr("start worker process"))
.with_startup_timeout(Duration::from_secs(180));
let container = request.start()?;
let host = container.get_host()?.to_string();
let http = Endpoint::new(host, container.get_host_port_ipv4(HTTP_PORT.tcp())?);
let accounts: Vec<Account> = layouts::accounts()
.iter()
.map(|name| Account {
username: (*name).to_owned(),
password: layouts::PASSWORD.to_owned(),
layout: layouts::layout_for(name),
})
.collect();
Ok(Self {
container,
http,
accounts,
})
}
pub fn base_url(&self) -> String {
self.http.http_base()
}
pub fn dav_root(&self) -> String {
format!("{}/dav.php", self.base_url())
}
pub fn calendar_home(&self, account: &Account) -> String {
format!("{}/calendars/{}/", self.dav_root(), account.username)
}
pub fn addressbook_home(&self, account: &Account) -> String {
format!("{}/addressbooks/{}/", self.dav_root(), account.username)
}
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
let icals = load_icals()?;
let vcards = load_vcards()?;
let mut out = Vec::new();
for acct in &self.accounts {
let seed = self.seed_account(acct, &icals, &vcards)?;
out.push(seed);
}
Ok(out)
}
pub fn seed_account(
&self,
account: &Account,
icals: &[RawFixture],
vcards: &[RawFixture],
) -> ContainerResult<AccountSeed> {
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
let layout = &account.layout;
let mut seed = AccountSeed::new(account.username.clone());
for (idx, cal) in layout.calendars.iter().enumerate() {
let segment = collection_segment("cal", idx, cal);
let path = format!("/calendars/{}/{}/", account.username, segment);
let mkcal = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:set><d:prop>
<d:displayname>{cal}</d:displayname>
<c:supported-calendar-component-set>
<c:comp name="VEVENT"/>
<c:comp name="VTODO"/>
</c:supported-calendar-component-set>
</d:prop></d:set>
</c:mkcalendar>"#
);
client.mkcalendar(&path, Some(&mkcal))?;
let mut plan = CollectionPlan::new((*cal).to_owned(), path.clone());
let count = events_per_calendar(layout, idx);
let offset = events_offset(layout, idx);
for (i, fx) in icals.iter().cycle().skip(offset).take(count).enumerate() {
let item = format!("{path}{}-{}.ics", fx.name, i);
let suffix = format!("{segment}-{i}");
let (bytes, uid) = rewrite_uid(&fx.bytes, &suffix).ok_or_else(|| {
ContainerError::Seed(format!("ical fixture {} has no UID", fx.name))
})?;
client.put(&item, "text/calendar; charset=utf-8", &bytes)?;
plan.items.push(SeededItem {
uid,
href: item,
source: bytes,
});
}
seed.calendars.push(plan);
}
for (idx, ab) in layout.address_books.iter().enumerate() {
let segment = collection_segment("ab", idx, ab);
let path = format!("/addressbooks/{}/{}/", account.username, segment);
let mkbook = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
<d:set><d:prop>
<d:resourcetype><d:collection/><c:addressbook/></d:resourcetype>
<d:displayname>{ab}</d:displayname>
</d:prop></d:set>
</d:mkcol>"#
);
client.mkcol(&path, Some(&mkbook))?;
let mut plan = CollectionPlan::new((*ab).to_owned(), path.clone());
let count = contacts_per_book(layout, idx);
let offset = contacts_offset(layout, idx);
for (i, fx) in vcards.iter().cycle().skip(offset).take(count).enumerate() {
let item = format!("{path}{}-{}.vcf", fx.name, i);
let suffix = format!("{segment}-{i}");
let (bytes, uid) = rewrite_uid(&fx.bytes, &suffix).ok_or_else(|| {
ContainerError::Seed(format!("vcard fixture {} has no UID", fx.name))
})?;
client.put(&item, "text/vcard; charset=utf-8", &bytes)?;
plan.items.push(SeededItem {
uid,
href: item,
source: bytes,
});
}
seed.address_books.push(plan);
}
Ok(seed)
}
pub fn seed_malformed_event(
&self,
account: &Account,
calendar_segment_idx: usize,
tag: &str,
) -> ContainerResult<String> {
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
let segment = collection_segment(
"cal",
calendar_segment_idx,
account.layout.calendars[calendar_segment_idx],
);
let path = format!(
"/calendars/{}/{}/broken-{tag}.ics",
account.username, segment
);
let bad = malformed_ical(tag);
client.put(&path, "text/calendar; charset=utf-8", &bad.bytes)?;
Ok(path)
}
pub fn delete_item(&self, account: &Account, path: &str) -> ContainerResult<()> {
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
client.delete(path)?;
Ok(())
}
pub fn add_event(
&self,
account: &Account,
calendar_segment_idx: usize,
tag: &str,
icals: &[RawFixture],
) -> ContainerResult<(String, String)> {
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
let display = account.layout.calendars[calendar_segment_idx];
let segment = collection_segment("cal", calendar_segment_idx, display);
let fixture = icals.first().ok_or_else(|| {
ContainerError::Seed("no ical fixture available for add_event".to_owned())
})?;
let suffix = format!("added-{tag}");
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix)
.ok_or_else(|| ContainerError::Seed("ical fixture has no UID".to_owned()))?;
let path = format!("/calendars/{}/{segment}/added-{tag}.ics", account.username);
client.put(&path, "text/calendar; charset=utf-8", &bytes)?;
Ok((path, uid))
}
pub fn add_contact(
&self,
account: &Account,
book_segment_idx: usize,
tag: &str,
vcards: &[RawFixture],
) -> ContainerResult<(String, String)> {
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
let display = account.layout.address_books[book_segment_idx];
let segment = collection_segment("ab", book_segment_idx, display);
let fixture = vcards.first().ok_or_else(|| {
ContainerError::Seed("no vcard fixture available for add_contact".to_owned())
})?;
let suffix = format!("added-{tag}");
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix)
.ok_or_else(|| ContainerError::Seed("vcard fixture has no UID".to_owned()))?;
let path = format!(
"/addressbooks/{}/{segment}/added-{tag}.vcf",
account.username
);
client.put(&path, "text/vcard; charset=utf-8", &bytes)?;
Ok((path, uid))
}
pub fn verify_seed(&self, seeds: &[AccountSeed]) -> ContainerResult<()> {
for (acct, seed) in self.accounts.iter().zip(seeds) {
let client = DavSeed::new(self.dav_root(), &acct.username, &acct.password);
let body = client.propfind(&format!("/calendars/{}/", acct.username), 1)?;
if !body.contains("multistatus") {
return Err(ContainerError::Protocol(format!(
"baikal propfind for {} returned no multistatus",
acct.username
)));
}
let _ = seed;
}
Ok(())
}
pub fn stop(self) -> ContainerResult<()> {
self.container.stop()?;
Ok(())
}
}
fn build_sqlite_db() -> ContainerResult<Vec<u8>> {
let tmp = tempfile::NamedTempFile::new()
.map_err(|e| ContainerError::Seed(format!("baikal tempfile: {e}")))?;
let path = tmp.path().to_path_buf();
drop(tmp);
{
let conn = rusqlite::Connection::open(&path)
.map_err(|e| ContainerError::Seed(format!("baikal sqlite open: {e}")))?;
conn.execute_batch(SCHEMA)
.map_err(|e| ContainerError::Seed(format!("baikal schema: {e}")))?;
let users: [(&str, &str); 3] = [
(layouts::ACCOUNT1, DIGEST_USER1),
(layouts::ACCOUNT2, DIGEST_USER2),
(layouts::ACCOUNT3, DIGEST_USER3),
];
for (name, digest) in users {
conn.execute(
"INSERT INTO users (username, digesta1) VALUES (?1, ?2)",
params![name, digest],
)
.map_err(|e| ContainerError::Seed(format!("baikal user {name}: {e}")))?;
let uri = format!("principals/{name}");
let email = format!("{name}@vandelay.test");
let display = format!("Vandelay {name}");
conn.execute(
"INSERT INTO principals (uri, email, displayname) VALUES (?1, ?2, ?3)",
params![uri, email, display],
)
.map_err(|e| ContainerError::Seed(format!("baikal principal {name}: {e}")))?;
conn.execute(
"INSERT INTO principals (uri, email, displayname) VALUES (?1, NULL, NULL)",
params![format!("{uri}/calendar-proxy-read")],
)
.map_err(|e| ContainerError::Seed(format!("baikal cal-proxy-read {name}: {e}")))?;
conn.execute(
"INSERT INTO principals (uri, email, displayname) VALUES (?1, NULL, NULL)",
params![format!("{uri}/calendar-proxy-write")],
)
.map_err(|e| ContainerError::Seed(format!("baikal cal-proxy-write {name}: {e}")))?;
}
let _ = REALM;
let _ = ADMIN_PASSWORD_HASH;
}
let bytes =
std::fs::read(&path).map_err(|e| ContainerError::Seed(format!("baikal read db: {e}")))?;
let _ = std::fs::remove_file(&path);
Ok(bytes)
}
fn collection_segment(prefix: &str, idx: usize, name: &str) -> String {
let mut out = format!("{prefix}-{:02}-", idx);
for c in name.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
out.trim_end_matches('-').to_owned()
}
fn events_per_calendar(layout: &Layout, idx: usize) -> usize {
if layout.calendars.is_empty() {
return 0;
}
let base = layout.event_count / layout.calendars.len().max(1);
let extra = if idx == 0 {
layout.event_count % layout.calendars.len()
} else {
0
};
base + extra
}
fn events_offset(layout: &Layout, idx: usize) -> usize {
(0..idx).map(|i| events_per_calendar(layout, i)).sum()
}
fn contacts_per_book(layout: &Layout, idx: usize) -> usize {
if layout.address_books.is_empty() {
return 0;
}
let base = layout.contact_count / layout.address_books.len().max(1);
let extra = if idx == 0 {
layout.contact_count % layout.address_books.len()
} else {
0
};
base + extra
}
fn contacts_offset(layout: &Layout, idx: usize) -> usize {
(0..idx).map(|i| contacts_per_book(layout, i)).sum()
}
#[derive(Debug, Clone)]
pub struct SeededItem {
pub uid: String,
pub href: String,
pub source: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct CollectionPlan {
pub display_name: String,
pub base_href: String,
pub items: Vec<SeededItem>,
}
impl CollectionPlan {
fn new(display_name: String, base_href: String) -> Self {
Self {
display_name,
base_href,
items: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct AccountSeed {
pub username: String,
pub calendars: Vec<CollectionPlan>,
pub address_books: Vec<CollectionPlan>,
}
impl AccountSeed {
fn new(username: String) -> Self {
Self {
username,
calendars: Vec::new(),
address_books: Vec::new(),
}
}
pub fn total_events(&self) -> usize {
self.calendars.iter().map(|c| c.items.len()).sum()
}
pub fn total_contacts(&self) -> usize {
self.address_books.iter().map(|c| c.items.len()).sum()
}
pub fn event_uids(&self) -> HashSet<String> {
self.calendars
.iter()
.flat_map(|c| c.items.iter().map(|i| i.uid.clone()))
.collect()
}
pub fn contact_uids(&self) -> HashSet<String> {
self.address_books
.iter()
.flat_map(|c| c.items.iter().map(|i| i.uid.clone()))
.collect()
}
}
+147
View File
@@ -0,0 +1,147 @@
CREATE TABLE addressbooks (
id integer primary key asc NOT NULL,
principaluri text NOT NULL,
displayname text,
uri text NOT NULL,
description text,
synctoken integer DEFAULT 1 NOT NULL
);
CREATE TABLE cards (
id integer primary key asc NOT NULL,
addressbookid integer NOT NULL,
carddata blob,
uri text NOT NULL,
lastmodified integer,
etag text,
size integer
);
CREATE TABLE addressbookchanges (
id integer primary key asc NOT NULL,
uri text,
synctoken integer NOT NULL,
addressbookid integer NOT NULL,
operation integer NOT NULL
);
CREATE INDEX addressbookid_synctoken ON addressbookchanges (addressbookid, synctoken);
CREATE TABLE calendarobjects (
id integer primary key asc NOT NULL,
calendardata blob NOT NULL,
uri text NOT NULL,
calendarid integer NOT NULL,
lastmodified integer NOT NULL,
etag text NOT NULL,
size integer NOT NULL,
componenttype text,
firstoccurence integer,
lastoccurence integer,
uid text
);
CREATE TABLE calendars (
id integer primary key asc NOT NULL,
synctoken integer DEFAULT 1 NOT NULL,
components text NOT NULL
);
CREATE TABLE calendarinstances (
id integer primary key asc NOT NULL,
calendarid integer,
principaluri text,
access integer,
displayname text,
uri text NOT NULL,
description text,
calendarorder integer,
calendarcolor text,
timezone text,
transparent bool,
share_href text,
share_displayname text,
share_invitestatus integer DEFAULT '2',
UNIQUE (principaluri, uri),
UNIQUE (calendarid, principaluri),
UNIQUE (calendarid, share_href)
);
CREATE TABLE calendarchanges (
id integer primary key asc NOT NULL,
uri text,
synctoken integer NOT NULL,
calendarid integer NOT NULL,
operation integer NOT NULL
);
CREATE INDEX calendarid_synctoken ON calendarchanges (calendarid, synctoken);
CREATE TABLE calendarsubscriptions (
id integer primary key asc NOT NULL,
uri text NOT NULL,
principaluri text NOT NULL,
source text NOT NULL,
displayname text,
refreshrate text,
calendarorder integer,
calendarcolor text,
striptodos bool,
stripalarms bool,
stripattachments bool,
lastmodified int
);
CREATE TABLE schedulingobjects (
id integer primary key asc NOT NULL,
principaluri text NOT NULL,
calendardata blob,
uri text NOT NULL,
lastmodified integer,
etag text NOT NULL,
size integer NOT NULL
);
CREATE INDEX principaluri_uri ON calendarsubscriptions (principaluri, uri);
BEGIN TRANSACTION;
CREATE TABLE locks (
id integer primary key asc NOT NULL,
owner text,
timeout integer,
created integer,
token text,
scope integer,
depth integer,
uri text
);
COMMIT;
CREATE TABLE principals (
id INTEGER PRIMARY KEY ASC NOT NULL,
uri TEXT NOT NULL,
email TEXT,
displayname TEXT,
UNIQUE(uri)
);
CREATE TABLE groupmembers (
id INTEGER PRIMARY KEY ASC NOT NULL,
principal_id INTEGER NOT NULL,
member_id INTEGER NOT NULL,
UNIQUE(principal_id, member_id)
);
CREATE TABLE propertystorage (
id integer primary key asc NOT NULL,
path text NOT NULL,
name text NOT NULL,
valuetype integer NOT NULL,
value string
);
CREATE UNIQUE INDEX path_property ON propertystorage (path, name);
CREATE TABLE users (
id integer primary key asc NOT NULL,
username TEXT NOT NULL,
digesta1 TEXT NOT NULL,
UNIQUE(username)
);
+393
View File
@@ -0,0 +1,393 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use std::time::Duration;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::{SyncBuilder, SyncRunner};
use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt};
use super::data::{MboxMessage, load_mbox};
use super::error::{ContainerError, ContainerResult};
use super::imap_client::{ImapSeed, full_path};
use super::layouts::{self};
use super::{Account, Endpoint};
const IMAP_PORT: u16 = 143;
const DOCKERFILE: &str = r#"FROM debian:bookworm-20260518-slim
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
cyrus-imapd cyrus-admin cyrus-clients sasl2-bin \
libsasl2-modules libsasl2-modules-db \
ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /var/lib/cyrus /var/spool/cyrus /run/cyrus && \
chown -R cyrus:mail /var/lib/cyrus /var/spool/cyrus /run/cyrus
COPY imapd.conf /etc/imapd.conf
COPY cyrus.conf /etc/cyrus.conf
COPY sasldb.txt /tmp/sasldb.txt
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 143
CMD ["/entrypoint.sh"]
"#;
const IMAPD_CONF: &str = r#"configdirectory: /var/lib/cyrus
partition-default: /var/spool/cyrus
admins: cyrus
allowanonymouslogin: no
allowplaintext: yes
sasl_mech_list: PLAIN LOGIN
sasl_pwcheck_method: auxprop
sasl_auxprop_plugin: sasldb
sasl_sasldb_path: /etc/sasldb2
unixhierarchysep: yes
altnamespace: yes
sasl_minimum_layer: 0
"#;
const CYRUS_CONF: &str = r#"START {
recover cmd="ctl_cyrusdb -r"
}
SERVICES {
imap cmd="imapd" listen="143" prefork=0
}
EVENTS {
checkpoint cmd="ctl_cyrusdb -c" period=30
delprune cmd="cyr_expire -E 3" at=0400
}
"#;
fn sasldb_entries() -> String {
let mut out = String::new();
for u in layouts::accounts() {
out.push_str(u);
out.push(' ');
out.push_str(layouts::PASSWORD);
out.push('\n');
}
out.push_str("cyrus admin-secret\n");
out
}
const ENTRYPOINT: &str = r#"#!/bin/bash
set -e
while IFS=' ' read -r user pw; do
[ -z "$user" ] && continue
echo "$pw" | saslpasswd2 -p -c -f /etc/sasldb2 "$user"
done < /tmp/sasldb.txt
chown cyrus:mail /etc/sasldb2
chmod 0640 /etc/sasldb2
mkdir -p /var/lib/cyrus /var/spool/cyrus /run/cyrus
chown -R cyrus:mail /var/lib/cyrus /var/spool/cyrus /run/cyrus
su cyrus -s /bin/bash -c '/usr/sbin/cyrus makedirs' || true
/usr/sbin/cyrmaster -C /etc/imapd.conf -M /etc/cyrus.conf &
master_pid=$!
for _ in $(seq 1 120); do
if (exec 3<>/dev/tcp/127.0.0.1/143) 2>/dev/null; then
exec 3<&- 3>&-
break
fi
sleep 0.5
done
provision() {
exec 3<>/dev/tcp/127.0.0.1/143
read -r _greeting <&3
printf 'a1 LOGIN cyrus admin-secret\r\n' >&3
read -r _ <&3
local tag=1
for u in "$@"; do
tag=$((tag + 1))
printf 'a%d CREATE user/%s\r\n' "$tag" "$u" >&3
read -r _ <&3
done
tag=$((tag + 1))
printf 'a%d LOGOUT\r\n' "$tag" >&3
read -r _ <&3
exec 3<&- 3>&-
}
users=()
while IFS=' ' read -r user _pw; do
[ -z "$user" ] && continue
[ "$user" = "cyrus" ] && continue
users+=("$user")
done < /tmp/sasldb.txt
provision "${users[@]}"
echo "cyrus provisioned"
wait "$master_pid"
"#;
pub struct Cyrus {
container: Container<GenericImage>,
pub imap: Endpoint,
pub accounts: Vec<Account>,
}
impl Cyrus {
pub fn start() -> ContainerResult<Self> {
let image: GenericImage = GenericBuildableImage::new("vandelay-cyrus", "test")
.with_dockerfile_string(DOCKERFILE.to_owned())
.with_data(IMAPD_CONF.as_bytes().to_vec(), "imapd.conf")
.with_data(CYRUS_CONF.as_bytes().to_vec(), "cyrus.conf")
.with_data(sasldb_entries().into_bytes(), "sasldb.txt")
.with_data(ENTRYPOINT.as_bytes().to_vec(), "entrypoint.sh")
.build_image()
.map_err(|e| ContainerError::Seed(format!("cyrus build: {e}")))?;
let request = image
.with_exposed_port(IMAP_PORT.tcp())
.with_wait_for(WaitFor::message_on_stdout("cyrus provisioned"))
.with_startup_timeout(Duration::from_secs(180));
let container = request.start()?;
let host = container.get_host()?.to_string();
let imap = Endpoint::new(host, container.get_host_port_ipv4(IMAP_PORT.tcp())?);
let accounts: Vec<Account> = layouts::accounts()
.iter()
.map(|name| Account {
username: (*name).to_owned(),
password: layouts::PASSWORD.to_owned(),
layout: layouts::layout_for(name),
})
.collect();
Ok(Self {
container,
imap,
accounts,
})
}
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
let limit = self
.accounts
.iter()
.map(|a| a.layout.email_count)
.max()
.unwrap_or(0)
.max(1);
let messages = load_mbox(limit)?;
let mut out = Vec::new();
for acct in &self.accounts {
let m = self.seed_account(acct, &messages)?;
out.push(m);
}
Ok(out)
}
fn seed_account(
&self,
account: &Account,
messages: &[MboxMessage],
) -> ContainerResult<AccountSeed> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
let sep = client.discover_separator().unwrap_or('/');
let mut paths = Vec::new();
for spec in account.layout.mailboxes {
let path = full_path(account.layout.mailboxes, spec.key, sep).ok_or_else(|| {
ContainerError::Seed(format!("missing mailbox key: {}", spec.key))
})?;
client.create(&path)?;
client.subscribe(&path)?;
paths.push(path);
}
let mut targets: Vec<String> = vec!["INBOX".to_owned()];
targets.extend(paths.iter().cloned());
let mut histogram: HashMap<String, usize> =
targets.iter().map(|t| (t.clone(), 0)).collect();
let mut appends: Vec<SeededAppend> = Vec::new();
let total = account.layout.email_count.min(messages.len());
if total < account.layout.email_count {
return Err(ContainerError::Seed(format!(
"{} only has {} messages but layout requires {}",
account.username,
messages.len(),
account.layout.email_count
)));
}
for (i, msg) in messages.iter().take(total).enumerate() {
let target = targets[i % targets.len()].clone();
client.append_with_flags(&target, &[], &msg.raw)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: msg.raw.clone(),
target,
flags: Vec::new(),
tag: SeedTag::Bulk(i),
});
}
let mut dedup_target: Option<String> = None;
if targets.len() > 1 && !messages.is_empty() {
let target = targets[1].clone();
client.append_with_flags(&target, &[], &messages[0].raw)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: messages[0].raw.clone(),
target: target.clone(),
flags: Vec::new(),
tag: SeedTag::Dedup,
});
dedup_target = Some(target);
}
let mut flagged_target: Option<String> = None;
if !messages.is_empty() {
let target = "INBOX".to_owned();
let probe = flag_probe_message();
client.append_with_flags(&target, &["\\Seen", "\\Flagged"], &probe)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: probe,
target: target.clone(),
flags: vec!["$seen".to_owned(), "$flagged".to_owned()],
tag: SeedTag::FlagProbe,
});
flagged_target = Some(target);
}
client.logout()?;
let total_appends = histogram.values().sum();
Ok(AccountSeed {
username: account.username.clone(),
paths,
histogram,
total_appends,
dedup_target,
flagged_target,
appends,
})
}
pub fn delete_first_inbox_message(&self, account: &Account) -> ContainerResult<()> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
client.delete_and_expunge_first("INBOX")?;
client.logout()?;
Ok(())
}
pub fn append_new_message(
&self,
account: &Account,
mailbox: &str,
tag: &str,
) -> ContainerResult<(Vec<u8>, String)> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
let message_id = format!("<cyrus-added-{tag}-{}@vandelay.test>", account.username);
let body = format!(
"From: cyrus-added-{tag}@vandelay.test\r\n\
To: {}@vandelay.test\r\n\
Subject: Cyrus added probe {tag}\r\n\
Message-ID: {message_id}\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Cyrus added probe body {tag}.\r\n",
account.username
);
let raw = body.into_bytes();
client.append_with_flags(mailbox, &[], &raw)?;
client.logout()?;
Ok((raw, message_id))
}
pub fn verify_seed(&self, seeds: &[AccountSeed]) -> ContainerResult<()> {
for (acct, seed) in self.accounts.iter().zip(seeds) {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&acct.username, &acct.password)?;
let names = client.list_all()?;
let expected = acct.layout.mailboxes.len() + 1;
if names.len() < expected {
return Err(ContainerError::Seed(format!(
"cyrus {}: expected >= {expected} mailboxes, got {}",
acct.username,
names.len()
)));
}
let inbox_n = client.select("INBOX")?;
if acct.layout.email_count > 0 && inbox_n == 0 {
return Err(ContainerError::Seed(format!(
"cyrus {}: INBOX EXISTS = 0 after seed",
acct.username
)));
}
for path in &seed.paths {
let n = client.select(path)?;
let want = seed.histogram.get(path).copied().unwrap_or(0);
if n < want {
return Err(ContainerError::Seed(format!(
"cyrus {}: mailbox {path} EXISTS={n} but {want} were appended",
acct.username
)));
}
}
client.logout()?;
}
Ok(())
}
pub fn stop(self) -> ContainerResult<()> {
self.container.stop()?;
Ok(())
}
}
pub fn flag_probe_message() -> Vec<u8> {
let body = "From: [email protected]\r\n\
To: [email protected]\r\n\
Subject: Cyrus flag probe\r\n\
Message-ID: <[email protected]>\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Cyrus flag probe body.\r\n";
body.as_bytes().to_vec()
}
#[derive(Debug, Clone)]
pub struct AccountSeed {
pub username: String,
pub paths: Vec<String>,
pub histogram: HashMap<String, usize>,
pub total_appends: usize,
pub dedup_target: Option<String>,
pub flagged_target: Option<String>,
pub appends: Vec<SeededAppend>,
}
#[derive(Debug, Clone)]
pub struct SeededAppend {
pub raw: Vec<u8>,
pub target: String,
pub flags: Vec<String>,
pub tag: SeedTag,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeedTag {
Bulk(usize),
Dedup,
FlagProbe,
}
+475
View File
@@ -0,0 +1,475 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use flate2::read::GzDecoder;
use mail_parser::mailbox::mbox::MessageIterator;
use super::error::{ContainerError, ContainerResult};
fn resources_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("resources")
}
#[derive(Debug, Clone)]
pub struct MboxMessage {
pub raw: Vec<u8>,
pub received_at: i64,
}
pub fn load_mbox(limit: usize) -> ContainerResult<Vec<MboxMessage>> {
let path = resources_dir().join("mailbox.gz");
if path.exists() {
let file = std::fs::File::open(&path)?;
let mut decoder = GzDecoder::new(file);
let mut bytes = Vec::new();
decoder.read_to_end(&mut bytes)?;
let reader = BufReader::new(std::io::Cursor::new(bytes));
let mut out = Vec::new();
for item in MessageIterator::new(reader) {
let message = item.map_err(|e| ContainerError::Resource(format!("mbox parse: {e}")))?;
let received_at = message.internal_date() as i64;
out.push(MboxMessage {
raw: message.unwrap_contents(),
received_at,
});
if out.len() >= limit {
break;
}
}
if !out.is_empty() {
return Ok(out);
}
}
if let Ok(takeout) = load_takeout_mbox("all_mail_including_spam_and_trash.mbox")
&& !takeout.is_empty()
{
return Ok(takeout.into_iter().take(limit).collect());
}
Ok(synth_messages(limit))
}
#[derive(Debug, Clone)]
pub struct RawFixture {
pub name: String,
pub bytes: Vec<u8>,
}
pub fn load_vcards() -> ContainerResult<Vec<RawFixture>> {
Ok(synth_vcards(48))
}
pub fn load_icals() -> ContainerResult<Vec<RawFixture>> {
Ok(synth_icals(48))
}
pub fn load_takeout_mbox(name: &str) -> ContainerResult<Vec<MboxMessage>> {
let path = resources_dir().join("takeout").join(name);
let bytes = std::fs::read(&path)
.map_err(|e| ContainerError::Resource(format!("open {}: {e}", path.display())))?;
let reader = BufReader::new(std::io::Cursor::new(bytes));
let mut out = Vec::new();
for item in MessageIterator::new(reader) {
let message = item.map_err(|e| ContainerError::Resource(format!("mbox parse: {e}")))?;
let received_at = message.internal_date() as i64;
out.push(MboxMessage {
raw: message.unwrap_contents(),
received_at,
});
}
Ok(out)
}
fn synth_messages(n: usize) -> Vec<MboxMessage> {
let mut out = Vec::with_capacity(n);
for i in 0..n {
let body = format!(
"From: sender{i}@vandelay.test\r\n\
To: [email protected]\r\n\
Subject: Synthetic test message {i}\r\n\
Message-ID: <synth-{i}@vandelay.test>\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
This is synthetic seed message #{i} for vandelay container tests.\r\n",
);
out.push(MboxMessage {
raw: body.into_bytes(),
received_at: 0,
});
}
out
}
const VCARD_VARIANTS: &[fn(usize) -> String] = &[
vcard_v3_basic,
vcard_v3_full,
vcard_v3_multi_email,
vcard_v3_org_and_title,
vcard_v4_basic,
vcard_v4_with_address,
vcard_v4_nickname,
vcard_v4_birthday,
];
fn synth_vcards(n: usize) -> Vec<RawFixture> {
let mut out = Vec::with_capacity(n);
for i in 0..n {
let body = VCARD_VARIANTS[i % VCARD_VARIANTS.len()](i);
out.push(RawFixture {
name: format!("synth-{i:03}"),
bytes: body.into_bytes(),
});
}
out
}
fn vcard_v3_basic(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
UID:vandelay-card-basic-{i}\r\n\
FN:Basic Contact {i}\r\n\
N:Contact{i};Basic;;;\r\n\
EMAIL;TYPE=INTERNET:basic-{i}@vandelay.test\r\n\
END:VCARD\r\n"
)
}
fn vcard_v3_full(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
UID:vandelay-card-full-{i}\r\n\
FN:Full Contact {i}\r\n\
N:Contact{i};Full;Middle;;\r\n\
EMAIL;TYPE=INTERNET;TYPE=WORK:full-{i}@vandelay.test\r\n\
TEL;TYPE=CELL:+15550010{i:03}\r\n\
ORG:Vandelay Test\r\n\
TITLE:Senior Test Engineer\r\n\
URL:https://vandelay.test/{i}\r\n\
NOTE:Synthetic full v3 contact #{i}\r\n\
END:VCARD\r\n"
)
}
fn vcard_v3_multi_email(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
UID:vandelay-card-multi-{i}\r\n\
FN:Multi Email {i}\r\n\
N:Contact{i};MultiEmail;;;\r\n\
EMAIL;TYPE=INTERNET;TYPE=HOME:multi-{i}[email protected]\r\n\
EMAIL;TYPE=INTERNET;TYPE=WORK:multi-{i}[email protected]\r\n\
END:VCARD\r\n"
)
}
fn vcard_v3_org_and_title(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
UID:vandelay-card-org-{i}\r\n\
FN:Org Contact {i}\r\n\
N:Contact{i};Org;;Dr.;PhD\r\n\
ORG:Vandelay Industries;Engineering;Test Group\r\n\
TITLE:Principal Researcher\r\n\
EMAIL;TYPE=INTERNET:org-{i}@vandelay.test\r\n\
END:VCARD\r\n"
)
}
fn vcard_v4_basic(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
UID:urn:uuid:00000000-0000-4000-a000-0000{i:08x}\r\n\
FN:V4 Basic {i}\r\n\
N:Contact{i};V4Basic;;;\r\n\
EMAIL:v4-basic-{i}@vandelay.test\r\n\
END:VCARD\r\n"
)
}
fn vcard_v4_with_address(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
UID:urn:uuid:00000000-0000-4000-b000-0000{i:08x}\r\n\
FN:V4 Address {i}\r\n\
N:Contact{i};V4Address;;;\r\n\
EMAIL:v4-address-{i}@vandelay.test\r\n\
ADR;TYPE=home:;;{i} Test Street;Springfield;OR;97477;US\r\n\
END:VCARD\r\n"
)
}
fn vcard_v4_nickname(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
UID:urn:uuid:00000000-0000-4000-c000-0000{i:08x}\r\n\
FN:V4 Nick {i}\r\n\
N:Contact{i};V4Nick;;;\r\n\
NICKNAME:Nicky{i}\r\n\
EMAIL:v4-nick-{i}@vandelay.test\r\n\
END:VCARD\r\n"
)
}
fn vcard_v4_birthday(i: usize) -> String {
let year = 1970 + (i % 30);
let month = (i % 12) + 1;
let day = (i % 28) + 1;
format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
UID:urn:uuid:00000000-0000-4000-d000-0000{i:08x}\r\n\
FN:V4 Birthday {i}\r\n\
N:Contact{i};V4Birthday;;;\r\n\
BDAY:{year:04}{month:02}{day:02}\r\n\
EMAIL:v4-bday-{i}@vandelay.test\r\n\
END:VCARD\r\n"
)
}
const ICAL_VARIANTS: &[fn(usize) -> String] = &[
ical_simple_dt,
ical_all_day,
ical_multi_day_dt,
ical_with_organizer_attendees,
ical_with_categories_location,
ical_recurring_daily,
ical_recurring_weekly_until,
ical_with_alarm,
];
fn synth_icals(n: usize) -> Vec<RawFixture> {
let mut out = Vec::with_capacity(n);
for i in 0..n {
let body = ICAL_VARIANTS[i % ICAL_VARIANTS.len()](i);
out.push(RawFixture {
name: format!("synth-{i:03}"),
bytes: body.into_bytes(),
});
}
out
}
fn ical_simple_dt(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
let hour = (i % 24) as u32;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-simple-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T{hour:02}0000Z\r\n\
DTEND:2025{month:02}{day:02}T{hour:02}3000Z\r\n\
SUMMARY:Simple datetime event {i}\r\n\
DESCRIPTION:Simple synthetic event #{i}\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_all_day(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
let next_day = day + 1;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-allday-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART;VALUE=DATE:2025{month:02}{day:02}\r\n\
DTEND;VALUE=DATE:2025{month:02}{next_day:02}\r\n\
SUMMARY:All-day event {i}\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_multi_day_dt(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 27) + 1) as u32;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-multiday-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T090000Z\r\n\
DTEND:2025{month:02}{day:02}T170000Z\r\n\
SUMMARY:Workshop session {i}\r\n\
DESCRIPTION:Multi-hour workshop with description.\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_with_organizer_attendees(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-meeting-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T140000Z\r\n\
DTEND:2025{month:02}{day:02}T150000Z\r\n\
SUMMARY:Sync meeting {i}\r\n\
ORGANIZER;CN=Organiser:mailto:organiser-{i}@vandelay.test\r\n\
ATTENDEE;CN=Alice;PARTSTAT=ACCEPTED:mailto:alice-{i}@vandelay.test\r\n\
ATTENDEE;CN=Bob;PARTSTAT=NEEDS-ACTION:mailto:bob-{i}@vandelay.test\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_with_categories_location(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-cat-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T100000Z\r\n\
DTEND:2025{month:02}{day:02}T110000Z\r\n\
SUMMARY:Categorised event {i}\r\n\
LOCATION:Test Lab\\, Room {i}\r\n\
CATEGORIES:VANDELAY,TEST,FIXTURE\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_recurring_daily(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-daily-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T070000Z\r\n\
DTEND:2025{month:02}{day:02}T073000Z\r\n\
RRULE:FREQ=DAILY;COUNT=10\r\n\
SUMMARY:Daily standup {i}\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_recurring_weekly_until(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
let until_month = (month % 12) + 1;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-weekly-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T160000Z\r\n\
DTEND:2025{month:02}{day:02}T170000Z\r\n\
RRULE:FREQ=WEEKLY;UNTIL=2025{until_month:02}{day:02}T160000Z;BYDAY=MO\r\n\
SUMMARY:Weekly retro {i}\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_with_alarm(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-alarm-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T080000Z\r\n\
DTEND:2025{month:02}{day:02}T083000Z\r\n\
SUMMARY:Event with alarm {i}\r\n\
BEGIN:VALARM\r\n\
ACTION:DISPLAY\r\n\
DESCRIPTION:Reminder {i}\r\n\
TRIGGER:-PT15M\r\n\
END:VALARM\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
pub fn malformed_ical(name: &str) -> RawFixture {
let body = format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//broken//EN\r\n\
BEGIN:VEVENT\r\n\
UID:broken-{name}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:NOT-A-DATE\r\n\
RRULE:FREQ=BOGUS;INTERVAL=oops\r\n\
END:NOT-A-VEVENT\r\n",
);
RawFixture {
name: format!("broken-{name}"),
bytes: body.into_bytes(),
}
}
pub fn rewrite_uid(bytes: &[u8], suffix: &str) -> Option<(Vec<u8>, String)> {
let text = std::str::from_utf8(bytes).ok()?;
let mut out = String::with_capacity(text.len() + 32);
let mut new_uid: Option<String> = None;
for line in text.lines() {
if let Some(rest) = line.strip_prefix("UID:") {
let combined = format!("{rest}-{suffix}");
if new_uid.is_none() {
new_uid = Some(combined.clone());
}
out.push_str("UID:");
out.push_str(&combined);
} else {
out.push_str(line);
}
out.push_str("\r\n");
}
let uid = new_uid?;
Some((out.into_bytes(), uid))
}
pub fn extract_uid(bytes: &[u8]) -> Option<String> {
let text = std::str::from_utf8(bytes).ok()?;
for line in text.lines() {
let l = line.trim_end_matches('\r');
if let Some(rest) = l.strip_prefix("UID:") {
return Some(rest.to_owned());
}
}
None
}
+167
View File
@@ -0,0 +1,167 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use ureq::Agent;
use ureq::http::{Method, Request};
use super::error::{ContainerError, ContainerResult};
pub struct DavSeed {
agent: Agent,
base: String,
auth: String,
}
impl DavSeed {
pub fn new(base: impl Into<String>, user: &str, password: &str) -> Self {
let agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.allow_non_standard_methods(true)
.timeout_global(Some(Duration::from_secs(30)))
.build()
.new_agent();
let credentials = B64.encode(format!("{user}:{password}"));
Self {
agent,
base: base.into(),
auth: format!("Basic {credentials}"),
}
}
fn url(&self, path: &str) -> String {
if path.starts_with("http://") || path.starts_with("https://") {
path.to_owned()
} else if path.starts_with('/') {
format!("{}{path}", self.base.trim_end_matches('/'))
} else {
format!("{}/{}", self.base.trim_end_matches('/'), path)
}
}
fn run(
&self,
method: &str,
url: &str,
depth: Option<u8>,
content_type: Option<&str>,
body: Option<&[u8]>,
) -> ContainerResult<(u16, Vec<u8>)> {
let parsed = Method::from_bytes(method.as_bytes())
.map_err(|e| ContainerError::Protocol(format!("bad method {method}: {e}")))?;
let mut builder = Request::builder()
.method(parsed)
.uri(url)
.header("Authorization", &self.auth);
if let Some(d) = depth {
builder = builder.header("Depth", d.to_string());
}
if let Some(ct) = content_type {
builder = builder.header("Content-Type", ct);
}
let payload: Vec<u8> = body.map(|b| b.to_vec()).unwrap_or_default();
let request = builder
.body(payload)
.map_err(|e| ContainerError::Protocol(format!("build request: {e}")))?;
let mut response = self.agent.run(request)?;
let status = response.status().as_u16();
let bytes = response.body_mut().read_to_vec()?;
Ok((status, bytes))
}
pub fn mkcol(&self, path: &str, body: Option<&str>) -> ContainerResult<u16> {
self.mkcol_with_method("MKCOL", path, body)
}
pub fn mkcalendar(&self, path: &str, body: Option<&str>) -> ContainerResult<u16> {
self.mkcol_with_method("MKCALENDAR", path, body)
}
fn mkcol_with_method(
&self,
method: &str,
path: &str,
body: Option<&str>,
) -> ContainerResult<u16> {
let url = self.url(path);
let bytes = body.map(|b| b.as_bytes());
let (status, response_body) = self.run(
method,
&url,
None,
Some("application/xml; charset=utf-8"),
bytes,
)?;
if !is_collection_created(status) {
return Err(ContainerError::Protocol(format!(
"{method} {url} -> {status}: {}",
truncate(&response_body)
)));
}
Ok(status)
}
pub fn put(&self, path: &str, content_type: &str, body: &[u8]) -> ContainerResult<u16> {
let url = self.url(path);
let (status, response_body) =
self.run("PUT", &url, None, Some(content_type), Some(body))?;
if !(200..300).contains(&status) {
return Err(ContainerError::Protocol(format!(
"PUT {url} -> {status}: {}",
truncate(&response_body)
)));
}
Ok(status)
}
pub fn delete(&self, path: &str) -> ContainerResult<u16> {
let url = self.url(path);
let (status, response_body) = self.run("DELETE", &url, None, None, None)?;
if !(200..300).contains(&status) && status != 404 {
return Err(ContainerError::Protocol(format!(
"DELETE {url} -> {status}: {}",
truncate(&response_body)
)));
}
Ok(status)
}
pub fn propfind(&self, path: &str, depth: u8) -> ContainerResult<String> {
let url = self.url(path);
let body = r#"<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:"><prop><resourcetype/><displayname/></prop></propfind>"#;
let (status, bytes) = self.run(
"PROPFIND",
&url,
Some(depth),
Some("application/xml; charset=utf-8"),
Some(body.as_bytes()),
)?;
if !(200..400).contains(&status) {
return Err(ContainerError::Protocol(format!(
"PROPFIND {url} -> {status}: {}",
truncate(&bytes)
)));
}
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
}
fn is_collection_created(status: u16) -> bool {
matches!(status, 200 | 201 | 204)
}
fn truncate(body: &[u8]) -> String {
let s = String::from_utf8_lossy(body);
if s.len() > 2000 {
format!("{}…", &s[..2000])
} else {
s.into_owned()
}
}
+503
View File
@@ -0,0 +1,503 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use std::time::Duration;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::{SyncBuilder, SyncRunner};
use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt};
use super::data::{MboxMessage, load_mbox};
use super::error::{ContainerError, ContainerResult};
use super::imap_client::{ImapSeed, full_path};
use super::layouts::{self};
use super::sieve_client::SieveSeed as SieveClient;
use super::{Account, Endpoint};
const IMAP_PORT: u16 = 143;
const SIEVE_PORT: u16 = 4190;
const DOCKERFILE: &str = r#"FROM debian:bookworm-20260518-slim
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
dovecot-imapd dovecot-managesieved dovecot-pop3d dovecot-sieve \
openssl ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
addgroup --gid 5000 vmail && \
adduser --system --no-create-home --uid 5000 --gid 5000 vmail && \
mkdir -p /var/vmail && chown -R vmail:vmail /var/vmail && \
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/dovecot/dovecot.key \
-out /etc/dovecot/dovecot.crt \
-subj /CN=localhost && \
chmod 600 /etc/dovecot/dovecot.key
COPY dovecot.conf /etc/dovecot/dovecot.conf
COPY users /etc/dovecot/users
EXPOSE 143 4190
CMD ["dovecot", "-F"]
"#;
const DOVECOT_CONF: &str = r#"protocols = imap sieve
listen = *
mail_location = maildir:/var/vmail/%u/Maildir
mail_uid = 5000
mail_gid = 5000
mail_privileged_group = vmail
namespace inbox {
inbox = yes
separator = /
prefix =
}
auth_mechanisms = plain login
disable_plaintext_auth = no
passdb {
driver = passwd-file
args = /etc/dovecot/users
}
userdb {
driver = static
args = uid=5000 gid=5000 home=/var/vmail/%u
}
service imap-login {
inet_listener imap {
port = 143
}
}
service managesieve-login {
inet_listener sieve {
port = 4190
}
}
protocol sieve {
managesieve_max_line_length = 1M
managesieve_max_compile_errors = 5
}
ssl = yes
ssl_cert = </etc/dovecot/dovecot.crt
ssl_key = </etc/dovecot/dovecot.key
log_path = /dev/stderr
info_log_path = /dev/stderr
plugin {
sieve = file:~/sieve;active=~/.dovecot.sieve
sieve_max_script_size = 1M
}
"#;
fn users_file() -> String {
let mut out = String::new();
for u in layouts::accounts() {
out.push_str(u);
out.push_str(":{plain}");
out.push_str(layouts::PASSWORD);
out.push_str(":5000:5000::/var/vmail/");
out.push_str(u);
out.push_str("::\n");
}
out
}
pub struct Dovecot {
container: Container<GenericImage>,
pub imap: Endpoint,
pub sieve: Endpoint,
pub accounts: Vec<Account>,
}
impl Dovecot {
pub fn start() -> ContainerResult<Self> {
let image: GenericImage = GenericBuildableImage::new("vandelay-dovecot", "test")
.with_dockerfile_string(DOCKERFILE.to_owned())
.with_data(DOVECOT_CONF.as_bytes().to_vec(), "dovecot.conf")
.with_data(users_file().into_bytes(), "users")
.build_image()
.map_err(|e| ContainerError::Seed(format!("dovecot build: {e}")))?;
let request = image
.with_exposed_port(IMAP_PORT.tcp())
.with_exposed_port(SIEVE_PORT.tcp())
.with_wait_for(WaitFor::message_on_stderr("starting up"))
.with_startup_timeout(Duration::from_secs(120));
let container = request.start()?;
let host = container.get_host()?.to_string();
let imap = Endpoint::new(host.clone(), container.get_host_port_ipv4(IMAP_PORT.tcp())?);
let sieve = Endpoint::new(host, container.get_host_port_ipv4(SIEVE_PORT.tcp())?);
let accounts: Vec<Account> = layouts::accounts()
.iter()
.map(|name| Account {
username: (*name).to_owned(),
password: layouts::PASSWORD.to_owned(),
layout: layouts::layout_for(name),
})
.collect();
Ok(Self {
container,
imap,
sieve,
accounts,
})
}
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
let limit = self
.accounts
.iter()
.map(|a| a.layout.email_count)
.max()
.unwrap_or(0)
.max(1);
let messages = load_mbox(limit)?;
let mut out = Vec::new();
for acct in &self.accounts {
let mailbox = self.seed_imap(acct, &messages)?;
let sieve = self.seed_sieve(acct)?;
out.push(AccountSeed {
username: acct.username.clone(),
mailbox,
sieve,
});
}
Ok(out)
}
fn seed_imap(
&self,
account: &Account,
messages: &[MboxMessage],
) -> ContainerResult<MailboxSeed> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
let sep = client.discover_separator().unwrap_or('/');
let mut paths = Vec::new();
for spec in account.layout.mailboxes {
let path = full_path(account.layout.mailboxes, spec.key, sep).ok_or_else(|| {
ContainerError::Seed(format!("missing mailbox key: {}", spec.key))
})?;
client.create(&path)?;
client.subscribe(&path)?;
paths.push(path);
}
let mut targets: Vec<String> = vec!["INBOX".to_owned()];
targets.extend(paths.iter().cloned());
let mut histogram: HashMap<String, usize> =
targets.iter().map(|t| (t.clone(), 0)).collect();
let mut appends: Vec<SeededAppend> = Vec::new();
let total = account.layout.email_count.min(messages.len());
if total < account.layout.email_count {
return Err(ContainerError::Seed(format!(
"{} only has {} messages but layout requires {}",
account.username,
messages.len(),
account.layout.email_count
)));
}
for (i, msg) in messages.iter().take(total).enumerate() {
let target = targets[i % targets.len()].clone();
client.append_with_flags(&target, &[], &msg.raw)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: msg.raw.clone(),
target,
flags: Vec::new(),
tag: SeedTag::Bulk(i),
});
}
let mut dedup_target: Option<String> = None;
if targets.len() > 1 && !messages.is_empty() {
let target = targets[1].clone();
client.append_with_flags(&target, &[], &messages[0].raw)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: messages[0].raw.clone(),
target: target.clone(),
flags: Vec::new(),
tag: SeedTag::Dedup,
});
dedup_target = Some(target);
}
let mut flagged_target: Option<String> = None;
if !messages.is_empty() {
let target = "INBOX".to_owned();
let probe = flag_probe_message();
client.append_with_flags(&target, &["\\Seen", "\\Flagged"], &probe)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: probe,
target: target.clone(),
flags: vec!["$seen".to_owned(), "$flagged".to_owned()],
tag: SeedTag::FlagProbe,
});
flagged_target = Some(target);
}
let mut nomid_target: Option<String> = None;
if !messages.is_empty() {
let target = "INBOX".to_owned();
let probe = no_message_id_probe();
client.append_with_flags(&target, &[], &probe)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: probe,
target: target.clone(),
flags: Vec::new(),
tag: SeedTag::NoMessageId,
});
nomid_target = Some(target);
}
let mut mid_dedup_targets: Option<(String, String)> = None;
if targets.len() >= 3 {
let body = shared_message_id_probe();
let a = targets[1].clone();
let b = targets[2].clone();
client.append_with_flags(&a, &[], &body)?;
client.append_with_flags(&b, &[], &body)?;
*histogram.entry(a.clone()).or_insert(0) += 1;
*histogram.entry(b.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: body.clone(),
target: a.clone(),
flags: Vec::new(),
tag: SeedTag::SharedMid,
});
appends.push(SeededAppend {
raw: body,
target: b.clone(),
flags: Vec::new(),
tag: SeedTag::SharedMid,
});
mid_dedup_targets = Some((a, b));
}
client.logout()?;
let extras = ExtraAppends {
dedup_target,
flagged_target,
nomid_target,
mid_dedup_targets,
};
let total_appends = histogram_total(&histogram);
Ok(MailboxSeed {
paths,
histogram,
total_appends,
extras,
appends,
})
}
fn seed_sieve(&self, account: &Account) -> ContainerResult<SieveSeed> {
if account.layout.sieve_scripts.is_empty() {
return Ok(SieveSeed::default());
}
let mut client = SieveClient::connect_seed(&self.sieve.host, self.sieve.port)?;
client.authenticate(&account.username, &account.password)?;
let mut active_name: Option<&'static str> = None;
let mut names = Vec::new();
for script in account.layout.sieve_scripts {
client.putscript(script.name, script.body)?;
names.push(script.name.to_owned());
if script.active {
active_name = Some(script.name);
}
}
if let Some(name) = active_name {
client.setactive(name)?;
}
client.logout()?;
Ok(SieveSeed {
names,
active: active_name.map(str::to_owned),
})
}
pub fn delete_first_inbox_message(&self, account: &Account) -> ContainerResult<()> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
client.delete_and_expunge_first("INBOX")?;
client.logout()?;
Ok(())
}
pub fn append_new_message(
&self,
account: &Account,
mailbox: &str,
tag: &str,
) -> ContainerResult<(Vec<u8>, String)> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
let message_id = format!("<added-{tag}-{}@vandelay.test>", account.username);
let body = format!(
"From: added-{tag}@vandelay.test\r\n\
To: {}@vandelay.test\r\n\
Subject: Added probe {tag}\r\n\
Message-ID: {message_id}\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Added probe body {tag}.\r\n",
account.username
);
let raw = body.into_bytes();
client.append_with_flags(mailbox, &[], &raw)?;
client.logout()?;
Ok((raw, message_id))
}
pub fn install_broken_sieve(&self, account: &Account) -> ContainerResult<String> {
let mut client = SieveClient::connect_seed(&self.sieve.host, self.sieve.port)?;
client.authenticate(&account.username, &account.password)?;
let name = "broken-script";
client.putscript_raw(name, "INVALID:::not_a_sieve_program;;;")?;
client.logout()?;
Ok(name.to_owned())
}
pub fn verify_seed(&self, seeds: &[AccountSeed]) -> ContainerResult<()> {
for (acct, seed) in self.accounts.iter().zip(seeds) {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&acct.username, &acct.password)?;
let names = client.list_all()?;
let expected = acct.layout.mailboxes.len() + 1;
if names.len() < expected {
return Err(ContainerError::Seed(format!(
"{}: expected >= {expected} mailboxes, got {}",
acct.username,
names.len()
)));
}
let inbox_n = client.select("INBOX")?;
if acct.layout.email_count > 0 && inbox_n == 0 {
return Err(ContainerError::Seed(format!(
"{}: INBOX EXISTS = 0 after seed",
acct.username
)));
}
for path in &seed.mailbox.paths {
let n = client.select(path)?;
let want = seed.mailbox.histogram.get(path).copied().unwrap_or(0);
if n < want {
return Err(ContainerError::Seed(format!(
"{}: mailbox {path} EXISTS={n} but {want} were appended",
acct.username
)));
}
}
client.logout()?;
}
Ok(())
}
pub fn stop(self) -> ContainerResult<()> {
self.container.stop()?;
Ok(())
}
}
fn histogram_total(h: &HashMap<String, usize>) -> usize {
h.values().sum()
}
pub fn flag_probe_message() -> Vec<u8> {
let body = "From: [email protected]\r\n\
To: [email protected]\r\n\
Subject: Flag probe\r\n\
Message-ID: <[email protected]>\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Flag probe body.\r\n";
body.as_bytes().to_vec()
}
pub fn no_message_id_probe() -> Vec<u8> {
let body = "From: [email protected]\r\n\
To: [email protected]\r\n\
Subject: No Message-ID probe\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
No Message-ID body.\r\n";
body.as_bytes().to_vec()
}
pub fn shared_message_id_probe() -> Vec<u8> {
let body = "From: [email protected]\r\n\
To: [email protected]\r\n\
Subject: Shared MID probe\r\n\
Message-ID: <[email protected]>\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Shared MID body.\r\n";
body.as_bytes().to_vec()
}
#[derive(Debug, Clone)]
pub struct MailboxSeed {
pub paths: Vec<String>,
pub histogram: HashMap<String, usize>,
pub total_appends: usize,
pub extras: ExtraAppends,
pub appends: Vec<SeededAppend>,
}
#[derive(Debug, Clone)]
pub struct SeededAppend {
pub raw: Vec<u8>,
pub target: String,
pub flags: Vec<String>,
pub tag: SeedTag,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeedTag {
Bulk(usize),
Dedup,
FlagProbe,
NoMessageId,
SharedMid,
}
#[derive(Debug, Clone)]
pub struct ExtraAppends {
pub dedup_target: Option<String>,
pub flagged_target: Option<String>,
pub nomid_target: Option<String>,
pub mid_dedup_targets: Option<(String, String)>,
}
#[derive(Debug, Default, Clone)]
pub struct SieveSeed {
pub names: Vec<String>,
pub active: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AccountSeed {
pub username: String,
pub mailbox: MailboxSeed,
pub sieve: SieveSeed,
}
+37
View File
@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io;
use thiserror::Error;
pub type ContainerResult<T> = Result<T, ContainerError>;
#[derive(Debug, Error)]
pub enum ContainerError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("testcontainers: {0}")]
Testcontainers(#[from] testcontainers::TestcontainersError),
#[error("ureq: {0}")]
Ureq(#[from] Box<ureq::Error>),
#[error("utf8: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("env: {0}")]
Env(String),
#[error("protocol: {0}")]
Protocol(String),
#[error("seed: {0}")]
Seed(String),
#[error("resource: {0}")]
Resource(String),
}
impl From<ureq::Error> for ContainerError {
fn from(e: ureq::Error) -> Self {
ContainerError::Ureq(Box::new(e))
}
}
+283
View File
@@ -0,0 +1,283 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use super::error::{ContainerError, ContainerResult};
use super::layouts::MailboxSpec;
pub struct ImapSeed {
reader: BufReader<TcpStream>,
writer: TcpStream,
tag_seq: u32,
pub separator: char,
}
impl ImapSeed {
pub fn connect(host: &str, port: u16) -> ContainerResult<Self> {
let deadline = std::time::Instant::now() + Duration::from_secs(30);
let stream = loop {
match TcpStream::connect((host, port)) {
Ok(s) => break s,
Err(e) => {
if std::time::Instant::now() >= deadline {
return Err(e.into());
}
std::thread::sleep(Duration::from_millis(250));
}
}
};
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
stream.set_write_timeout(Some(Duration::from_secs(30)))?;
let writer = stream.try_clone()?;
let mut me = Self {
reader: BufReader::new(stream),
writer,
tag_seq: 0,
separator: '/',
};
let greeting = me.read_line()?;
if !greeting.starts_with("* OK") && !greeting.starts_with("* PREAUTH") {
return Err(ContainerError::Protocol(format!(
"imap greeting unexpected: {greeting}"
)));
}
Ok(me)
}
fn next_tag(&mut self) -> String {
self.tag_seq += 1;
format!("a{:04}", self.tag_seq)
}
fn read_line(&mut self) -> ContainerResult<String> {
let mut line = String::new();
let n = self.reader.read_line(&mut line)?;
if n == 0 {
return Err(ContainerError::Protocol("imap eof".to_owned()));
}
if line.ends_with('\n') {
line.pop();
if line.ends_with('\r') {
line.pop();
}
}
Ok(line)
}
fn read_tagged(&mut self, tag: &str) -> ContainerResult<Vec<String>> {
let mut untagged = Vec::new();
loop {
let line = self.read_line()?;
if let Some(rest) = line.strip_prefix(&format!("{tag} ")) {
if rest.starts_with("OK") {
return Ok(untagged);
}
return Err(ContainerError::Protocol(format!(
"imap tagged failure: {line}"
)));
}
untagged.push(line);
}
}
fn send(&mut self, line: &str) -> ContainerResult<()> {
self.writer.write_all(line.as_bytes())?;
self.writer.write_all(b"\r\n")?;
self.writer.flush()?;
Ok(())
}
pub fn login(&mut self, user: &str, password: &str) -> ContainerResult<()> {
let tag = self.next_tag();
let escaped_user = quote(user);
let escaped_pw = quote(password);
self.send(&format!("{tag} LOGIN {escaped_user} {escaped_pw}"))?;
self.read_tagged(&tag)?;
Ok(())
}
pub fn discover_separator(&mut self) -> ContainerResult<char> {
let tag = self.next_tag();
self.send(&format!(r#"{tag} LIST "" """#))?;
let untagged = self.read_tagged(&tag)?;
for line in &untagged {
if let Some(rest) = line.strip_prefix("* LIST ")
&& let Some(open) = rest.find('"')
&& let Some(close) = rest[open + 1..].find('"')
{
let sep = &rest[open + 1..open + 1 + close];
if let Some(c) = sep.chars().next() {
self.separator = c;
return Ok(c);
}
}
}
Ok('/')
}
pub fn create(&mut self, full_path: &str) -> ContainerResult<()> {
let tag = self.next_tag();
self.send(&format!("{tag} CREATE {}", quote(full_path)))?;
match self.read_tagged(&tag) {
Ok(_) => Ok(()),
Err(ContainerError::Protocol(msg)) if msg.contains("already exists") => Ok(()),
Err(e) => Err(e),
}
}
pub fn subscribe(&mut self, full_path: &str) -> ContainerResult<()> {
let tag = self.next_tag();
self.send(&format!("{tag} SUBSCRIBE {}", quote(full_path)))?;
let _ = self.read_tagged(&tag);
Ok(())
}
pub fn append(&mut self, mailbox: &str, message: &[u8]) -> ContainerResult<()> {
self.append_with_flags(mailbox, &[], message)
}
pub fn append_with_flags(
&mut self,
mailbox: &str,
flags: &[&str],
message: &[u8],
) -> ContainerResult<()> {
let tag = self.next_tag();
let flag_clause = if flags.is_empty() {
String::new()
} else {
format!(" ({})", flags.join(" "))
};
let cmd = format!(
"{tag} APPEND {}{flag_clause} {{{}}}\r\n",
quote(mailbox),
message.len(),
);
self.writer.write_all(cmd.as_bytes())?;
self.writer.flush()?;
let cont = self.read_line()?;
if !cont.starts_with('+') {
return Err(ContainerError::Protocol(format!(
"append expected continuation, got: {cont}"
)));
}
self.writer.write_all(message)?;
self.writer.write_all(b"\r\n")?;
self.writer.flush()?;
self.read_tagged(&tag)?;
Ok(())
}
pub fn list_all(&mut self) -> ContainerResult<Vec<String>> {
let tag = self.next_tag();
self.send(&format!(r#"{tag} LIST "" "*""#))?;
let untagged = self.read_tagged(&tag)?;
let mut names = Vec::new();
for line in untagged {
if let Some(rest) = line.strip_prefix("* LIST ")
&& let Some(name) = parse_list_name(rest)
{
names.push(name);
}
}
Ok(names)
}
pub fn select(&mut self, mailbox: &str) -> ContainerResult<usize> {
let tag = self.next_tag();
self.send(&format!("{tag} SELECT {}", quote(mailbox)))?;
let untagged = self.read_tagged(&tag)?;
for line in untagged {
if let Some(rest) = line.strip_prefix("* ")
&& let Some(num) = rest.strip_suffix(" EXISTS")
&& let Ok(n) = num.parse::<usize>()
{
return Ok(n);
}
}
Ok(0)
}
pub fn delete_and_expunge_first(&mut self, mailbox: &str) -> ContainerResult<()> {
self.select(mailbox)?;
let store_tag = self.next_tag();
self.send(&format!("{store_tag} STORE 1 +FLAGS (\\Deleted)"))?;
self.read_tagged(&store_tag)?;
let expunge_tag = self.next_tag();
self.send(&format!("{expunge_tag} EXPUNGE"))?;
self.read_tagged(&expunge_tag)?;
Ok(())
}
pub fn logout(&mut self) -> ContainerResult<()> {
let tag = self.next_tag();
self.send(&format!("{tag} LOGOUT"))?;
let mut buf = String::new();
let _ = self.reader.read_to_string(&mut buf);
Ok(())
}
}
pub fn full_path(specs: &[MailboxSpec], key: &str, separator: char) -> Option<String> {
let mut chain: Vec<&str> = Vec::new();
let mut cur = key;
loop {
let spec = specs.iter().find(|s| s.key == cur)?;
chain.push(spec.name);
match spec.parent {
Some(p) => cur = p,
None => break,
}
}
chain.reverse();
Some(chain.join(&separator.to_string()))
}
fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
if c == '\\' || c == '"' {
out.push('\\');
}
out.push(c);
}
out.push('"');
out
}
fn parse_list_name(line: &str) -> Option<String> {
let mut chars = line.chars();
let _ = chars.next()?;
let mut depth = 1;
let mut idx = 1;
for (i, c) in line[1..].char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
idx = i + 2;
break;
}
}
_ => {}
}
}
let rest = line[idx..].trim_start();
let mut parts = rest.splitn(2, ' ');
let _sep_token = parts.next()?;
let name_token = parts.next()?.trim();
if let Some(stripped) = name_token.strip_prefix('"') {
let end = stripped.find('"')?;
Some(stripped[..end].to_owned())
} else {
Some(name_token.to_owned())
}
}
+223
View File
@@ -0,0 +1,223 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
#[derive(Debug, Clone, Copy)]
pub struct MailboxSpec {
pub key: &'static str,
pub name: &'static str,
pub parent: Option<&'static str>,
}
#[derive(Debug, Clone, Copy)]
pub struct FileSpec {
pub key: &'static str,
pub name: &'static str,
pub parent: Option<&'static str>,
pub directory: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct SieveScript {
pub name: &'static str,
pub body: &'static str,
pub active: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct Layout {
pub mailboxes: &'static [MailboxSpec],
pub files: &'static [FileSpec],
pub calendars: &'static [&'static str],
pub address_books: &'static [&'static str],
pub sieve_scripts: &'static [SieveScript],
pub email_count: usize,
pub contact_count: usize,
pub event_count: usize,
}
macro_rules! mailbox {
($k:expr, $n:expr, $p:expr) => {
MailboxSpec {
key: $k,
name: $n,
parent: $p,
}
};
}
macro_rules! node {
($k:expr, $n:expr, $p:expr, $d:expr) => {
FileSpec {
key: $k,
name: $n,
parent: $p,
directory: $d,
}
};
}
pub const ACCOUNT1: &str = "user1";
pub const ACCOUNT2: &str = "user2";
pub const ACCOUNT3: &str = "user3";
pub const PASSWORD: &str = "VandelayUser#2026";
pub static MAILBOXES_DEEP: &[MailboxSpec] = &[
mailbox!("proj", "Projects", None),
mailbox!("y2024", "2024", Some("proj")),
mailbox!("q1", "Q1", Some("y2024")),
mailbox!("acme", "ClientAcme", Some("q1")),
mailbox!("acme_in", "Internal", Some("acme")),
mailbox!("q2", "Q2", Some("y2024")),
mailbox!("globex", "ClientGlobex", Some("q2")),
mailbox!("y2025", "2025", Some("proj")),
mailbox!("alpha", "Alpha", Some("y2025")),
mailbox!("beta", "Beta", Some("y2025")),
mailbox!("travel", "Travel", None),
mailbox!("asia", "Asia", Some("travel")),
mailbox!("jp", "Japan", Some("asia")),
mailbox!("tokyo", "Tokyo", Some("jp")),
mailbox!("eu", "Europe", Some("travel")),
mailbox!("it", "Italy", Some("eu")),
mailbox!("rome", "Rome", Some("it")),
mailbox!("arch", "Archive", None),
mailbox!("arch23", "2023", Some("arch")),
mailbox!("arch24", "2024", Some("arch")),
];
pub static MAILBOXES_MEDIUM: &[MailboxSpec] = &[
mailbox!("work", "Work", None),
mailbox!("clients", "Clients", Some("work")),
mailbox!("acme", "AcmeCorp", Some("clients")),
mailbox!("inv", "Invoices", Some("acme")),
mailbox!("globex", "Globex", Some("clients")),
mailbox!("internal", "Internal", Some("work")),
mailbox!("pers", "Personal", None),
mailbox!("rcpt", "Receipts", Some("pers")),
];
pub static MAILBOXES_FLAT: &[MailboxSpec] = &[
mailbox!("lists", "Lists", None),
mailbox!("news", "Newsletters", Some("lists")),
mailbox!("misc", "Misc", None),
];
pub static FILES_DEEP: &[FileSpec] = &[
node!("docs", "Documents", None, true),
node!("rep", "Reports", Some("docs"), true),
node!("d24", "2024", Some("rep"), true),
node!("q1f", "q1-results.bin", Some("d24"), false),
node!("q2f", "q2-results.bin", Some("d24"), false),
node!("d25", "2025", Some("rep"), true),
node!("q1f25", "q1-results.bin", Some("d25"), false),
node!("spec", "Specs", Some("docs"), true),
node!("ovr", "overview.bin", Some("spec"), false),
node!("notes", "notes.bin", Some("docs"), false),
node!("photos", "Photos", None, true),
node!("vac", "Vacations", Some("photos"), true),
node!("vit", "Italy", Some("vac"), true),
node!("vrome", "rome.bin", Some("vit"), false),
node!("vjp", "Japan", Some("vac"), true),
node!("vtokyo", "tokyo.bin", Some("vjp"), false),
node!("readme", "README.bin", None, false),
];
pub static FILES_MEDIUM: &[FileSpec] = &[
node!("media", "Media", None, true),
node!("audio", "Audio", Some("media"), true),
node!("song", "song.bin", Some("audio"), false),
node!("video", "Video", Some("media"), true),
node!("clip", "clip.bin", Some("video"), false),
node!("proj", "Projects", None, true),
node!("src", "src", Some("proj"), true),
node!("main", "main.bin", Some("src"), false),
];
pub static FILES_FLAT: &[FileSpec] = &[
node!("bak", "Backup", None, true),
node!("snap", "snapshot.bin", Some("bak"), false),
node!("scratch", "scratch.bin", None, false),
];
pub static CALENDARS_DEEP: &[&str] = &["Personal", "Work", "Birthdays", "Travel"];
pub static CALENDARS_MEDIUM: &[&str] = &["Personal", "Work"];
pub static CALENDARS_FLAT: &[&str] = &["Personal", "Holidays"];
pub static ADDRESS_BOOKS_DEEP: &[&str] = &["Personal", "Work", "Friends"];
pub static ADDRESS_BOOKS_MEDIUM: &[&str] = &["Personal", "Work"];
pub static ADDRESS_BOOKS_FLAT: &[&str] = &["Personal", "Family"];
pub static SIEVE_DEEP: &[SieveScript] = &[
SieveScript {
name: "spam",
body: "require [\"fileinto\"];\nif header :contains \"X-Spam\" \"YES\" {\n fileinto \"Junk\";\n stop;\n}\n",
active: true,
},
SieveScript {
name: "vacation",
body: "require [\"vacation\"];\nvacation :days 7 :subject \"Out of office\" \"I am away, will reply later.\";\n",
active: false,
},
SieveScript {
name: "organize",
body: "require [\"fileinto\",\"mailbox\"];\nif address :is \"from\" \"[email protected]\" {\n fileinto :create \"Projects/2025/Beta\";\n}\n",
active: false,
},
];
pub static SIEVE_MEDIUM: &[SieveScript] = &[
SieveScript {
name: "filter",
body: "require [\"fileinto\"];\nif address :is \"to\" \"[email protected]\" {\n fileinto \"Work\";\n}\n",
active: true,
},
SieveScript {
name: "draft",
body: "require [\"reject\"];\nif address :is \"from\" \"[email protected]\" {\n reject \"go away\";\n}\n",
active: false,
},
];
pub static SIEVE_FLAT: &[SieveScript] = &[];
pub fn layout_for(username: &str) -> Layout {
match username {
s if s == ACCOUNT1 => Layout {
mailboxes: MAILBOXES_DEEP,
files: FILES_DEEP,
calendars: CALENDARS_DEEP,
address_books: ADDRESS_BOOKS_DEEP,
sieve_scripts: SIEVE_DEEP,
email_count: 200,
contact_count: 40,
event_count: 40,
},
s if s == ACCOUNT2 => Layout {
mailboxes: MAILBOXES_MEDIUM,
files: FILES_MEDIUM,
calendars: CALENDARS_MEDIUM,
address_books: ADDRESS_BOOKS_MEDIUM,
sieve_scripts: SIEVE_MEDIUM,
email_count: 80,
contact_count: 15,
event_count: 15,
},
_ => Layout {
mailboxes: MAILBOXES_FLAT,
files: FILES_FLAT,
calendars: CALENDARS_FLAT,
address_books: ADDRESS_BOOKS_FLAT,
sieve_scripts: SIEVE_FLAT,
email_count: 20,
contact_count: 10,
event_count: 10,
},
}
}
pub fn accounts() -> [&'static str; 3] {
[ACCOUNT1, ACCOUNT2, ACCOUNT3]
}
+49
View File
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
#![allow(dead_code, unused_imports)]
pub mod baikal;
pub mod cyrus;
pub mod data;
pub mod dav_client;
pub mod dovecot;
pub mod error;
pub mod imap_client;
pub mod layouts;
pub mod radicale;
pub mod sieve_client;
pub mod stalwart;
pub mod validate;
pub mod webdav;
pub use error::{ContainerError, ContainerResult};
#[derive(Debug, Clone)]
pub struct Endpoint {
pub host: String,
pub port: u16,
}
impl Endpoint {
pub fn new(host: impl Into<String>, port: u16) -> Self {
Self {
host: host.into(),
port,
}
}
pub fn http_base(&self) -> String {
format!("http://{}:{}", self.host, self.port)
}
}
#[derive(Debug, Clone)]
pub struct Account {
pub username: String,
pub password: String,
pub layout: layouts::Layout,
}
+385
View File
@@ -0,0 +1,385 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashSet;
use std::time::Duration;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::SyncRunner;
use testcontainers::{Container, GenericImage, ImageExt};
use super::data::{RawFixture, load_icals, load_vcards, malformed_ical, rewrite_uid};
use super::dav_client::DavSeed;
use super::error::{ContainerError, ContainerResult};
use super::layouts::{self, Layout};
use super::{Account, Endpoint};
const IMAGE_NAME: &str = "tomsquest/docker-radicale";
const IMAGE_TAG: &str = "3.7.3.0";
const RADICALE_PORT: u16 = 5232;
const CONFIG: &str = "[server]
hosts = 0.0.0.0:5232
[auth]
type = htpasswd
htpasswd_filename = /config/users
htpasswd_encryption = plain
[storage]
filesystem_folder = /data/collections
";
pub struct Radicale {
container: Container<GenericImage>,
pub endpoint: Endpoint,
pub accounts: Vec<Account>,
}
impl Radicale {
pub fn start() -> ContainerResult<Self> {
let mut users = String::new();
for u in layouts::accounts() {
users.push_str(u);
users.push(':');
users.push_str(layouts::PASSWORD);
users.push('\n');
}
let image = GenericImage::new(IMAGE_NAME, IMAGE_TAG)
.with_exposed_port(RADICALE_PORT.tcp())
.with_wait_for(WaitFor::message_on_stderr("Radicale server ready"));
let request = image
.with_copy_to("/config/config", CONFIG.as_bytes().to_vec())
.with_copy_to("/config/users", users.into_bytes())
.with_startup_timeout(Duration::from_secs(90));
let container = request.start()?;
let host = container.get_host()?.to_string();
let port = container.get_host_port_ipv4(RADICALE_PORT.tcp())?;
let accounts: Vec<Account> = layouts::accounts()
.iter()
.map(|name| Account {
username: (*name).to_owned(),
password: layouts::PASSWORD.to_owned(),
layout: layouts::layout_for(name),
})
.collect();
Ok(Self {
container,
endpoint: Endpoint::new(host, port),
accounts,
})
}
pub fn base_url(&self) -> String {
self.endpoint.http_base()
}
pub fn account_url(&self, account: &Account) -> String {
format!("{}/{}/", self.base_url(), account.username)
}
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
let icals = load_icals()?;
let vcards = load_vcards()?;
let mut out = Vec::new();
for acct in &self.accounts {
let seed = self.seed_account(acct, &icals, &vcards)?;
out.push(seed);
}
Ok(out)
}
pub fn seed_account(
&self,
account: &Account,
icals: &[RawFixture],
vcards: &[RawFixture],
) -> ContainerResult<AccountSeed> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
let layout = &account.layout;
let mut seed = AccountSeed::new(account.username.clone());
for (idx, cal) in layout.calendars.iter().enumerate() {
let collection = collection_segment("cal", idx, cal);
let base = format!("/{}/{}/", account.username, collection);
let body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<set><prop>
<resourcetype><collection/><c:calendar/></resourcetype>
<displayname>{cal}</displayname>
</prop></set>
</mkcol>"#
);
client.mkcol(&base, Some(&body))?;
let mut plan = CollectionPlan::new((*cal).to_owned(), base.clone());
let count = events_per_calendar(layout, idx);
let offset = events_offset(layout, idx);
for (i, fixture) in icals.iter().cycle().skip(offset).take(count).enumerate() {
let path = format!("{base}{}-{}.ics", fixture.name, i);
let suffix = format!("{collection}-{i}");
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix).ok_or_else(|| {
ContainerError::Seed(format!("ical fixture {} has no UID", fixture.name))
})?;
client.put(&path, "text/calendar; charset=utf-8", &bytes)?;
plan.items.push(SeededItem {
uid,
href: path,
source: bytes,
});
}
seed.calendars.push(plan);
}
for (idx, ab) in layout.address_books.iter().enumerate() {
let collection = collection_segment("ab", idx, ab);
let base = format!("/{}/{}/", account.username, collection);
let body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<set><prop>
<resourcetype><collection/><card:addressbook/></resourcetype>
<displayname>{ab}</displayname>
</prop></set>
</mkcol>"#
);
client.mkcol(&base, Some(&body))?;
let mut plan = CollectionPlan::new((*ab).to_owned(), base.clone());
let count = contacts_per_book(layout, idx);
let offset = contacts_offset(layout, idx);
for (i, fixture) in vcards.iter().cycle().skip(offset).take(count).enumerate() {
let path = format!("{base}{}-{}.vcf", fixture.name, i);
let suffix = format!("{collection}-{i}");
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix).ok_or_else(|| {
ContainerError::Seed(format!("vcard fixture {} has no UID", fixture.name))
})?;
client.put(&path, "text/vcard; charset=utf-8", &bytes)?;
plan.items.push(SeededItem {
uid,
href: path,
source: bytes,
});
}
seed.address_books.push(plan);
}
Ok(seed)
}
pub fn seed_malformed_event(
&self,
account: &Account,
calendar_segment_idx: usize,
tag: &str,
) -> ContainerResult<String> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
let collection = collection_segment(
"cal",
calendar_segment_idx,
account.layout.calendars[calendar_segment_idx],
);
let path = format!("/{}/{}/broken-{tag}.ics", account.username, collection);
let bad = malformed_ical(tag);
client.put(&path, "text/calendar; charset=utf-8", &bad.bytes)?;
Ok(path)
}
pub fn delete_item(&self, account: &Account, path: &str) -> ContainerResult<()> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
client.delete(path)?;
Ok(())
}
pub fn add_event(
&self,
account: &Account,
calendar_segment_idx: usize,
tag: &str,
icals: &[RawFixture],
) -> ContainerResult<(String, String)> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
let display = account.layout.calendars[calendar_segment_idx];
let collection = collection_segment("cal", calendar_segment_idx, display);
let fixture = icals.first().ok_or_else(|| {
ContainerError::Seed("no ical fixture available for add_event".to_owned())
})?;
let suffix = format!("added-{tag}");
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix)
.ok_or_else(|| ContainerError::Seed("ical fixture has no UID".to_owned()))?;
let path = format!("/{}/{collection}/added-{tag}.ics", account.username);
client.put(&path, "text/calendar; charset=utf-8", &bytes)?;
Ok((path, uid))
}
pub fn add_contact(
&self,
account: &Account,
book_segment_idx: usize,
tag: &str,
vcards: &[RawFixture],
) -> ContainerResult<(String, String)> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
let display = account.layout.address_books[book_segment_idx];
let collection = collection_segment("ab", book_segment_idx, display);
let fixture = vcards.first().ok_or_else(|| {
ContainerError::Seed("no vcard fixture available for add_contact".to_owned())
})?;
let suffix = format!("added-{tag}");
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix)
.ok_or_else(|| ContainerError::Seed("vcard fixture has no UID".to_owned()))?;
let path = format!("/{}/{collection}/added-{tag}.vcf", account.username);
client.put(&path, "text/vcard; charset=utf-8", &bytes)?;
Ok((path, uid))
}
pub fn verify_seed(&self, seeds: &[AccountSeed]) -> ContainerResult<()> {
for (acct, seed) in self.accounts.iter().zip(seeds) {
let client = DavSeed::new(self.base_url(), &acct.username, &acct.password);
let body = client.propfind(&format!("/{}/", acct.username), 1)?;
if !body.contains("multistatus") {
return Err(ContainerError::Protocol(format!(
"radicale propfind for {} returned no multistatus",
acct.username
)));
}
let expected_cal_names: HashSet<&str> = seed
.calendars
.iter()
.map(|c| c.display_name.as_str())
.collect();
let expected_ab_names: HashSet<&str> = seed
.address_books
.iter()
.map(|c| c.display_name.as_str())
.collect();
if expected_cal_names.is_empty() && expected_ab_names.is_empty() {
continue;
}
}
Ok(())
}
pub fn stop(self) -> ContainerResult<()> {
self.container.stop()?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SeededItem {
pub uid: String,
pub href: String,
pub source: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct CollectionPlan {
pub display_name: String,
pub base_href: String,
pub items: Vec<SeededItem>,
}
impl CollectionPlan {
fn new(display_name: String, base_href: String) -> Self {
Self {
display_name,
base_href,
items: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct AccountSeed {
pub username: String,
pub calendars: Vec<CollectionPlan>,
pub address_books: Vec<CollectionPlan>,
}
impl AccountSeed {
fn new(username: String) -> Self {
Self {
username,
calendars: Vec::new(),
address_books: Vec::new(),
}
}
pub fn total_events(&self) -> usize {
self.calendars.iter().map(|c| c.items.len()).sum()
}
pub fn total_contacts(&self) -> usize {
self.address_books.iter().map(|c| c.items.len()).sum()
}
pub fn event_uids(&self) -> HashSet<String> {
self.calendars
.iter()
.flat_map(|c| c.items.iter().map(|i| i.uid.clone()))
.collect()
}
pub fn contact_uids(&self) -> HashSet<String> {
self.address_books
.iter()
.flat_map(|c| c.items.iter().map(|i| i.uid.clone()))
.collect()
}
}
fn collection_segment(prefix: &str, idx: usize, name: &str) -> String {
let mut out = format!("{prefix}-{:02}-", idx);
for c in name.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
out.trim_end_matches('-').to_owned()
}
fn events_per_calendar(layout: &Layout, idx: usize) -> usize {
if layout.calendars.is_empty() {
return 0;
}
let base = layout.event_count / layout.calendars.len().max(1);
let extra = if idx == 0 {
layout.event_count % layout.calendars.len()
} else {
0
};
base + extra
}
fn events_offset(layout: &Layout, idx: usize) -> usize {
(0..idx).map(|i| events_per_calendar(layout, i)).sum()
}
fn contacts_per_book(layout: &Layout, idx: usize) -> usize {
if layout.address_books.is_empty() {
return 0;
}
let base = layout.contact_count / layout.address_books.len().max(1);
let extra = if idx == 0 {
layout.contact_count % layout.address_books.len()
} else {
0
};
base + extra
}
fn contacts_offset(layout: &Layout, idx: usize) -> usize {
(0..idx).map(|i| contacts_per_book(layout, i)).sum()
}
+150
View File
@@ -0,0 +1,150 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use super::error::{ContainerError, ContainerResult};
pub struct SieveSeed {
reader: BufReader<TcpStream>,
writer: TcpStream,
}
impl SieveSeed {
pub fn connect_seed(host: &str, port: u16) -> ContainerResult<Self> {
Self::connect(host, port)
}
pub fn connect(host: &str, port: u16) -> ContainerResult<Self> {
let stream = TcpStream::connect((host, port))?;
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
stream.set_write_timeout(Some(Duration::from_secs(30)))?;
let writer = stream.try_clone()?;
let mut me = Self {
reader: BufReader::new(stream),
writer,
};
me.read_until_ok()?;
Ok(me)
}
fn read_line(&mut self) -> ContainerResult<String> {
let mut line = String::new();
let n = self.reader.read_line(&mut line)?;
if n == 0 {
return Err(ContainerError::Protocol("sieve eof".to_owned()));
}
if line.ends_with('\n') {
line.pop();
if line.ends_with('\r') {
line.pop();
}
}
Ok(line)
}
fn read_until_ok(&mut self) -> ContainerResult<Vec<String>> {
let mut lines = Vec::new();
loop {
let line = self.read_line()?;
if line.starts_with("OK") {
return Ok(lines);
}
if line.starts_with("NO") || line.starts_with("BYE") {
return Err(ContainerError::Protocol(format!("sieve: {line}")));
}
lines.push(line);
}
}
fn write_all(&mut self, bytes: &[u8]) -> ContainerResult<()> {
self.writer.write_all(bytes)?;
self.writer.flush()?;
Ok(())
}
pub fn authenticate(&mut self, user: &str, password: &str) -> ContainerResult<()> {
let mut blob = Vec::new();
blob.push(0u8);
blob.extend_from_slice(user.as_bytes());
blob.push(0u8);
blob.extend_from_slice(password.as_bytes());
let encoded = B64.encode(&blob);
let cmd = format!(
"AUTHENTICATE \"PLAIN\" {{{}+}}\r\n{encoded}\r\n",
encoded.len()
);
self.write_all(cmd.as_bytes())?;
self.read_until_ok()?;
Ok(())
}
pub fn putscript(&mut self, name: &str, body: &str) -> ContainerResult<()> {
let cmd = format!(
"PUTSCRIPT \"{}\" {{{}+}}\r\n{}\r\n",
escape(name),
body.len(),
body
);
self.write_all(cmd.as_bytes())?;
self.read_until_ok()?;
Ok(())
}
pub fn putscript_raw(&mut self, name: &str, body: &str) -> ContainerResult<bool> {
let cmd = format!(
"PUTSCRIPT \"{}\" {{{}+}}\r\n{}\r\n",
escape(name),
body.len(),
body
);
self.write_all(cmd.as_bytes())?;
match self.read_until_ok() {
Ok(_) => Ok(true),
Err(ContainerError::Protocol(_)) => Ok(false),
Err(e) => Err(e),
}
}
pub fn setactive(&mut self, name: &str) -> ContainerResult<()> {
self.write_all(format!("SETACTIVE \"{}\"\r\n", escape(name)).as_bytes())?;
self.read_until_ok()?;
Ok(())
}
pub fn listscripts(&mut self) -> ContainerResult<Vec<(String, bool)>> {
self.write_all(b"LISTSCRIPTS\r\n")?;
let lines = self.read_until_ok()?;
let mut scripts = Vec::new();
for line in lines {
let trimmed = line.trim();
if let Some(stripped) = trimmed.strip_prefix('"') {
let close = stripped.find('"').unwrap_or(stripped.len());
let name = stripped[..close].to_owned();
let rest = &stripped[close..];
let active = rest.contains("ACTIVE");
scripts.push((name, active));
}
}
Ok(scripts)
}
pub fn logout(&mut self) -> ContainerResult<()> {
let _ = self.write_all(b"LOGOUT\r\n");
let mut buf = String::new();
let _ = self.reader.read_to_string(&mut buf);
Ok(())
}
}
fn escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
+165
View File
@@ -0,0 +1,165 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::net::TcpListener;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use base64::Engine;
use serde_json::Value;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::SyncRunner;
use testcontainers::{Container, GenericImage, ImageExt};
use ureq::Agent;
use ureq::config::RedirectAuthHeaders;
use ureq::tls::{TlsConfig, TlsProvider};
use super::error::{ContainerError, ContainerResult};
const IMAGE_NAME: &str = "stalwartlabs/stalwart";
const IMAGE_TAG: &str = "latest";
const HTTPS_PORT: u16 = 443;
const IMAP_PORT: u16 = 143;
const IMAPS_PORT: u16 = 993;
const SIEVE_PORT: u16 = 4190;
const CONFIG_PATH: &str = "/etc/stalwart/config.json";
const CONFIG_JSON: &str = r#"{"@type":"RocksDb","blobSize":16834,"bufferSize":134217728,"path":"/var/lib/stalwart","poolWorkers":null}"#;
pub const ADMIN_USER: &str = "admin";
pub const ADMIN_PASSWORD: &str = "admin";
pub struct Stalwart {
_container: Container<GenericImage>,
pub host: String,
pub https_port: u16,
pub imap_port: u16,
pub imaps_port: u16,
pub sieve_port: u16,
pub public_url: String,
}
impl Stalwart {
pub fn start() -> ContainerResult<Self> {
let https_port = pick_free_port()?;
let imap_port = pick_free_port()?;
let imaps_port = pick_free_port()?;
let sieve_port = pick_free_port()?;
let host = "127.0.0.1".to_owned();
let public_url = format!("https://{host}:{https_port}");
let image = GenericImage::new(IMAGE_NAME, IMAGE_TAG)
.with_exposed_port(HTTPS_PORT.tcp())
.with_exposed_port(IMAP_PORT.tcp())
.with_exposed_port(IMAPS_PORT.tcp())
.with_exposed_port(SIEVE_PORT.tcp())
.with_wait_for(WaitFor::seconds(2));
let request = image
.with_env_var("STALWART_PUBLIC_URL", &public_url)
.with_env_var("STALWART_RECOVERY_ADMIN", "admin:admin")
.with_copy_to(CONFIG_PATH, CONFIG_JSON.as_bytes().to_vec())
.with_mapped_port(https_port, HTTPS_PORT.tcp())
.with_mapped_port(imap_port, IMAP_PORT.tcp())
.with_mapped_port(imaps_port, IMAPS_PORT.tcp())
.with_mapped_port(sieve_port, SIEVE_PORT.tcp())
.with_startup_timeout(Duration::from_secs(180));
let container = request.start()?;
let me = Self {
_container: container,
host,
https_port,
imap_port,
imaps_port,
sieve_port,
public_url,
};
me.wait_ready(Duration::from_secs(120))?;
Ok(me)
}
pub fn base_url(&self) -> &str {
&self.public_url
}
pub fn fetch_jmap_session(&self) -> ContainerResult<Value> {
let agent = build_agent();
let auth = basic(ADMIN_USER, ADMIN_PASSWORD);
let url = format!("{}/.well-known/jmap", self.public_url);
let mut resp = agent.get(&url).header("Authorization", &auth).call()?;
let status = resp.status().as_u16();
let text = resp
.body_mut()
.read_to_string()
.map_err(|e| ContainerError::Protocol(format!("body read: {e}")))?;
if status != 200 {
return Err(ContainerError::Protocol(format!(
"jmap session status {status}: {text}"
)));
}
serde_json::from_str(&text)
.map_err(|e| ContainerError::Protocol(format!("jmap session parse: {e}")))
}
fn wait_ready(&self, total: Duration) -> ContainerResult<()> {
let deadline = Instant::now() + total;
let mut last_err = String::from("no probe attempted");
while Instant::now() < deadline {
match self.fetch_jmap_session() {
Ok(v) if v.get("apiUrl").is_some() => return Ok(()),
Ok(v) => last_err = format!("session missing apiUrl: {v}"),
Err(e) => last_err = e.to_string(),
}
std::thread::sleep(Duration::from_millis(500));
}
Err(ContainerError::Protocol(format!(
"stalwart did not become ready in {total:?}: {last_err}"
)))
}
}
static SHARED: OnceLock<Stalwart> = OnceLock::new();
pub fn shared() -> &'static Stalwart {
SHARED.get_or_init(|| Stalwart::start().expect("start shared stalwart container"))
}
fn pick_free_port() -> ContainerResult<u16> {
let listener = TcpListener::bind("127.0.0.1:0")?;
Ok(listener.local_addr()?.port())
}
fn build_agent() -> Agent {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let tls = TlsConfig::builder()
.provider(TlsProvider::Rustls)
.unversioned_rustls_crypto_provider(provider)
.disable_verification(true)
.build();
Agent::config_builder()
.tls_config(tls)
.http_status_as_error(false)
.max_redirects(10)
.redirect_auth_headers(RedirectAuthHeaders::SameHost)
.build()
.new_agent()
}
fn basic(user: &str, password: &str) -> String {
let mut raw = String::with_capacity(user.len() + password.len() + 1);
raw.push_str(user);
raw.push(':');
raw.push_str(password);
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(raw.as_bytes())
)
}
+610
View File
@@ -0,0 +1,610 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use vandelay::logging::Logger;
use vandelay::sync::CommonConfig;
pub fn tmp_archive(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"vandelay-container-{tag}-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let _ = std::fs::remove_file(&p);
p
}
pub fn common(archive: &Path) -> CommonConfig {
CommonConfig {
archive: archive.to_path_buf(),
threads: 2,
dry_run: false,
max_retries: 3,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
}
}
pub fn open_archive(archive: &Path) -> Connection {
Connection::open(archive).expect("open archive")
}
pub fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap_or(0)
}
pub fn blob_bytes(conn: &Connection, blob_id: i64) -> Vec<u8> {
conn.query_row("SELECT data FROM blobs WHERE id = ?1", [blob_id], |r| {
r.get::<_, Vec<u8>>(0)
})
.expect("blob fetch")
}
pub fn mailbox_path(conn: &Connection, mailbox_id: i64, sep: char) -> String {
let mut parts: Vec<String> = Vec::new();
let mut cur = Some(mailbox_id);
while let Some(id) = cur {
let (name, parent): (String, Option<i64>) = conn
.query_row(
"SELECT name, parent_id FROM mailboxes WHERE id = ?1",
[id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("mailbox path");
parts.push(name);
cur = parent;
}
parts.reverse();
parts.join(&sep.to_string())
}
pub fn file_path(conn: &Connection, node_id: i64) -> String {
let mut parts: Vec<String> = Vec::new();
let mut cur = Some(node_id);
while let Some(id) = cur {
let (name, parent): (String, Option<i64>) = conn
.query_row(
"SELECT name, parent_id FROM file_nodes WHERE id = ?1",
[id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("file path");
parts.push(name);
cur = parent;
}
parts.reverse();
parts.join("/")
}
pub fn emails_in_mailbox(conn: &Connection, mailbox_id: i64) -> i64 {
conn.query_row(
"SELECT count(*) FROM emails
WHERE EXISTS (SELECT 1 FROM json_each(emails.mailbox_ids) j WHERE j.value = ?1)",
[mailbox_id],
|r| r.get(0),
)
.unwrap_or(0)
}
pub fn email_rows_for_blob(conn: &Connection, blob_id: i64) -> i64 {
conn.query_row(
"SELECT count(*) FROM emails WHERE blob_id = ?1",
[blob_id],
|r| r.get(0),
)
.unwrap_or(0)
}
pub fn blob_id_for_hash(conn: &Connection, hash: &[u8]) -> Option<i64> {
conn.query_row("SELECT id FROM blobs WHERE hash = ?1", [hash], |r| r.get(0))
.ok()
}
pub fn mailbox_id_by_name(conn: &Connection, name: &str) -> Option<i64> {
conn.query_row("SELECT id FROM mailboxes WHERE name = ?1", [name], |r| {
r.get(0)
})
.ok()
}
pub fn mailbox_id_by_path(conn: &Connection, path: &str, sep: char) -> Option<i64> {
let mut parent: Option<i64> = None;
for segment in path.split(sep) {
let id: i64 = match parent {
Some(p) => conn
.query_row(
"SELECT id FROM mailboxes WHERE parent_id = ?1 AND name = ?2",
rusqlite::params![p, segment],
|r| r.get(0),
)
.ok()?,
None => conn
.query_row(
"SELECT id FROM mailboxes WHERE parent_id IS NULL AND name = ?1",
[segment],
|r| r.get(0),
)
.ok()?,
};
parent = Some(id);
}
parent
}
pub fn file_node_id_by_path(conn: &Connection, segments: &[&str]) -> Option<i64> {
let mut parent: Option<i64> = None;
for segment in segments {
let id: i64 = match parent {
Some(p) => conn
.query_row(
"SELECT id FROM file_nodes WHERE parent_id = ?1 AND name = ?2",
rusqlite::params![p, segment],
|r| r.get(0),
)
.ok()?,
None => conn
.query_row(
"SELECT id FROM file_nodes WHERE parent_id IS NULL AND name = ?1",
[segment],
|r| r.get(0),
)
.ok()?,
};
parent = Some(id);
}
parent
}
pub fn collection_names(conn: &Connection, table: &str) -> HashSet<String> {
let mut stmt = conn
.prepare(&format!("SELECT name FROM {table}"))
.expect("prepare");
stmt.query_map([], |r| r.get::<_, String>(0))
.expect("query")
.filter_map(|r| r.ok())
.collect()
}
pub fn collection_id_by_name(conn: &Connection, table: &str, name: &str) -> Option<i64> {
conn.query_row(
&format!("SELECT id FROM {table} WHERE name = ?1"),
[name],
|r| r.get(0),
)
.ok()
}
pub fn calendar_event_count(conn: &Connection, calendar_id: i64) -> i64 {
conn.query_row(
"SELECT count(*) FROM calendar_events
WHERE EXISTS (SELECT 1 FROM json_each(calendar_events.calendar_ids) j WHERE j.value = ?1)",
[calendar_id],
|r| r.get(0),
)
.unwrap_or(0)
}
pub fn contact_card_count(conn: &Connection, address_book_id: i64) -> i64 {
conn.query_row(
"SELECT count(*) FROM contact_cards
WHERE EXISTS (SELECT 1 FROM json_each(contact_cards.address_book_ids) j WHERE j.value = ?1)",
[address_book_id],
|r| r.get(0),
)
.unwrap_or(0)
}
pub fn all_event_uids(conn: &Connection) -> HashSet<String> {
let mut stmt = conn
.prepare(
"SELECT json_extract(data, '$.uid') FROM calendar_events
WHERE json_extract(data, '$.uid') IS NOT NULL",
)
.expect("prepare");
stmt.query_map([], |r| r.get::<_, String>(0))
.expect("query")
.filter_map(|r| r.ok())
.collect()
}
pub fn all_contact_uids(conn: &Connection) -> HashSet<String> {
let mut stmt = conn
.prepare("SELECT uid FROM contact_cards")
.expect("prepare");
stmt.query_map([], |r| r.get::<_, String>(0))
.expect("query")
.filter_map(|r| r.ok())
.collect()
}
pub fn keywords_for_blob(conn: &Connection, blob_id: i64) -> Vec<String> {
let json: String = conn
.query_row(
"SELECT keywords FROM emails WHERE blob_id = ?1 LIMIT 1",
[blob_id],
|r| r.get(0),
)
.expect("keywords fetch");
serde_json::from_str::<Vec<String>>(&json).expect("keywords json")
}
pub fn event_data_by_uid(conn: &Connection, uid: &str) -> Option<serde_json::Value> {
let raw: String = conn
.query_row(
"SELECT data FROM calendar_events WHERE json_extract(data,'$.uid') = ?1",
[uid],
|r| r.get(0),
)
.ok()?;
serde_json::from_str(&raw).ok()
}
pub fn contact_data_by_uid(conn: &Connection, uid: &str) -> Option<serde_json::Value> {
let raw: String = conn
.query_row(
"SELECT data FROM contact_cards WHERE uid = ?1",
[uid],
|r| r.get(0),
)
.ok()?;
serde_json::from_str(&raw).ok()
}
pub fn unfold_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for line in text.lines() {
let l = line.trim_end_matches('\r');
if l.starts_with(' ') || l.starts_with('\t') {
out.push_str(&l[1..]);
} else {
if !out.is_empty() {
out.push('\n');
}
out.push_str(l);
}
}
out
}
pub fn extract_property(text: &str, name: &str) -> Option<String> {
component_top_level_properties(text, name)
.into_iter()
.next()
}
pub fn extract_property_all(text: &str, name: &str) -> Vec<String> {
component_top_level_properties(text, name)
}
fn component_top_level_properties(text: &str, name: &str) -> Vec<String> {
let value_colon = format!("{name}:");
let value_semi = format!("{name};");
let mut out = Vec::new();
let mut depth: i32 = 0;
let mut item_depth: Option<i32> = None;
for line in unfold_text(text).lines() {
let l = line.trim_end_matches('\r');
if l.starts_with("BEGIN:") {
depth += 1;
if matches!(l, "BEGIN:VEVENT" | "BEGIN:VTODO" | "BEGIN:VCARD") {
item_depth = Some(depth);
}
continue;
}
if l.starts_with("END:") {
if Some(depth) == item_depth {
item_depth = None;
}
depth -= 1;
continue;
}
if item_depth.is_some_and(|d| d == depth) {
if let Some(rest) = l.strip_prefix(&value_colon) {
out.push(rest.to_owned());
} else if l.starts_with(&value_semi)
&& let Some(colon) = l.find(':')
{
out.push(l[colon + 1..].to_owned());
}
}
}
out
}
pub fn has_line_prefix(text: &str, prefix: &str) -> bool {
let unfolded = unfold_text(text);
unfolded.lines().any(|l| l.starts_with(prefix))
}
pub fn json_contains_string(value: &serde_json::Value, needle: &str) -> bool {
let serialised = value.to_string();
serialised.contains(needle)
}
pub fn assert_message_round_trip(conn: &Connection, raw: &[u8], target_mailbox: &str, label: &str) {
let hash = blake3::hash(raw);
let blob_id = blob_id_for_hash(conn, hash.as_bytes())
.unwrap_or_else(|| panic!("{label}: blob for seeded message missing in archive"));
let stored = blob_bytes(conn, blob_id);
assert_eq!(
stored, raw,
"{label}: stored blob bytes differ from seeded raw"
);
let mailbox_id = mailbox_id_by_path(conn, target_mailbox, '/')
.unwrap_or_else(|| panic!("{label}: target mailbox {target_mailbox} missing in archive"));
let count: i64 = conn
.query_row(
"SELECT count(*) FROM emails
WHERE blob_id = ?1
AND EXISTS (SELECT 1 FROM json_each(mailbox_ids) j WHERE j.value = ?2)",
rusqlite::params![blob_id, mailbox_id],
|r| r.get(0),
)
.unwrap();
assert!(
count >= 1,
"{label}: blob {blob_id} not linked to mailbox {target_mailbox} (id {mailbox_id})"
);
}
pub fn assert_event_round_trip(conn: &Connection, source: &[u8], uid: &str, label: &str) {
let text = std::str::from_utf8(source).expect("seeded ical bytes utf-8");
let data = event_data_by_uid(conn, uid)
.unwrap_or_else(|| panic!("{label}: event uid {uid} missing in archive"));
let obj = data.as_object().expect("event data is object");
let stored_uid = obj.get("uid").and_then(|v| v.as_str()).unwrap_or("");
assert_eq!(
stored_uid, uid,
"{label}: event uid mismatch in stored data"
);
let summary = extract_property(text, "SUMMARY").unwrap_or_default();
if !summary.is_empty() {
let title = obj.get("title").and_then(|v| v.as_str()).unwrap_or("");
let expected = unescape_text(&summary);
assert!(
title == expected || title.contains(&expected),
"{label}: event {uid} title mismatch: stored={title:?} expected to contain={expected:?}"
);
}
if extract_property(text, "DESCRIPTION").is_some() {
let descr = obj
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
assert!(
!descr.is_empty(),
"{label}: event {uid} description was seeded but stored data has no description"
);
}
let start = obj.get("start").and_then(|v| v.as_str()).unwrap_or("");
assert!(
!start.is_empty(),
"{label}: event {uid} stored data.start is empty"
);
if has_line_prefix(text, "RRULE:") || has_line_prefix(text, "RRULE;") {
let has_rule = obj
.get("recurrenceRules")
.map(|v| !v.as_array().is_some_and(|a| a.is_empty()))
.unwrap_or(false)
|| obj
.get("recurrenceRule")
.map(|v| {
!v.as_object().is_some_and(|o| o.is_empty())
&& !v.as_array().is_some_and(|a| a.is_empty())
})
.unwrap_or(false);
assert!(
has_rule,
"{label}: event {uid} seeded RRULE but no recurrenceRule/recurrenceRules in stored data: {obj:?}"
);
}
if has_line_prefix(text, "BEGIN:VALARM") {
let alerts = obj
.get("alerts")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
assert!(
!alerts.is_empty(),
"{label}: event {uid} seeded VALARM but alerts empty"
);
}
if has_line_prefix(text, "ORGANIZER:")
|| has_line_prefix(text, "ORGANIZER;")
|| has_line_prefix(text, "ATTENDEE:")
|| has_line_prefix(text, "ATTENDEE;")
{
let parts = obj
.get("participants")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
assert!(
!parts.is_empty(),
"{label}: event {uid} seeded ORGANIZER/ATTENDEE but participants empty"
);
for addr in extract_property_all(text, "ATTENDEE") {
if let Some(idx) = addr.find("mailto:")
&& let Some(end) = addr[idx + 7..].find([',', ';'])
{
let email = &addr[idx + 7..idx + 7 + end];
assert!(
json_contains_string(&data, email),
"{label}: event {uid} attendee {email} not present in stored JSON"
);
} else if let Some(idx) = addr.find("mailto:") {
let email = &addr[idx + 7..];
assert!(
json_contains_string(&data, email),
"{label}: event {uid} attendee {email} not present in stored JSON"
);
}
}
}
if let Some(location) = extract_property(text, "LOCATION") {
let cleaned = unescape_text(&location);
let probe = cleaned.split(',').next().unwrap_or(&cleaned).trim();
if !probe.is_empty() {
assert!(
json_contains_string(&data, probe),
"{label}: event {uid} location fragment {probe:?} not present in stored JSON"
);
}
}
if let Some(cats) = extract_property(text, "CATEGORIES") {
let keywords = obj
.get("keywords")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
assert!(
!keywords.is_empty(),
"{label}: event {uid} seeded CATEGORIES={cats} but keywords empty"
);
for cat in cats.split(',') {
let cat = cat.trim();
if cat.is_empty() {
continue;
}
assert!(
keywords.contains_key(cat),
"{label}: event {uid} category {cat} not in keywords {keywords:?}"
);
}
}
}
pub fn assert_contact_round_trip(conn: &Connection, source: &[u8], uid: &str, label: &str) {
let text = std::str::from_utf8(source).expect("seeded vcard bytes utf-8");
let data = contact_data_by_uid(conn, uid)
.unwrap_or_else(|| panic!("{label}: contact uid {uid} missing in archive"));
if let Some(fn_value) = extract_property(text, "FN") {
let cleaned = unescape_text(&fn_value);
assert!(
json_contains_string(&data, &cleaned),
"{label}: contact {uid} FN {cleaned:?} not present in stored JSContact"
);
}
for email in extract_property_all(text, "EMAIL") {
assert!(
json_contains_string(&data, &email),
"{label}: contact {uid} EMAIL {email:?} not present in stored JSContact"
);
}
for tel in extract_property_all(text, "TEL") {
assert!(
json_contains_string(&data, &tel),
"{label}: contact {uid} TEL {tel:?} not present in stored JSContact"
);
}
if let Some(org) = extract_property(text, "ORG") {
let primary = org.split(';').next().unwrap_or(&org).trim();
if !primary.is_empty() {
assert!(
json_contains_string(&data, primary),
"{label}: contact {uid} ORG {primary:?} not present in stored JSContact"
);
}
}
if let Some(nick) = extract_property(text, "NICKNAME") {
assert!(
json_contains_string(&data, &nick),
"{label}: contact {uid} NICKNAME {nick:?} not present"
);
}
if let Some(bday) = extract_property(text, "BDAY") {
let iso = format_vcard_date_as_iso(&bday);
let present_raw = json_contains_string(&data, &bday);
let present_iso = iso
.as_deref()
.map(|s| json_contains_string(&data, s))
.unwrap_or(false);
let obj = data.as_object().expect("contact data is object");
let has_anniversaries = obj
.get("anniversaries")
.map(|v| !v.as_object().is_some_and(|o| o.is_empty()))
.unwrap_or(false);
assert!(
present_raw || present_iso || has_anniversaries,
"{label}: contact {uid} BDAY {bday:?} not present in stored JSContact (no anniversaries either)"
);
}
}
fn format_vcard_date_as_iso(bday: &str) -> Option<String> {
let trimmed = bday.trim();
if trimmed.len() == 8 && trimmed.bytes().all(|b| b.is_ascii_digit()) {
Some(format!(
"{}-{}-{}",
&trimmed[..4],
&trimmed[4..6],
&trimmed[6..8]
))
} else {
None
}
}
fn unescape_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\'
&& let Some(&next) = chars.peek()
{
match next {
',' | ';' | '\\' => {
out.push(next);
chars.next();
continue;
}
'n' | 'N' => {
out.push('\n');
chars.next();
continue;
}
_ => {}
}
}
out.push(c);
}
out
}
pub fn cleanup(archive: &Path) {
let _ = std::fs::remove_file(archive);
let mut wal = archive.as_os_str().to_owned();
wal.push("-wal");
let _ = std::fs::remove_file(Path::new(&wal));
let mut shm = archive.as_os_str().to_owned();
shm.push("-shm");
let _ = std::fs::remove_file(Path::new(&shm));
}
+321
View File
@@ -0,0 +1,321 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::{SyncBuilder, SyncRunner};
use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt};
use super::dav_client::DavSeed;
use super::error::{ContainerError, ContainerResult};
use super::layouts::{self, FileSpec};
use super::{Account, Endpoint};
const HTTP_PORT: u16 = 80;
const DOCKERFILE: &str = r#"FROM debian:bookworm-20260518-slim
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
apache2 apache2-utils ca-certificates && \
a2enmod dav dav_fs auth_basic authn_file && \
rm -rf /var/lib/apt/lists/*
COPY apache.conf /etc/apache2/sites-available/000-default.conf
COPY htpasswd /etc/apache2/htpasswd
RUN mkdir -p /var/dav && \
chown -R www-data:www-data /var/dav
EXPOSE 80
CMD ["apachectl", "-D", "FOREGROUND"]
"#;
const APACHE_CONF: &str = r#"DavLockDB /tmp/DavLock
<VirtualHost *:80>
DocumentRoot /var/dav
ErrorLog /dev/stderr
CustomLog /dev/stdout combined
<Directory /var/dav>
Dav On
AuthType Basic
AuthName "WebDAV"
AuthUserFile /etc/apache2/htpasswd
Require valid-user
Options Indexes FollowSymLinks
</Directory>
</VirtualHost>
"#;
fn htpasswd_for_test() -> String {
let mut out = String::new();
for u in layouts::accounts() {
out.push_str(u);
out.push(':');
out.push_str("{SHA}");
out.push_str(&sha1_b64(layouts::PASSWORD.as_bytes()));
out.push('\n');
}
out
}
fn sha1_b64(bytes: &[u8]) -> String {
let digest = simple_sha1(bytes);
B64.encode(digest)
}
fn simple_sha1(bytes: &[u8]) -> [u8; 20] {
let mut h0: u32 = 0x6745_2301;
let mut h1: u32 = 0xEFCD_AB89;
let mut h2: u32 = 0x98BA_DCFE;
let mut h3: u32 = 0x1032_5476;
let mut h4: u32 = 0xC3D2_E1F0;
let bit_len = (bytes.len() as u64).wrapping_mul(8);
let mut msg = bytes.to_vec();
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&bit_len.to_be_bytes());
for chunk in msg.chunks(64) {
let mut w = [0u32; 80];
for (i, word) in w.iter_mut().take(16).enumerate() {
*word = u32::from_be_bytes(chunk[i * 4..i * 4 + 4].try_into().unwrap());
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let (mut a, mut b, mut c, mut d, mut e) = (h0, h1, h2, h3, h4);
for (i, &wi) in w.iter().enumerate() {
let (f, k): (u32, u32) = match i {
0..=19 => ((b & c) | ((!b) & d), 0x5A82_7999),
20..=39 => (b ^ c ^ d, 0x6ED9_EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC),
_ => (b ^ c ^ d, 0xCA62_C1D6),
};
let temp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(wi);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = temp;
}
h0 = h0.wrapping_add(a);
h1 = h1.wrapping_add(b);
h2 = h2.wrapping_add(c);
h3 = h3.wrapping_add(d);
h4 = h4.wrapping_add(e);
}
let mut out = [0u8; 20];
out[..4].copy_from_slice(&h0.to_be_bytes());
out[4..8].copy_from_slice(&h1.to_be_bytes());
out[8..12].copy_from_slice(&h2.to_be_bytes());
out[12..16].copy_from_slice(&h3.to_be_bytes());
out[16..20].copy_from_slice(&h4.to_be_bytes());
out
}
pub struct WebDav {
container: Container<GenericImage>,
pub http: Endpoint,
pub accounts: Vec<Account>,
}
impl WebDav {
pub fn start() -> ContainerResult<Self> {
let image: GenericImage = GenericBuildableImage::new("vandelay-webdav", "test")
.with_dockerfile_string(DOCKERFILE.to_owned())
.with_data(APACHE_CONF.as_bytes().to_vec(), "apache.conf")
.with_data(htpasswd_for_test().into_bytes(), "htpasswd")
.build_image()
.map_err(|e| ContainerError::Seed(format!("webdav build: {e}")))?;
let request = image
.with_exposed_port(HTTP_PORT.tcp())
.with_wait_for(WaitFor::message_on_stderr("AH00558"))
.with_startup_timeout(Duration::from_secs(120));
let container = request.start()?;
let host = container.get_host()?.to_string();
let http = Endpoint::new(host, container.get_host_port_ipv4(HTTP_PORT.tcp())?);
let accounts: Vec<Account> = layouts::accounts()
.iter()
.map(|name| Account {
username: (*name).to_owned(),
password: layouts::PASSWORD.to_owned(),
layout: layouts::layout_for(name),
})
.collect();
Ok(Self {
container,
http,
accounts,
})
}
pub fn base_url(&self) -> String {
self.http.http_base()
}
pub fn account_url(&self, account: &Account) -> String {
format!("{}/{}/", self.base_url(), account.username)
}
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
let mut out = Vec::new();
for acct in &self.accounts {
let m = self.seed_account(acct)?;
out.push(m);
}
Ok(out)
}
pub fn seed_account(&self, account: &Account) -> ContainerResult<AccountSeed> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
let root = format!("/{}/", account.username);
client.mkcol(&root, None)?;
let mut seed = AccountSeed::new(account.username.clone());
let specs = account.layout.files;
for spec in specs {
let path = build_path(&root, specs, spec.key)
.ok_or_else(|| ContainerError::Seed(format!("missing file key: {}", spec.key)))?;
if spec.directory {
client.mkcol(&path, None)?;
seed.directories += 1;
} else {
let payload = synth_payload(spec.name);
client.put(&path, "application/octet-stream", payload.as_bytes())?;
seed.files.push(SeededFile {
key: spec.key.to_owned(),
name: spec.name.to_owned(),
href: path,
payload: payload.into_bytes(),
});
}
}
Ok(seed)
}
pub fn delete_resource(&self, account: &Account, href: &str) -> ContainerResult<()> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
client.delete(href)?;
Ok(())
}
pub fn add_file(
&self,
account: &Account,
parent_segments: &[&str],
name: &str,
payload: &[u8],
) -> ContainerResult<String> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
let mut href = format!("/{}/", account.username);
for seg in parent_segments {
href.push_str(seg);
href.push('/');
}
href.push_str(name);
client.put(&href, "application/octet-stream", payload)?;
Ok(href)
}
pub fn verify_seed(&self) -> ContainerResult<()> {
for acct in &self.accounts {
let client = DavSeed::new(self.base_url(), &acct.username, &acct.password);
let body = client.propfind(&format!("/{}/", acct.username), 1)?;
if !body.contains("multistatus") {
return Err(ContainerError::Protocol(format!(
"webdav propfind for {} returned no multistatus",
acct.username
)));
}
}
Ok(())
}
pub fn stop(self) -> ContainerResult<()> {
self.container.stop()?;
Ok(())
}
}
fn build_path(root: &str, specs: &[FileSpec], key: &str) -> Option<String> {
let mut chain: Vec<&str> = Vec::new();
let mut cur = key;
let mut leaf_dir = false;
loop {
let spec = specs.iter().find(|s| s.key == cur)?;
chain.push(spec.name);
if chain.len() == 1 {
leaf_dir = spec.directory;
}
match spec.parent {
Some(p) => cur = p,
None => break,
}
}
chain.reverse();
let mut path = String::from(root);
for (i, name) in chain.iter().enumerate() {
path.push_str(name);
let is_last = i + 1 == chain.len();
if !is_last || leaf_dir {
path.push('/');
}
}
Some(path)
}
pub fn synth_payload(name: &str) -> String {
let mut out = String::new();
out.push_str(name);
out.push('\n');
for i in 0..16 {
out.push_str(&format!("line {i} for {name}\n"));
}
out
}
#[derive(Debug, Clone)]
pub struct SeededFile {
pub key: String,
pub name: String,
pub href: String,
pub payload: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct AccountSeed {
pub username: String,
pub directories: usize,
pub files: Vec<SeededFile>,
}
impl AccountSeed {
fn new(username: String) -> Self {
Self {
username,
directories: 0,
files: Vec::new(),
}
}
}