Files
jcoffey-dev 5931b06392 Rename to inbuxa-migrate
The crate, binary, library path and every name the tool writes or reads
become inbuxa-migrate: the CLI name and about text, the HTTP user agent,
the credential variables (INBUXA_MIGRATE_PASSWORD, _TOKEN,
_EWS_CLIENT_SECRET and _GRAPH_TOKEN, with no fallback to the old names),
thread names, synthesized UIDs and calendar addresses
(urn:x-inbuxa-migrate:attendee:), and the Maildir file suffix used by the
export script. This is a new tool, so archives written by Vandelay are not
read back.

The version moves to the date scheme the release tags use (2026.9.30), and
the package metadata points at the inbuxa-migrate repository. The cargo-dist
and MSI metadata goes with the release tooling it served.

Modified files carry an added copyright line; the original notices stay.
2026-09-30 10:09:10 -07:00

466 lines
16 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 John Coffey <[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("inbuxa-migrate-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))
.with_labels([(super::OWNER_LABEL, "1")]);
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}@inbuxa-migrate.test");
let display = format!("Inbuxa {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()
}
}