Initial commit

This commit is contained in:
Maurus Decimus
2026-05-29 18:02:15 +02:00
commit 576073f8c9
263 changed files with 78638 additions and 0 deletions
+638
View File
@@ -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(_)));
}
}
+480
View File
@@ -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}"
);
}
}