diff --git a/src/sync/export.rs b/src/sync/export.rs index 12ec7f2..d3cadfe 100644 --- a/src/sync/export.rs +++ b/src/sync/export.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * 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 { + 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; diff --git a/src/sync/export/sieve.rs b/src/sync/export/sieve.rs index 8928202..5420559 100644 --- a/src/sync/export/sieve.rs +++ b/src/sync/export/sieve.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * 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 = 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,10 +73,18 @@ 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 { - let blob_id = up - .upload_with(*blob_local, "application/sieve") - .map_err(Error::from)?; + 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 { obj.insert("name".to_owned(), Value::String(n.clone())); @@ -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 { + 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>, 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 + })) +} diff --git a/src/sync/export/sieve_names.rs b/src/sync/export/sieve_names.rs new file mode 100644 index 0000000..0849403 --- /dev/null +++ b/src/sync/export/sieve_names.rs @@ -0,0 +1,342 @@ +/* + * SPDX-FileCopyrightText: 2026 John Coffey + * + * 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, Vec)> { + 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 = 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 { + 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)> { + 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(&[])); + } +} diff --git a/tests/mock_sync.rs b/tests/mock_sync.rs index 2bd9546..8c545f4 100644 --- a/tests/mock_sync.rs +++ b/tests/mock_sync.rs @@ -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) { + 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();