Initial commit
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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('"', "\\\"")
|
||||
}
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user