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

323 lines
9.5 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::time::Duration;
use encodify::base64::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("inbuxa-migrate-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))
.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 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(),
}
}
}