Initial commit
This commit is contained in:
@@ -0,0 +1,638 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use crate::db;
|
||||
use crate::db::sources::SourceKey;
|
||||
use crate::error::Error;
|
||||
use crate::imap::retry::BackoffState;
|
||||
use crate::imap::transport::Connector;
|
||||
use crate::logging::{LEVEL_DEFAULT, LEVEL_PROGRESS, Logger};
|
||||
use crate::managesieve::error::SieveError;
|
||||
use crate::managesieve::response::parse_listscripts;
|
||||
use crate::managesieve::retry::{Disposition, classify, is_negotiation_failure};
|
||||
use crate::managesieve::{ConnectMode, SieveClient, parse_getscript};
|
||||
use crate::sync::keys::blake3_bytes;
|
||||
use crate::sync::{CommonConfig, Summary, TypeCounts};
|
||||
|
||||
use super::reconcile::{
|
||||
Action, PresentOutcome, apply_active_assignment, apply_content_update, apply_delete, apply_new,
|
||||
load_local_state, plan,
|
||||
};
|
||||
|
||||
pub struct ManageSieveImportConfig {
|
||||
pub url: String,
|
||||
pub auth: ManageSieveAuth,
|
||||
pub allow_cleartext: bool,
|
||||
pub allow_source_change: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ManageSieveAuth {
|
||||
Basic { user: String, password: String },
|
||||
Bearer { user: String, token: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Endpoint {
|
||||
host: String,
|
||||
port: u16,
|
||||
implicit_tls: bool,
|
||||
}
|
||||
|
||||
struct ControlCtx {
|
||||
connector: Arc<Connector>,
|
||||
host: String,
|
||||
port: u16,
|
||||
mode: ConnectMode,
|
||||
allow_cleartext: bool,
|
||||
auth: ManageSieveAuth,
|
||||
backoff: BackoffState,
|
||||
max_retries: u32,
|
||||
logger: Logger,
|
||||
}
|
||||
|
||||
fn reconnect(client: &mut SieveClient, ctx: &ControlCtx) -> Result<(), SieveError> {
|
||||
let new = SieveClient::connect(
|
||||
&ctx.connector,
|
||||
&ctx.host,
|
||||
ctx.port,
|
||||
ctx.mode,
|
||||
ctx.allow_cleartext,
|
||||
)?;
|
||||
*client = new;
|
||||
match do_authenticate(client, &ctx.auth) {
|
||||
Ok(()) => {}
|
||||
Err(SieveAuthError::Wire(e)) => return Err(e),
|
||||
Err(SieveAuthError::TerminallyRefused(text)) => {
|
||||
return Err(SieveError::Protocol(format!(
|
||||
"auth refused on reconnect: {text}"
|
||||
)));
|
||||
}
|
||||
Err(SieveAuthError::NoUsableMechanism(text)) => {
|
||||
return Err(SieveError::Unsupported(text));
|
||||
}
|
||||
}
|
||||
if !client.had_fresh_post_auth_caps()
|
||||
&& let Err(e) = client.refresh_capabilities()
|
||||
{
|
||||
ctx.logger.warn(&format!(
|
||||
"post-reconnect CAPABILITY refresh failed: {e}; continuing"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run(common: CommonConfig, config: ManageSieveImportConfig) -> Result<Summary, Error> {
|
||||
let logger = common.logger;
|
||||
if common.threads > 1 {
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_PROGRESS,
|
||||
"managesieve importer is single-threaded; --threads value will be ignored",
|
||||
);
|
||||
}
|
||||
|
||||
let mut conn = db::init::open(&common.archive)?;
|
||||
|
||||
let endpoint = parse_endpoint(&config.url)?;
|
||||
let session_url = format!(
|
||||
"{}://{}:{}",
|
||||
if endpoint.implicit_tls {
|
||||
"sieves"
|
||||
} else {
|
||||
"sieve"
|
||||
},
|
||||
endpoint.host,
|
||||
endpoint.port
|
||||
);
|
||||
|
||||
let connector = Arc::new(
|
||||
Connector::new(common.allow_invalid_certs).map_err(|e| Error::Connection(e.to_string()))?,
|
||||
);
|
||||
let mode = if endpoint.implicit_tls {
|
||||
ConnectMode::ImplicitTls
|
||||
} else {
|
||||
ConnectMode::StartTls
|
||||
};
|
||||
|
||||
let mut client = SieveClient::connect(
|
||||
&connector,
|
||||
&endpoint.host,
|
||||
endpoint.port,
|
||||
mode,
|
||||
config.allow_cleartext,
|
||||
)
|
||||
.map_err(|e| Error::Connection(e.to_string()))?;
|
||||
|
||||
let account_id = authenticate(&mut client, &config.auth)?;
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_PROGRESS,
|
||||
&format!("authenticated as {account_id} on {session_url}"),
|
||||
);
|
||||
if !client.had_fresh_post_auth_caps()
|
||||
&& let Err(e) = client.refresh_capabilities()
|
||||
{
|
||||
logger.warn(&format!(
|
||||
"post-auth CAPABILITY refresh failed: {e}; continuing"
|
||||
));
|
||||
}
|
||||
|
||||
let source_key = SourceKey {
|
||||
kind: "managesieve".to_owned(),
|
||||
session_url: session_url.clone(),
|
||||
account_id: account_id.clone(),
|
||||
};
|
||||
if let Some((existing_url, existing_account)) =
|
||||
db::sources::conflicting_source(&conn, "managesieve", &session_url, &account_id)?
|
||||
&& !config.allow_source_change
|
||||
{
|
||||
return Err(Error::SourceChange(format!(
|
||||
"archive already records managesieve source {existing_url} / {existing_account}; \
|
||||
re-run with --allow-source-change or use a fresh archive"
|
||||
)));
|
||||
}
|
||||
let source_id = if common.dry_run {
|
||||
db::sources::find_source(&conn, &source_key)?.unwrap_or(-1)
|
||||
} else {
|
||||
db::sources::upsert_source(&conn, &source_key, Some(&account_id), &account_id)?
|
||||
};
|
||||
|
||||
let backoff = BackoffState::new();
|
||||
let control_ctx = ControlCtx {
|
||||
connector: connector.clone(),
|
||||
host: endpoint.host.clone(),
|
||||
port: endpoint.port,
|
||||
mode,
|
||||
allow_cleartext: config.allow_cleartext,
|
||||
auth: config.auth.clone(),
|
||||
backoff: backoff.clone(),
|
||||
max_retries: common.max_retries,
|
||||
logger,
|
||||
};
|
||||
let server_listing = listscripts_with_retry(&mut client, &control_ctx)?;
|
||||
|
||||
let mut server_with_bytes: Vec<(String, bool, [u8; 32], Vec<u8>)> =
|
||||
Vec::with_capacity(server_listing.len());
|
||||
let mut counts = TypeCounts::default();
|
||||
for (name, active) in &server_listing {
|
||||
match getscript_with_retry(&mut client, &control_ctx, name) {
|
||||
Ok(bytes) => {
|
||||
let hash = blake3_bytes(&bytes);
|
||||
server_with_bytes.push((name.clone(), *active, hash, bytes));
|
||||
}
|
||||
Err(e) => match classify(&e) {
|
||||
Disposition::Permanent
|
||||
| Disposition::PerScriptRecoverable
|
||||
| Disposition::Transient => {
|
||||
logger.warn(&format!("GETSCRIPT {name:?} failed, skipping: {e}"));
|
||||
counts.failed += 1;
|
||||
}
|
||||
Disposition::Referral => {
|
||||
return Err(Error::Connection(format!(
|
||||
"ManageSieve referral on GETSCRIPT {name:?}: {e}; vandelay does not follow referrals"
|
||||
)));
|
||||
}
|
||||
Disposition::TransportDrop => {
|
||||
return Err(Error::Connection(format!(
|
||||
"GETSCRIPT {name:?} transport drop exhausted retries: {e}"
|
||||
)));
|
||||
}
|
||||
Disposition::FeatureNegotiation => {
|
||||
return Err(Error::Connection(format!("GETSCRIPT {name:?}: {e}")));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if common.dry_run {
|
||||
let summary =
|
||||
build_dry_run_summary(&conn, source_id, &server_with_bytes, counts, &backoff)?;
|
||||
let _ = client.logout();
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
let local_map = db::managesieve_ids::all_names(&conn, source_id)?;
|
||||
let (local_hashes, local_active) = load_local_state(&conn, source_id)?;
|
||||
let server_keyed: Vec<(String, bool, [u8; 32])> = server_with_bytes
|
||||
.iter()
|
||||
.map(|(n, a, h, _)| (n.clone(), *a, *h))
|
||||
.collect();
|
||||
let plan = plan(&server_keyed, &local_map, &local_hashes, &local_active);
|
||||
|
||||
let bytes_by_name: std::collections::HashMap<String, &Vec<u8>> = server_with_bytes
|
||||
.iter()
|
||||
.map(|(n, _, _, b)| (n.clone(), b))
|
||||
.collect();
|
||||
|
||||
let mut active_local: Option<i64> = None;
|
||||
|
||||
for action in &plan.actions {
|
||||
match action {
|
||||
Action::New { name, active } => {
|
||||
let bytes = bytes_by_name.get(name).cloned().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
match apply_new(&tx, source_id, name, bytes) {
|
||||
Ok(local_id) => {
|
||||
tx.commit()?;
|
||||
if *active {
|
||||
active_local = Some(local_id);
|
||||
}
|
||||
counts.created += 1;
|
||||
counts.fetched += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
logger.warn(&format!("INSERT sieve_scripts {name:?} failed: {e}"));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::Present {
|
||||
name,
|
||||
local_id,
|
||||
outcome,
|
||||
active,
|
||||
} => {
|
||||
match outcome {
|
||||
PresentOutcome::Unchanged => {
|
||||
counts.skipped += 1;
|
||||
}
|
||||
PresentOutcome::ActiveOnly => {
|
||||
counts.skipped += 1;
|
||||
}
|
||||
PresentOutcome::ContentUpdated => {
|
||||
let bytes = bytes_by_name.get(name).cloned().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
match apply_content_update(&tx, *local_id, bytes) {
|
||||
Ok(()) => {
|
||||
tx.commit()?;
|
||||
counts.fetched += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
logger.warn(&format!("UPDATE sieve_scripts {name:?} failed: {e}"));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if *active {
|
||||
active_local = Some(*local_id);
|
||||
}
|
||||
}
|
||||
Action::Vanished { name, local_id } => {
|
||||
let tx = conn.transaction()?;
|
||||
match apply_delete(&tx, source_id, name, *local_id) {
|
||||
Ok(()) => {
|
||||
tx.commit()?;
|
||||
counts.deleted += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
logger.warn(&format!("DELETE sieve_scripts {name:?} failed: {e}"));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply_active_assignment(&mut conn, active_local)?;
|
||||
|
||||
let _ = client.logout();
|
||||
|
||||
if counts.failed == 0 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
db::blobs::gc_orphan_blobs(&tx)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!(
|
||||
"managesieve: created={} fetched={} deleted={} skipped={} failed={}",
|
||||
counts.created, counts.fetched, counts.deleted, counts.skipped, counts.failed
|
||||
),
|
||||
);
|
||||
|
||||
Ok(Summary {
|
||||
per_type: vec![("sievescript", counts)],
|
||||
retries_observed: backoff.total_retries(),
|
||||
retry_after_sleeps: backoff.transient_retries() as u64,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_dry_run_summary(
|
||||
conn: &rusqlite::Connection,
|
||||
source_id: i64,
|
||||
server: &[(String, bool, [u8; 32], Vec<u8>)],
|
||||
mut counts: TypeCounts,
|
||||
backoff: &BackoffState,
|
||||
) -> Result<Summary, Error> {
|
||||
let server_keyed: Vec<(String, bool, [u8; 32])> = server
|
||||
.iter()
|
||||
.map(|(n, a, h, _)| (n.clone(), *a, *h))
|
||||
.collect();
|
||||
let (local_map, local_hashes, local_active) = if source_id < 0 {
|
||||
(
|
||||
std::collections::HashMap::new(),
|
||||
std::collections::HashMap::new(),
|
||||
std::collections::HashMap::new(),
|
||||
)
|
||||
} else {
|
||||
let local_map = db::managesieve_ids::all_names(conn, source_id)?;
|
||||
let (h, a) = load_local_state(conn, source_id)?;
|
||||
(local_map, h, a)
|
||||
};
|
||||
let plan = plan(&server_keyed, &local_map, &local_hashes, &local_active);
|
||||
for action in &plan.actions {
|
||||
match action {
|
||||
Action::New { .. } => {
|
||||
counts.created += 1;
|
||||
counts.fetched += 1;
|
||||
}
|
||||
Action::Present {
|
||||
outcome: PresentOutcome::ContentUpdated,
|
||||
..
|
||||
} => counts.fetched += 1,
|
||||
Action::Present { .. } => counts.skipped += 1,
|
||||
Action::Vanished { .. } => counts.deleted += 1,
|
||||
}
|
||||
}
|
||||
Ok(Summary {
|
||||
per_type: vec![("sievescript", counts)],
|
||||
retries_observed: backoff.total_retries(),
|
||||
retry_after_sleeps: backoff.transient_retries() as u64,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_endpoint(url: &str) -> Result<Endpoint, Error> {
|
||||
let parsed =
|
||||
Url::parse(url).map_err(|e| Error::Usage(format!("invalid --url {url:?}: {e}")))?;
|
||||
let scheme = parsed.scheme();
|
||||
let implicit_tls = match scheme {
|
||||
"sieves" => true,
|
||||
"sieve" => false,
|
||||
other => {
|
||||
return Err(Error::Usage(format!(
|
||||
"--url scheme must be sieve or sieves, got {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| Error::Usage(format!("--url missing host: {url}")))?
|
||||
.to_owned();
|
||||
let port = parsed.port().unwrap_or(4190);
|
||||
Ok(Endpoint {
|
||||
host,
|
||||
port,
|
||||
implicit_tls,
|
||||
})
|
||||
}
|
||||
|
||||
fn authenticate(client: &mut SieveClient, auth: &ManageSieveAuth) -> Result<String, Error> {
|
||||
do_authenticate(client, auth).map_err(|e| match e {
|
||||
SieveAuthError::TerminallyRefused(text) => {
|
||||
Error::Connection(format!("auth failed: {text}"))
|
||||
}
|
||||
SieveAuthError::NoUsableMechanism(text) => Error::Connection(text),
|
||||
SieveAuthError::Wire(e) => Error::Connection(e.to_string()),
|
||||
})?;
|
||||
Ok(account_id_for(auth))
|
||||
}
|
||||
|
||||
fn account_id_for(auth: &ManageSieveAuth) -> String {
|
||||
match auth {
|
||||
ManageSieveAuth::Basic { user, .. } => user.clone(),
|
||||
ManageSieveAuth::Bearer { user, .. } => user.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SieveAuthError {
|
||||
|
||||
TerminallyRefused(String),
|
||||
|
||||
NoUsableMechanism(String),
|
||||
|
||||
Wire(SieveError),
|
||||
}
|
||||
|
||||
fn do_authenticate(client: &mut SieveClient, auth: &ManageSieveAuth) -> Result<(), SieveAuthError> {
|
||||
match auth {
|
||||
ManageSieveAuth::Basic { user, password } => {
|
||||
let caps = client.capabilities().clone();
|
||||
let mut last_plain_error: Option<String> = None;
|
||||
if caps.has_sasl("PLAIN") {
|
||||
match client.authenticate_plain(user, password) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if is_negotiation_failure(&e) => {
|
||||
last_plain_error = Some(e.to_string());
|
||||
}
|
||||
Err(e) => return Err(SieveAuthError::Wire(e)),
|
||||
}
|
||||
}
|
||||
if caps.has_sasl("LOGIN") {
|
||||
match client.authenticate_login(user, password) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if is_negotiation_failure(&e) => {
|
||||
let combined = match last_plain_error {
|
||||
Some(p) => format!("LOGIN: {e}; PLAIN: {p}"),
|
||||
None => format!("LOGIN: {e}"),
|
||||
};
|
||||
return Err(SieveAuthError::TerminallyRefused(combined));
|
||||
}
|
||||
Err(e) => return Err(SieveAuthError::Wire(e)),
|
||||
}
|
||||
}
|
||||
if let Some(p) = last_plain_error {
|
||||
return Err(SieveAuthError::TerminallyRefused(format!("PLAIN: {p}")));
|
||||
}
|
||||
Err(SieveAuthError::NoUsableMechanism(format!(
|
||||
"server SASL list lacks PLAIN and LOGIN (advertised: {:?})",
|
||||
caps.sasl
|
||||
)))
|
||||
}
|
||||
ManageSieveAuth::Bearer { user, token } => {
|
||||
if !client.capabilities().has_sasl("OAUTHBEARER") {
|
||||
return Err(SieveAuthError::NoUsableMechanism(format!(
|
||||
"server does not advertise OAUTHBEARER (advertised: {:?})",
|
||||
client.capabilities().sasl
|
||||
)));
|
||||
}
|
||||
match client.authenticate_oauthbearer(user, token) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if is_negotiation_failure(&e) => Err(SieveAuthError::TerminallyRefused(
|
||||
format!("OAUTHBEARER: {e}"),
|
||||
)),
|
||||
Err(e) => Err(SieveAuthError::Wire(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn listscripts_with_retry(
|
||||
client: &mut SieveClient,
|
||||
ctx: &ControlCtx,
|
||||
) -> Result<Vec<(String, bool)>, Error> {
|
||||
let mut transient_attempts: u32 = 0;
|
||||
let mut transport_attempts: u32 = 0;
|
||||
loop {
|
||||
match client.listscripts() {
|
||||
Ok(block) => {
|
||||
ctx.backoff.reset();
|
||||
let listed = parse_listscripts(&block.data)
|
||||
.map_err(|e| Error::Connection(format!("LISTSCRIPTS parse: {e}")))?;
|
||||
return Ok(listed.into_iter().map(|s| (s.name, s.active)).collect());
|
||||
}
|
||||
Err(e) => match classify(&e) {
|
||||
Disposition::Transient => {
|
||||
if transient_attempts >= ctx.max_retries {
|
||||
return Err(Error::Connection(format!(
|
||||
"LISTSCRIPTS retries exhausted: {e}"
|
||||
)));
|
||||
}
|
||||
transient_attempts += 1;
|
||||
log_at(
|
||||
ctx.logger,
|
||||
LEVEL_PROGRESS,
|
||||
&format!("LISTSCRIPTS transient: {e}; retrying"),
|
||||
);
|
||||
std::thread::sleep(ctx.backoff.next_shared_delay());
|
||||
}
|
||||
Disposition::TransportDrop => {
|
||||
if transport_attempts >= ctx.max_retries {
|
||||
return Err(Error::Connection(format!(
|
||||
"LISTSCRIPTS transport drop, retries exhausted: {e}"
|
||||
)));
|
||||
}
|
||||
transport_attempts += 1;
|
||||
std::thread::sleep(ctx.backoff.transport_delay(transport_attempts));
|
||||
match reconnect(client, ctx) {
|
||||
Ok(()) => {
|
||||
log_at(
|
||||
ctx.logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!("managesieve reconnected after transport drop ({e})"),
|
||||
);
|
||||
}
|
||||
Err(e2) => {
|
||||
return Err(Error::Connection(format!(
|
||||
"LISTSCRIPTS reconnect failed: {e2}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Disposition::Referral => {
|
||||
return Err(Error::Connection(format!(
|
||||
"LISTSCRIPTS referral: {e}; vandelay does not follow referrals"
|
||||
)));
|
||||
}
|
||||
_ => return Err(Error::Connection(format!("LISTSCRIPTS failed: {e}"))),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn getscript_with_retry(
|
||||
client: &mut SieveClient,
|
||||
ctx: &ControlCtx,
|
||||
name: &str,
|
||||
) -> Result<Vec<u8>, SieveError> {
|
||||
let mut transient_attempts: u32 = 0;
|
||||
let mut transport_attempts: u32 = 0;
|
||||
loop {
|
||||
match client.getscript(name) {
|
||||
Ok(block) => {
|
||||
ctx.backoff.reset();
|
||||
return parse_getscript(&block.data);
|
||||
}
|
||||
Err(e) => match classify(&e) {
|
||||
Disposition::Transient => {
|
||||
if transient_attempts >= ctx.max_retries {
|
||||
return Err(e);
|
||||
}
|
||||
transient_attempts += 1;
|
||||
log_at(
|
||||
ctx.logger,
|
||||
LEVEL_PROGRESS,
|
||||
&format!("GETSCRIPT {name:?} transient: {e}; retrying"),
|
||||
);
|
||||
std::thread::sleep(ctx.backoff.next_shared_delay());
|
||||
}
|
||||
Disposition::TransportDrop => {
|
||||
if transport_attempts >= ctx.max_retries {
|
||||
return Err(e);
|
||||
}
|
||||
transport_attempts += 1;
|
||||
std::thread::sleep(ctx.backoff.transport_delay(transport_attempts));
|
||||
reconnect(client, ctx)?;
|
||||
log_at(
|
||||
ctx.logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!(
|
||||
"managesieve reconnected mid-GETSCRIPT {name:?} after transport drop"
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => return Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_at(logger: Logger, level: u8, msg: &str) {
|
||||
if logger.enabled(level) {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_endpoint_sieves_defaults_to_4190() {
|
||||
let e = parse_endpoint("sieves://mail.example.com").unwrap();
|
||||
assert_eq!(e.host, "mail.example.com");
|
||||
assert_eq!(e.port, 4190);
|
||||
assert!(e.implicit_tls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_endpoint_sieve_defaults_to_4190() {
|
||||
let e = parse_endpoint("sieve://mail.example.com").unwrap();
|
||||
assert_eq!(e.host, "mail.example.com");
|
||||
assert_eq!(e.port, 4190);
|
||||
assert!(!e.implicit_tls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_endpoint_explicit_port() {
|
||||
let e = parse_endpoint("sieve://mail.example.com:14190").unwrap();
|
||||
assert_eq!(e.port, 14190);
|
||||
assert!(!e.implicit_tls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_endpoint_rejects_other_schemes() {
|
||||
let err = parse_endpoint("imap://example.com").unwrap_err();
|
||||
assert!(matches!(err, Error::Usage(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_endpoint_rejects_missing_host() {
|
||||
let err = parse_endpoint("sieve:///path").unwrap_err();
|
||||
assert!(matches!(err, Error::Usage(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::db;
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PresentOutcome {
|
||||
Unchanged,
|
||||
ActiveOnly,
|
||||
ContentUpdated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
New {
|
||||
name: String,
|
||||
active: bool,
|
||||
},
|
||||
Present {
|
||||
name: String,
|
||||
local_id: i64,
|
||||
outcome: PresentOutcome,
|
||||
active: bool,
|
||||
},
|
||||
Vanished {
|
||||
name: String,
|
||||
local_id: i64,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Plan {
|
||||
pub actions: Vec<Action>,
|
||||
}
|
||||
|
||||
pub fn plan(
|
||||
server: &[(String, bool, [u8; 32])],
|
||||
local: &HashMap<String, i64>,
|
||||
local_hashes: &HashMap<i64, [u8; 32]>,
|
||||
local_active: &HashMap<i64, bool>,
|
||||
) -> Plan {
|
||||
let mut actions = Vec::new();
|
||||
let mut server_names: HashSet<&str> = HashSet::new();
|
||||
for (name, active, hash) in server {
|
||||
server_names.insert(name.as_str());
|
||||
match local.get(name) {
|
||||
None => actions.push(Action::New {
|
||||
name: name.clone(),
|
||||
active: *active,
|
||||
}),
|
||||
Some(local_id) => {
|
||||
let cur_hash = local_hashes.get(local_id);
|
||||
let cur_active = local_active.get(local_id).copied().unwrap_or(false);
|
||||
let outcome = if cur_hash != Some(hash) {
|
||||
PresentOutcome::ContentUpdated
|
||||
} else if cur_active != *active {
|
||||
PresentOutcome::ActiveOnly
|
||||
} else {
|
||||
PresentOutcome::Unchanged
|
||||
};
|
||||
actions.push(Action::Present {
|
||||
name: name.clone(),
|
||||
local_id: *local_id,
|
||||
outcome,
|
||||
active: *active,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (name, local_id) in local {
|
||||
if !server_names.contains(name.as_str()) {
|
||||
actions.push(Action::Vanished {
|
||||
name: name.clone(),
|
||||
local_id: *local_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
Plan { actions }
|
||||
}
|
||||
|
||||
pub type LocalState = (HashMap<i64, [u8; 32]>, HashMap<i64, bool>);
|
||||
|
||||
pub fn load_local_state(conn: &Connection, source_id: i64) -> Result<LocalState, Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT s.id, b.hash, s.is_active
|
||||
FROM sieve_scripts s JOIN blobs b ON b.id = s.blob_id
|
||||
JOIN sync_id_managesieve m ON m.local_id = s.id
|
||||
WHERE m.source_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![source_id], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
let hash: Vec<u8> = row.get(1)?;
|
||||
let active: i64 = row.get(2)?;
|
||||
Ok((id, hash, active != 0))
|
||||
})?;
|
||||
let mut hashes = HashMap::new();
|
||||
let mut active = HashMap::new();
|
||||
for r in rows {
|
||||
let (id, hash, a) = r?;
|
||||
if hash.len() == 32 {
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&hash);
|
||||
hashes.insert(id, arr);
|
||||
}
|
||||
active.insert(id, a);
|
||||
}
|
||||
Ok((hashes, active))
|
||||
}
|
||||
|
||||
pub fn apply_new(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
source_id: i64,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<i64, Error> {
|
||||
let blob_id = db::blobs::intern_blob(tx, bytes)?;
|
||||
tx.execute(
|
||||
"INSERT INTO sieve_scripts (name, is_active, blob_id) VALUES (?1, 0, ?2)",
|
||||
params![name, blob_id],
|
||||
)?;
|
||||
let local_id = tx.last_insert_rowid();
|
||||
db::managesieve_ids::insert(tx, source_id, name, local_id)?;
|
||||
Ok(local_id)
|
||||
}
|
||||
|
||||
pub fn apply_content_update(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
local_id: i64,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), Error> {
|
||||
let blob_id = db::blobs::intern_blob(tx, bytes)?;
|
||||
tx.execute(
|
||||
"UPDATE sieve_scripts SET blob_id = ?1 WHERE id = ?2",
|
||||
params![blob_id, local_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_delete(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
source_id: i64,
|
||||
name: &str,
|
||||
local_id: i64,
|
||||
) -> Result<(), Error> {
|
||||
tx.execute("DELETE FROM sieve_scripts WHERE id = ?1", params![local_id])?;
|
||||
db::managesieve_ids::delete(tx, source_id, name)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_active_assignment(
|
||||
conn: &mut Connection,
|
||||
new_active_local: Option<i64>,
|
||||
) -> Result<(), Error> {
|
||||
let tx = conn.transaction()?;
|
||||
let existing_active: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT id FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if existing_active != new_active_local {
|
||||
if existing_active.is_some() {
|
||||
tx.execute(
|
||||
"UPDATE sieve_scripts SET is_active = 0 WHERE is_active = 1",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
if let Some(id) = new_active_local {
|
||||
tx.execute(
|
||||
"UPDATE sieve_scripts SET is_active = 1 WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::init;
|
||||
use crate::db::sources::{SourceKey, upsert_source};
|
||||
use blake3::Hasher;
|
||||
|
||||
fn hash(b: &[u8]) -> [u8; 32] {
|
||||
let mut h = Hasher::new();
|
||||
h.update(b);
|
||||
h.finalize().into()
|
||||
}
|
||||
|
||||
fn mem() -> (Connection, i64) {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
init::apply_schema(&c).unwrap();
|
||||
let sid = upsert_source(
|
||||
&c,
|
||||
&SourceKey {
|
||||
kind: "managesieve".to_owned(),
|
||||
session_url: "sieve://h:4190".to_owned(),
|
||||
account_id: "a".to_owned(),
|
||||
},
|
||||
None,
|
||||
"a",
|
||||
)
|
||||
.unwrap();
|
||||
(c, sid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_classifies_new_present_unchanged_and_vanished() {
|
||||
let h_v = hash(b"v");
|
||||
let h_w = hash(b"w");
|
||||
let server: Vec<(String, bool, [u8; 32])> = vec![
|
||||
("a".into(), false, h_v),
|
||||
("b".into(), true, h_w),
|
||||
("c".into(), false, h_v),
|
||||
];
|
||||
let mut local: HashMap<String, i64> = HashMap::new();
|
||||
local.insert("b".into(), 2);
|
||||
local.insert("c".into(), 3);
|
||||
local.insert("d".into(), 4);
|
||||
let mut lh: HashMap<i64, [u8; 32]> = HashMap::new();
|
||||
lh.insert(2, h_w);
|
||||
lh.insert(3, h_v);
|
||||
lh.insert(4, h_v);
|
||||
let mut la: HashMap<i64, bool> = HashMap::new();
|
||||
la.insert(2, true);
|
||||
la.insert(3, false);
|
||||
la.insert(4, false);
|
||||
let p = plan(&server, &local, &lh, &la);
|
||||
let mut new_count = 0;
|
||||
let mut unchanged = 0;
|
||||
let mut content_updated = 0;
|
||||
let mut active_only = 0;
|
||||
let mut vanished = 0;
|
||||
for a in p.actions {
|
||||
match a {
|
||||
Action::New { .. } => new_count += 1,
|
||||
Action::Present {
|
||||
outcome: PresentOutcome::Unchanged,
|
||||
..
|
||||
} => unchanged += 1,
|
||||
Action::Present {
|
||||
outcome: PresentOutcome::ContentUpdated,
|
||||
..
|
||||
} => content_updated += 1,
|
||||
Action::Present {
|
||||
outcome: PresentOutcome::ActiveOnly,
|
||||
..
|
||||
} => active_only += 1,
|
||||
Action::Vanished { .. } => vanished += 1,
|
||||
}
|
||||
}
|
||||
assert_eq!(new_count, 1);
|
||||
assert_eq!(unchanged, 2);
|
||||
assert_eq!(content_updated, 0);
|
||||
assert_eq!(active_only, 0);
|
||||
assert_eq!(vanished, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_flags_content_update_when_hash_differs() {
|
||||
let h_v = hash(b"v");
|
||||
let h_w = hash(b"w");
|
||||
let server: Vec<(String, bool, [u8; 32])> = vec![("a".into(), false, h_w)];
|
||||
let mut local: HashMap<String, i64> = HashMap::new();
|
||||
local.insert("a".into(), 1);
|
||||
let mut lh = HashMap::new();
|
||||
lh.insert(1, h_v);
|
||||
let mut la = HashMap::new();
|
||||
la.insert(1, false);
|
||||
let p = plan(&server, &local, &lh, &la);
|
||||
assert!(matches!(
|
||||
p.actions[0],
|
||||
Action::Present {
|
||||
outcome: PresentOutcome::ContentUpdated,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_flags_active_only_when_only_flag_differs() {
|
||||
let h = hash(b"same");
|
||||
let server: Vec<(String, bool, [u8; 32])> = vec![("a".into(), true, h)];
|
||||
let mut local: HashMap<String, i64> = HashMap::new();
|
||||
local.insert("a".into(), 1);
|
||||
let mut lh = HashMap::new();
|
||||
lh.insert(1, h);
|
||||
let mut la = HashMap::new();
|
||||
la.insert(1, false);
|
||||
let p = plan(&server, &local, &lh, &la);
|
||||
assert!(matches!(
|
||||
p.actions[0],
|
||||
Action::Present {
|
||||
outcome: PresentOutcome::ActiveOnly,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_new_inserts_script_and_id_mapping() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id = apply_new(&tx, sid, "vacation", b"require[\"x\"];").unwrap();
|
||||
tx.commit().unwrap();
|
||||
assert!(id > 0);
|
||||
let local = db::managesieve_ids::local_for(&c, sid, "vacation")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(local, id);
|
||||
let count: i64 = c
|
||||
.query_row("SELECT count(*) FROM sieve_scripts", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_content_update_swaps_blob_pointer() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id = apply_new(&tx, sid, "vacation", b"v1").unwrap();
|
||||
tx.commit().unwrap();
|
||||
let tx = c.transaction().unwrap();
|
||||
apply_content_update(&tx, id, b"v2").unwrap();
|
||||
tx.commit().unwrap();
|
||||
let blob: Vec<u8> = c
|
||||
.query_row(
|
||||
"SELECT b.data FROM sieve_scripts s JOIN blobs b ON b.id=s.blob_id WHERE s.id=?1",
|
||||
params![id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(blob, b"v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_active_assignment_swaps_active_in_one_transaction() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id_a = apply_new(&tx, sid, "a", b"x").unwrap();
|
||||
let id_b = apply_new(&tx, sid, "b", b"y").unwrap();
|
||||
tx.commit().unwrap();
|
||||
apply_active_assignment(&mut c, Some(id_a)).unwrap();
|
||||
let cur: Option<i64> = c
|
||||
.query_row(
|
||||
"SELECT id FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap();
|
||||
assert_eq!(cur, Some(id_a));
|
||||
apply_active_assignment(&mut c, Some(id_b)).unwrap();
|
||||
let cur: Option<i64> = c
|
||||
.query_row(
|
||||
"SELECT id FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap();
|
||||
assert_eq!(cur, Some(id_b));
|
||||
apply_active_assignment(&mut c, None).unwrap();
|
||||
let cur: Option<i64> = c
|
||||
.query_row(
|
||||
"SELECT id FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap();
|
||||
assert_eq!(cur, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_delete_clears_active_before_destroy() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id = apply_new(&tx, sid, "a", b"x").unwrap();
|
||||
tx.commit().unwrap();
|
||||
apply_active_assignment(&mut c, Some(id)).unwrap();
|
||||
let tx = c.transaction().unwrap();
|
||||
apply_delete(&tx, sid, "a", id).unwrap();
|
||||
tx.commit().unwrap();
|
||||
let count: i64 = c
|
||||
.query_row("SELECT count(*) FROM sieve_scripts", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
assert!(
|
||||
db::managesieve_ids::local_for(&c, sid, "a")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_new_dedups_identical_script_bytes_at_blob_level() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id_a = apply_new(&tx, sid, "a", b"require [\"fileinto\"];\n").unwrap();
|
||||
let id_b = apply_new(&tx, sid, "b", b"require [\"fileinto\"];\n").unwrap();
|
||||
tx.commit().unwrap();
|
||||
let blob_a: i64 = c
|
||||
.query_row(
|
||||
"SELECT blob_id FROM sieve_scripts WHERE id = ?1",
|
||||
params![id_a],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let blob_b: i64 = c
|
||||
.query_row(
|
||||
"SELECT blob_id FROM sieve_scripts WHERE id = ?1",
|
||||
params![id_b],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(blob_a, blob_b, "identical bytes must share a blob row");
|
||||
let blob_count: i64 = c
|
||||
.query_row("SELECT count(*) FROM blobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(blob_count, 1, "blob table must contain a single row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_content_update_orphans_old_blob_for_gc() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id = apply_new(&tx, sid, "a", b"v1").unwrap();
|
||||
tx.commit().unwrap();
|
||||
let tx = c.transaction().unwrap();
|
||||
apply_content_update(&tx, id, b"v2").unwrap();
|
||||
tx.commit().unwrap();
|
||||
let blob_count_before_gc: i64 = c
|
||||
.query_row("SELECT count(*) FROM blobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(blob_count_before_gc, 2, "old blob still present pre-GC");
|
||||
let tx = c.unchecked_transaction().unwrap();
|
||||
crate::db::blobs::gc_orphan_blobs(&tx).unwrap();
|
||||
tx.commit().unwrap();
|
||||
let blob_count_after_gc: i64 = c
|
||||
.query_row("SELECT count(*) FROM blobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(blob_count_after_gc, 1, "orphan blob reclaimed by GC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_unique_index_rejects_two_active_rows() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id_a = apply_new(&tx, sid, "a", b"x").unwrap();
|
||||
let id_b = apply_new(&tx, sid, "b", b"y").unwrap();
|
||||
tx.commit().unwrap();
|
||||
c.execute(
|
||||
"UPDATE sieve_scripts SET is_active = 1 WHERE id = ?1",
|
||||
params![id_a],
|
||||
)
|
||||
.unwrap();
|
||||
let err = c
|
||||
.execute(
|
||||
"UPDATE sieve_scripts SET is_active = 1 WHERE id = ?1",
|
||||
params![id_b],
|
||||
)
|
||||
.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("UNIQUE") || msg.contains("unique"),
|
||||
"expected unique-constraint failure, got {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user