Merge pull request 'Rename Stalwart's Sieve names for inbuxa, and fail when activation does' (#3) from fix/sieve-stalwart-names into main
This commit was merged in pull request #3.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
@@ -97,6 +98,34 @@ impl<'a> Uploader<'a> {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// As `upload_with`, but sends `bytes` in place of the stored blob: for
|
||||
/// content rewritten on its way to the target. Cached under the same
|
||||
/// local id, so a retry sends the rewritten bytes again.
|
||||
fn upload_bytes_as(
|
||||
&mut self,
|
||||
local_id: i64,
|
||||
content_type: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<JmapId, JmapError> {
|
||||
self.touched.push(local_id);
|
||||
if let Some(id) = self.cache.get(&local_id) {
|
||||
return Ok(id.clone());
|
||||
}
|
||||
let id = if self.net.dry_run {
|
||||
JmapId(format!("dryrun-blob-{local_id}"))
|
||||
} else {
|
||||
blobxfer::upload_bytes(
|
||||
&self.net.client,
|
||||
&self.net.session,
|
||||
&self.net.account,
|
||||
content_type,
|
||||
bytes,
|
||||
)?
|
||||
};
|
||||
self.cache.insert(local_id, id.clone());
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn invalidate(&mut self, local_id: i64) {
|
||||
self.cache.remove(&local_id);
|
||||
}
|
||||
@@ -435,6 +464,8 @@ mod keyed;
|
||||
|
||||
mod sieve;
|
||||
|
||||
mod sieve_names;
|
||||
|
||||
mod uidtype;
|
||||
|
||||
mod email;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
@@ -9,10 +10,12 @@ use std::collections::{HashMap, HashSet};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::common::{create_batch, jid, retry_if_blob_missing, target_get_all};
|
||||
use super::sieve_names;
|
||||
use super::{Maps, Net, Plan, Uploader};
|
||||
use crate::error::Error;
|
||||
use crate::jmap::request::Request;
|
||||
use crate::logging::Logger;
|
||||
use crate::jmap::request::{Request, check_method_error};
|
||||
use crate::logging::{LEVEL_DEFAULT, Logger};
|
||||
use crate::sync::import_jmap::mapping::BlobBytes;
|
||||
use crate::sync::import_jmap::mapping::{SIEVE_SELECT, row_to_sieve_script};
|
||||
use crate::sync::{Context, TypeCounts};
|
||||
use crate::types::ObjectType;
|
||||
@@ -57,6 +60,11 @@ pub fn reconcile(
|
||||
let mut active_target: Option<String> = None;
|
||||
let mut deactivate = false;
|
||||
let mut uploader = Uploader::new(net, &ctx.conn);
|
||||
let rename_vendor = sieve_names::target_uses_inbuxa_names(&target_sieve_extensions(net));
|
||||
let wanted_active = locals
|
||||
.iter()
|
||||
.find(|(_, _, a, _)| *a)
|
||||
.map(|(_, n, _, _)| n.clone().unwrap_or_default());
|
||||
|
||||
for (local, name, is_active, blob_local) in &locals {
|
||||
let matched = name.as_ref().and_then(|n| target_by_name.get(n)).cloned();
|
||||
@@ -65,9 +73,17 @@ pub fn reconcile(
|
||||
id
|
||||
} else {
|
||||
let cid = format!("c{local}");
|
||||
let label = name.as_deref().unwrap_or("(unnamed)");
|
||||
let rewritten = if rename_vendor {
|
||||
renamed_script(&uploader, *blob_local, label, logger)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let build = |up: &mut Uploader<'_>| -> Result<Value, Error> {
|
||||
let blob_id = up
|
||||
.upload_with(*blob_local, "application/sieve")
|
||||
let blob_id = match &rewritten {
|
||||
Some(bytes) => up.upload_bytes_as(*blob_local, "application/sieve", bytes),
|
||||
None => up.upload_with(*blob_local, "application/sieve"),
|
||||
}
|
||||
.map_err(Error::from)?;
|
||||
let mut obj = serde_json::Map::new();
|
||||
if let Some(n) = name {
|
||||
@@ -92,7 +108,7 @@ pub fn reconcile(
|
||||
}
|
||||
None => {
|
||||
for (cid, err) in &outcome.not_created {
|
||||
logger.warn(&format!("SieveScript {cid} not created: {err}"));
|
||||
logger.warn(&format!("SieveScript {label} ({cid}) not created: {err}"));
|
||||
}
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
@@ -107,6 +123,15 @@ pub fn reconcile(
|
||||
if active_target.is_none() && locals.iter().all(|(_, _, a, _)| !*a) {
|
||||
deactivate = true;
|
||||
}
|
||||
if let (Some(name), None) = (&wanted_active, &active_target) {
|
||||
// The script that was active at the source never made it to the
|
||||
// target (its creation failure is already counted): say plainly that
|
||||
// the account now has no filtering, rather than leave it to a warning.
|
||||
logger.error(&format!(
|
||||
"the active Sieve script \"{name}\" could not be created on the target; \
|
||||
no filtering is active there"
|
||||
));
|
||||
}
|
||||
|
||||
if !net.dry_run {
|
||||
let mut req = Request::new();
|
||||
@@ -118,8 +143,20 @@ pub fn reconcile(
|
||||
json!({ "accountId": net.account })
|
||||
};
|
||||
req.call("SieveScript/set", args, "a");
|
||||
if let Err(e) = req.send(&net.client, &net.api) {
|
||||
logger.warn(&format!("SieveScript activation failed: {e}"));
|
||||
let result = req
|
||||
.send(&net.client, &net.api)
|
||||
.and_then(|resp| resp.by_call_id("a").cloned())
|
||||
.and_then(|mr| check_method_error(&mr));
|
||||
if let Err(e) = result {
|
||||
match (&active_target, &wanted_active) {
|
||||
(Some(_), Some(name)) => {
|
||||
logger.error(&format!(
|
||||
"the Sieve script \"{name}\" was created but could not be activated: {e}"
|
||||
));
|
||||
counts.failed += 1;
|
||||
}
|
||||
_ => logger.warn(&format!("SieveScript activation failed: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,3 +173,39 @@ pub fn reconcile(
|
||||
active_sieve_target: active_target,
|
||||
})
|
||||
}
|
||||
|
||||
/// The target's `sieveExtensions`, from its Sieve account capability.
|
||||
fn target_sieve_extensions(net: &Net) -> Vec<String> {
|
||||
net.session
|
||||
.account_capabilities(&net.account)
|
||||
.and_then(|caps| caps.get("urn:ietf:params:jmap:sieve"))
|
||||
.and_then(|c| c.get("sieveExtensions"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The script's bytes with Stalwart's vendor names renamed for an inbuxa
|
||||
/// target, or `None` when it needs no change. Each rename is logged.
|
||||
fn renamed_script(
|
||||
uploader: &Uploader<'_>,
|
||||
blob_local: i64,
|
||||
label: &str,
|
||||
logger: &Logger,
|
||||
) -> Result<Option<Vec<u8>>, Error> {
|
||||
let bytes = uploader.bytes(blob_local).map_err(Error::from)?;
|
||||
Ok(sieve_names::rewrite(&bytes).map(|(out, renamed)| {
|
||||
for old in &renamed {
|
||||
let new = old.replacen("vnd.stalwart.", "vnd.inbuxa.", 1);
|
||||
if logger.enabled(LEVEL_DEFAULT) {
|
||||
eprintln!("export: SieveScript {label}: renamed {old} to {new}");
|
||||
}
|
||||
}
|
||||
out
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
//! Stalwart's vendor Sieve names, renamed for inbuxa.
|
||||
//!
|
||||
//! inbuxa accepts `vnd.inbuxa.while` and `vnd.inbuxa.expressions` where
|
||||
//! Stalwart accepted `vnd.stalwart.*`, with no alias, and names its
|
||||
//! environment items the same way. A script carried over unchanged fails to
|
||||
//! compile on inbuxa, so export renames those names -- and only those names:
|
||||
//! the strings of a `require` list, the name argument of an `environment`
|
||||
//! test, and `${env.vnd.stalwart.…}` references inside strings. Everything
|
||||
//! else in the script, including other strings that happen to contain the
|
||||
//! text, is copied byte for byte.
|
||||
|
||||
const OLD: &str = "vnd.stalwart.";
|
||||
const NEW: &str = "vnd.inbuxa.";
|
||||
const OLD_ENV_REF: &str = "${env.vnd.stalwart.";
|
||||
const NEW_ENV_REF: &str = "${env.vnd.inbuxa.";
|
||||
|
||||
/// Whether the target advertises inbuxa's vendor extensions, from the
|
||||
/// `sieveExtensions` list of its `urn:ietf:params:jmap:sieve` account
|
||||
/// capability.
|
||||
pub fn target_uses_inbuxa_names(sieve_extensions: &[String]) -> bool {
|
||||
sieve_extensions.iter().any(|e| e.starts_with(NEW))
|
||||
}
|
||||
|
||||
/// The script with Stalwart's vendor names renamed, and the old names that
|
||||
/// were changed, in order. `None` when nothing needed renaming, or when the
|
||||
/// script is not UTF-8 (left alone rather than guessed at).
|
||||
pub fn rewrite(script: &[u8]) -> Option<(Vec<u8>, Vec<String>)> {
|
||||
let text = std::str::from_utf8(script).ok()?;
|
||||
if !text.contains(OLD) {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut renamed = Vec::new();
|
||||
let mut copied = 0;
|
||||
let mut context = Context::None;
|
||||
for tok in Tokens::new(text) {
|
||||
match tok.kind {
|
||||
Kind::Word => {
|
||||
context = match tok.text(text).to_ascii_lowercase().as_str() {
|
||||
"require" => Context::Require,
|
||||
"environment" => Context::Environment,
|
||||
_ if context == Context::Environment => Context::Environment,
|
||||
_ => Context::None,
|
||||
};
|
||||
}
|
||||
Kind::Tag => {
|
||||
// `environment :comparator "i;octet"`: the comparator's own
|
||||
// string is not the item name.
|
||||
if context == Context::Environment
|
||||
&& tok.text(text).eq_ignore_ascii_case(":comparator")
|
||||
{
|
||||
context = Context::EnvironmentComparator;
|
||||
}
|
||||
}
|
||||
Kind::Quoted | Kind::Multiline => {
|
||||
let (start, end) = tok.content;
|
||||
let content = &text[start..end];
|
||||
let mut replacement: Option<String> = None;
|
||||
let whole_name = matches!(context, Context::Require | Context::Environment);
|
||||
if whole_name && content.starts_with(OLD) {
|
||||
replacement = Some(format!("{NEW}{}", &content[OLD.len()..]));
|
||||
renamed.push(content.to_owned());
|
||||
}
|
||||
let current = replacement.as_deref().unwrap_or(content);
|
||||
if current.contains(OLD_ENV_REF) {
|
||||
let mut n = 0;
|
||||
let mut rest = current;
|
||||
while let Some(i) = rest.find(OLD_ENV_REF) {
|
||||
let tail = &rest[i + 2..];
|
||||
let name_end = tail.find('}').unwrap_or(tail.len());
|
||||
renamed.push(tail[4..name_end].to_owned());
|
||||
rest = &rest[i + OLD_ENV_REF.len()..];
|
||||
n += 1;
|
||||
}
|
||||
if n > 0 {
|
||||
replacement = Some(current.replace(OLD_ENV_REF, NEW_ENV_REF));
|
||||
}
|
||||
}
|
||||
if let Some(r) = replacement {
|
||||
out.push_str(&text[copied..start]);
|
||||
out.push_str(&r);
|
||||
copied = end;
|
||||
}
|
||||
context = match context {
|
||||
Context::Require => Context::Require,
|
||||
Context::EnvironmentComparator => Context::Environment,
|
||||
_ => Context::None,
|
||||
};
|
||||
}
|
||||
Kind::Punct(';') | Kind::Punct('{') | Kind::Punct('}') => context = Context::None,
|
||||
Kind::Punct(_) => {}
|
||||
}
|
||||
}
|
||||
if renamed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
out.push_str(&text[copied..]);
|
||||
Some((out.into_bytes(), renamed))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Context {
|
||||
None,
|
||||
Require,
|
||||
Environment,
|
||||
EnvironmentComparator,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Word,
|
||||
Tag,
|
||||
Quoted,
|
||||
Multiline,
|
||||
Punct(char),
|
||||
}
|
||||
|
||||
struct Token {
|
||||
kind: Kind,
|
||||
span: (usize, usize),
|
||||
/// The string's content, without quotes or the `text:` framing.
|
||||
content: (usize, usize),
|
||||
}
|
||||
|
||||
impl Token {
|
||||
fn text<'a>(&self, src: &'a str) -> &'a str {
|
||||
&src[self.span.0..self.span.1]
|
||||
}
|
||||
}
|
||||
|
||||
/// Just enough of RFC 5228's lexer to find strings and the words before
|
||||
/// them: comments are skipped, and quoted strings and `text:` blocks are
|
||||
/// read whole, so nothing inside them is mistaken for a command.
|
||||
struct Tokens<'a> {
|
||||
src: &'a str,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Tokens<'a> {
|
||||
fn new(src: &'a str) -> Self {
|
||||
Tokens { src, pos: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Tokens<'_> {
|
||||
type Item = Token;
|
||||
|
||||
fn next(&mut self) -> Option<Token> {
|
||||
let b = self.src.as_bytes();
|
||||
loop {
|
||||
while self.pos < b.len() && b[self.pos].is_ascii_whitespace() {
|
||||
self.pos += 1;
|
||||
}
|
||||
if self.pos >= b.len() {
|
||||
return None;
|
||||
}
|
||||
if b[self.pos] == b'#' {
|
||||
while self.pos < b.len() && b[self.pos] != b'\n' {
|
||||
self.pos += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if b[self.pos..].starts_with(b"/*") {
|
||||
self.pos = match self.src[self.pos + 2..].find("*/") {
|
||||
Some(i) => self.pos + 2 + i + 2,
|
||||
None => b.len(),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
let start = self.pos;
|
||||
let c = b[start];
|
||||
if c == b'"' {
|
||||
let mut i = start + 1;
|
||||
while i < b.len() && b[i] != b'"' {
|
||||
i += if b[i] == b'\\' { 2 } else { 1 };
|
||||
}
|
||||
let end = i.min(b.len());
|
||||
self.pos = (end + 1).min(b.len());
|
||||
return Some(Token {
|
||||
kind: Kind::Quoted,
|
||||
span: (start, self.pos),
|
||||
content: (start + 1, end),
|
||||
});
|
||||
}
|
||||
if c.is_ascii_alphabetic() || c == b'_' || c == b':' {
|
||||
let mut i = start + 1;
|
||||
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') {
|
||||
i += 1;
|
||||
}
|
||||
let word = &self.src[start..i];
|
||||
if word.eq_ignore_ascii_case("text:")
|
||||
|| (word.eq_ignore_ascii_case("text") && b.get(i) == Some(&b':'))
|
||||
{
|
||||
let after = if b.get(i) == Some(&b':') { i + 1 } else { i };
|
||||
// The body starts after the rest of the `text:` line and runs
|
||||
// to a line holding a single dot.
|
||||
let body = match self.src[after..].find('\n') {
|
||||
Some(n) => after + n + 1,
|
||||
None => b.len(),
|
||||
};
|
||||
let (body_end, next) = find_dot_line(self.src, body);
|
||||
self.pos = next;
|
||||
return Some(Token {
|
||||
kind: Kind::Multiline,
|
||||
span: (start, next),
|
||||
content: (body, body_end),
|
||||
});
|
||||
}
|
||||
self.pos = i;
|
||||
let kind = if c == b':' { Kind::Tag } else { Kind::Word };
|
||||
return Some(Token {
|
||||
kind,
|
||||
span: (start, i),
|
||||
content: (start, i),
|
||||
});
|
||||
}
|
||||
let ch = self.src[start..].chars().next().unwrap_or('\0');
|
||||
self.pos = start + ch.len_utf8();
|
||||
Some(Token {
|
||||
kind: Kind::Punct(ch),
|
||||
span: (start, self.pos),
|
||||
content: (start, self.pos),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// End of a `text:` body (the start of its closing dot line) and the offset
|
||||
/// after that line.
|
||||
fn find_dot_line(src: &str, from: usize) -> (usize, usize) {
|
||||
let mut line_start = from;
|
||||
while line_start < src.len() {
|
||||
let line_end = src[line_start..]
|
||||
.find('\n')
|
||||
.map(|n| line_start + n)
|
||||
.unwrap_or(src.len());
|
||||
if src[line_start..line_end].trim_end_matches('\r') == "." {
|
||||
return (line_start, (line_end + 1).min(src.len()));
|
||||
}
|
||||
line_start = line_end + 1;
|
||||
}
|
||||
(src.len(), src.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(s: &str) -> Option<(String, Vec<String>)> {
|
||||
rewrite(s.as_bytes()).map(|(b, r)| (String::from_utf8(b).unwrap(), r))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renames_a_require_list() {
|
||||
let (out, renamed) =
|
||||
run("require [\"fileinto\", \"vnd.stalwart.while\", \"vnd.stalwart.expressions\"];\n")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
"require [\"fileinto\", \"vnd.inbuxa.while\", \"vnd.inbuxa.expressions\"];\n"
|
||||
);
|
||||
assert_eq!(renamed, ["vnd.stalwart.while", "vnd.stalwart.expressions"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renames_a_single_require_string() {
|
||||
let (out, _) = run("REQUIRE \"vnd.stalwart.while\";").unwrap();
|
||||
assert_eq!(out, "REQUIRE \"vnd.inbuxa.while\";");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renames_the_environment_item_name_only() {
|
||||
let src = "if environment :comparator \"i;octet\" :is \"vnd.stalwart.username\" \"vnd.stalwart.x\" { keep; }";
|
||||
let (out, renamed) = run(src).unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
"if environment :comparator \"i;octet\" :is \"vnd.inbuxa.username\" \"vnd.stalwart.x\" { keep; }"
|
||||
);
|
||||
assert_eq!(renamed, ["vnd.stalwart.username"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renames_env_references_inside_strings() {
|
||||
let src = "set \"box\" \"${env.vnd.stalwart.default_mailbox}/Archive\";";
|
||||
let (out, renamed) = run(src).unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
"set \"box\" \"${env.vnd.inbuxa.default_mailbox}/Archive\";"
|
||||
);
|
||||
assert_eq!(renamed, ["vnd.stalwart.default_mailbox"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renames_env_references_in_text_blocks() {
|
||||
let src = "vacation text:\nHi ${env.vnd.stalwart.username}.\n.\n;\n";
|
||||
let (out, _) = run(src).unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
"vacation text:\nHi ${env.vnd.inbuxa.username}.\n.\n;\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_unrelated_strings_comments_and_text_alone() {
|
||||
let src = "# vnd.stalwart.while is old\n/* \"vnd.stalwart.x\" */\n\
|
||||
if header :contains \"subject\" \"vnd.stalwart.while\" { fileinto \"vnd.stalwart.box\"; }\n\
|
||||
vacation text:\nrequire \"vnd.stalwart.while\";\n.\n;\n";
|
||||
assert!(run(src).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_context_ends_at_the_semicolon() {
|
||||
let src = "require \"fileinto\"; fileinto \"vnd.stalwart.folder\";";
|
||||
assert!(run(src).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_do_is_none() {
|
||||
assert!(run("require \"fileinto\";\nkeep;\n").is_none());
|
||||
assert!(rewrite(&[0xff, 0xfe, b'v']).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_detection() {
|
||||
assert!(target_uses_inbuxa_names(&[
|
||||
"fileinto".to_owned(),
|
||||
"vnd.inbuxa.while".to_owned()
|
||||
]));
|
||||
assert!(!target_uses_inbuxa_names(&[
|
||||
"fileinto".to_owned(),
|
||||
"vnd.stalwart.while".to_owned()
|
||||
]));
|
||||
assert!(!target_uses_inbuxa_names(&[]));
|
||||
}
|
||||
}
|
||||
@@ -2683,6 +2683,205 @@ fn export_sieve_script_matches_by_name_not_content() {
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
/// A session like `session_body_full`, whose Sieve capability lists
|
||||
/// `extensions` in `sieveExtensions`.
|
||||
fn session_body_sieve(base: &str, extensions: &[&str]) -> String {
|
||||
let mut v: serde_json::Value = serde_json::from_str(&session_body_full(base)).unwrap();
|
||||
v["accounts"]["w"]["accountCapabilities"]["urn:ietf:params:jmap:sieve"] =
|
||||
json!({ "sieveExtensions": extensions });
|
||||
v.to_string()
|
||||
}
|
||||
|
||||
/// One active script `name` in a fresh archive, with `body` as its content.
|
||||
fn archive_with_active_sieve(name: &str, body: &[u8]) -> PathBuf {
|
||||
let archive = tmp();
|
||||
let conn = db::init::open(&archive).unwrap();
|
||||
let blob = db::blobs::intern_blob(&conn, body).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,?1,1,?2)",
|
||||
rusqlite::params![name, blob],
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
}
|
||||
|
||||
/// Mocks for exporting one script to an empty target: get, upload (matched
|
||||
/// by `upload_body`), create, and activation answered with `activation`.
|
||||
/// Returns the upload mock and the activation mock.
|
||||
fn mock_sieve_export(
|
||||
server: &mut mockito::ServerGuard,
|
||||
session: String,
|
||||
upload_body: Matcher,
|
||||
create_ok: bool,
|
||||
activation: serde_json::Value,
|
||||
) -> (mockito::Mock, mockito::Mock, Vec<mockito::Mock>) {
|
||||
let api = "/jmap/api";
|
||||
let mut keep = vec![
|
||||
server.mock("GET", "/").with_status(404).create(),
|
||||
server
|
||||
.mock("GET", "/.well-known/jmap")
|
||||
.with_body(session)
|
||||
.expect_at_least(1)
|
||||
.create(),
|
||||
server
|
||||
.mock("POST", api)
|
||||
.match_body(Matcher::Regex("SieveScript/get".into()))
|
||||
.with_body(
|
||||
json!({"methodResponses":[["SieveScript/get",
|
||||
{"accountId":"w","list":[],"notFound":[]},"g"]]})
|
||||
.to_string(),
|
||||
)
|
||||
.create(),
|
||||
];
|
||||
let upload = server
|
||||
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
||||
.match_body(upload_body)
|
||||
.with_body(json!({"blobId":"UPN"}).to_string())
|
||||
.expect(1)
|
||||
.create();
|
||||
let created = if create_ok {
|
||||
json!({"accountId":"w","created":{"c1":{"id":"S1"}}})
|
||||
} else {
|
||||
json!({"accountId":"w","notCreated":{"c1":{"type":"invalidScript",
|
||||
"description":"unknown extension"}}})
|
||||
};
|
||||
keep.push(
|
||||
server
|
||||
.mock("POST", api)
|
||||
.match_body(Matcher::AllOf(vec![
|
||||
Matcher::Regex("SieveScript/set".into()),
|
||||
Matcher::Regex("\"create\"".into()),
|
||||
]))
|
||||
.with_body(json!({"methodResponses":[["SieveScript/set", created, "s"]]}).to_string())
|
||||
.create(),
|
||||
);
|
||||
let activate = server
|
||||
.mock("POST", api)
|
||||
.match_body(Matcher::AllOf(vec![
|
||||
Matcher::Regex("SieveScript/set".into()),
|
||||
Matcher::Regex("onSuccess".into()),
|
||||
]))
|
||||
.with_body(json!({"methodResponses":[activation]}).to_string())
|
||||
.expect(1)
|
||||
.create();
|
||||
(upload, activate, keep)
|
||||
}
|
||||
|
||||
fn sieve_counts(summary: &sync::Summary) -> sync::TypeCounts {
|
||||
summary
|
||||
.per_type
|
||||
.iter()
|
||||
.find(|(t, _)| *t == "SieveScript")
|
||||
.map(|(_, c)| c.clone())
|
||||
.expect("sieve counts")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_sieve_renames_stalwart_names_for_an_inbuxa_target() {
|
||||
let mut server = mockito::Server::new();
|
||||
let base = server.url();
|
||||
let archive = archive_with_active_sieve(
|
||||
"loop",
|
||||
b"require [\"fileinto\", \"vnd.stalwart.while\"];\nkeep;\n",
|
||||
);
|
||||
let (upload, activate, _keep) = mock_sieve_export(
|
||||
&mut server,
|
||||
session_body_sieve(
|
||||
&base,
|
||||
&["fileinto", "vnd.inbuxa.while", "vnd.inbuxa.expressions"],
|
||||
),
|
||||
Matcher::Exact("require [\"fileinto\", \"vnd.inbuxa.while\"];\nkeep;\n".into()),
|
||||
true,
|
||||
json!(["SieveScript/set", {"accountId":"w"}, "a"]),
|
||||
);
|
||||
let summary = sync::export::run(
|
||||
common(&archive),
|
||||
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
|
||||
)
|
||||
.expect("export");
|
||||
upload.assert();
|
||||
activate.assert();
|
||||
let c = sieve_counts(&summary);
|
||||
assert_eq!((c.created, c.failed), (1, 0));
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_sieve_keeps_stalwart_names_for_a_target_without_inbuxa_names() {
|
||||
let mut server = mockito::Server::new();
|
||||
let base = server.url();
|
||||
let archive = archive_with_active_sieve(
|
||||
"loop",
|
||||
b"require [\"fileinto\", \"vnd.stalwart.while\"];\nkeep;\n",
|
||||
);
|
||||
let (upload, _activate, _keep) = mock_sieve_export(
|
||||
&mut server,
|
||||
session_body_sieve(&base, &["fileinto", "vnd.stalwart.while"]),
|
||||
Matcher::Regex("vnd\\.stalwart\\.while".into()),
|
||||
true,
|
||||
json!(["SieveScript/set", {"accountId":"w"}, "a"]),
|
||||
);
|
||||
let summary = sync::export::run(
|
||||
common(&archive),
|
||||
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
|
||||
)
|
||||
.expect("export");
|
||||
upload.assert();
|
||||
assert_eq!(sieve_counts(&summary).failed, 0);
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_sieve_activation_error_is_a_failure() {
|
||||
let mut server = mockito::Server::new();
|
||||
let base = server.url();
|
||||
let archive = archive_with_active_sieve("main", b"require [\"fileinto\"];\nkeep;\n");
|
||||
let (_upload, activate, _keep) = mock_sieve_export(
|
||||
&mut server,
|
||||
session_body_full(&base),
|
||||
Matcher::Any,
|
||||
true,
|
||||
json!(["error", {"type":"invalidArguments","description":"cannot activate"}, "a"]),
|
||||
);
|
||||
let summary = sync::export::run(
|
||||
common(&archive),
|
||||
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
|
||||
)
|
||||
.expect("export");
|
||||
activate.assert();
|
||||
let c = sieve_counts(&summary);
|
||||
assert_eq!(c.created, 1);
|
||||
assert_eq!(
|
||||
c.failed, 1,
|
||||
"a failed activation is a failure, not a warning"
|
||||
);
|
||||
assert!(summary.any_failed(), "so export exits non-zero");
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_sieve_active_script_not_created_is_a_failure() {
|
||||
let mut server = mockito::Server::new();
|
||||
let base = server.url();
|
||||
let archive = archive_with_active_sieve("main", b"require [\"nope\"];\nkeep;\n");
|
||||
let (_upload, _activate, _keep) = mock_sieve_export(
|
||||
&mut server,
|
||||
session_body_full(&base),
|
||||
Matcher::Any,
|
||||
false,
|
||||
json!(["SieveScript/set", {"accountId":"w"}, "a"]),
|
||||
);
|
||||
let summary = sync::export::run(
|
||||
common(&archive),
|
||||
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
|
||||
)
|
||||
.expect("export");
|
||||
let c = sieve_counts(&summary);
|
||||
assert_eq!((c.created, c.failed), (0, 1));
|
||||
assert!(summary.any_failed());
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_sieve_scripts_identical_content_different_names_both_created() {
|
||||
let mut server = mockito::Server::new();
|
||||
|
||||
Reference in New Issue
Block a user