diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs index 842b9d70..7e5c6595 100644 --- a/crates/directory/src/core/secret.rs +++ b/crates/directory/src/core/secret.rs @@ -140,7 +140,7 @@ pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Resu Ok(bsdi_crypt::verify(secret, hashed_secret)) } else if let Some(hashed_secret) = hashed_secret.strip_prefix('{') { if let Some((algo, hashed_secret)) = hashed_secret.split_once('}') { - match algo { + match algo.to_ascii_uppercase().as_str() { "ARGON2" | "ARGON2I" | "ARGON2ID" | "PBKDF2" => { verify_hash_prefix(hashed_secret, secret).await } @@ -218,7 +218,7 @@ pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Resu == hashed_secret, ) } - "CRYPT" | "crypt" => { + "CRYPT" => { if hashed_secret.starts_with('$') { verify_hash_prefix(hashed_secret, secret).await } else { @@ -226,7 +226,7 @@ pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Resu Ok(unix_crypt::verify(secret, hashed_secret)) } } - "PLAIN" | "plain" | "CLEAR" | "clear" => Ok(hashed_secret.as_bytes() == secret), + "PLAIN" | "CLEAR" => Ok(hashed_secret.as_bytes() == secret), _ => Err(trc::AuthEvent::Error .ctx(trc::Key::Reason, "Unsupported algorithm") .details(hashed_secret.to_string())), @@ -292,37 +292,26 @@ pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: Vec) -> t pub fn is_password_hash(s: &str) -> bool { if s.starts_with("$argon2") || s.starts_with("$pbkdf2") || s.starts_with("$scrypt") { - return is_complete_phc(s); - } - - if s.starts_with("$2") { - return is_bcrypt_format(s); - } - - if let Some(body) = s.strip_prefix("$1$") { - return is_md5_crypt(body); - } - - if let Some(body) = s.strip_prefix("$5$") { - return is_sha_crypt(body, 43); - } - - if let Some(body) = s.strip_prefix("$6$") { - return is_sha_crypt(body, 86); - } - - if let Some(body) = s.strip_prefix("$sha1$") { - return is_sha1_crypt(body); - } - - if let Some(rest) = s.strip_prefix('{') { - return rest - .split_once('}') + is_complete_phc(s) + } else if s.starts_with("$2") { + is_bcrypt_format(s) + } else if let Some(body) = s.strip_prefix("$1$") { + is_md5_crypt(body) + } else if let Some(body) = s.strip_prefix("$5$") { + is_sha_crypt(body, 43) + } else if let Some(body) = s.strip_prefix("$6$") { + is_sha_crypt(body, 86) + } else if let Some(body) = s.strip_prefix("$sha1$") { + is_sha1_crypt(body) + } else if s.starts_with('_') { + is_unix_des_crypt(s) + } else if let Some(rest) = s.strip_prefix('{') { + rest.split_once('}') .map(|(scheme, body)| is_ldap_hash(scheme, body)) - .unwrap_or(false); + .unwrap_or(false) + } else { + false } - - false } fn is_complete_phc(s: &str) -> bool { @@ -341,20 +330,17 @@ fn all_crypt_b64(s: &str) -> bool { fn is_bcrypt_format(s: &str) -> bool { let bytes = s.as_bytes(); - if bytes.len() != 60 { - return false; - } - if bytes[1] != b'2' || !matches!(bytes[2], b'a' | b'b' | b'x' | b'y') { - return false; - } - if bytes[3] != b'$' + if bytes.len() != 60 + || !matches!(bytes[2], b'a' | b'b' | b'x' | b'y') + || bytes[3] != b'$' || !bytes[4].is_ascii_digit() || !bytes[5].is_ascii_digit() || bytes[6] != b'$' { - return false; + false + } else { + bytes[7..].iter().copied().all(is_crypt_b64) } - bytes[7..].iter().copied().all(is_crypt_b64) } fn is_md5_crypt(body: &str) -> bool { @@ -401,17 +387,20 @@ fn is_sha1_crypt(body: &str) -> bool { let Some(hash) = parts.next() else { return false; }; - if rounds.is_empty() || !rounds.bytes().all(|b| b.is_ascii_digit()) { - return false; + if rounds.is_empty() + || !rounds.bytes().all(|b| b.is_ascii_digit()) + || salt.is_empty() + || salt.len() > 64 + || !all_crypt_b64(salt) + { + false + } else { + hash.len() == 28 && all_crypt_b64(hash) } - if salt.is_empty() || salt.len() > 64 || !all_crypt_b64(salt) { - return false; - } - hash.len() == 28 && all_crypt_b64(hash) } fn is_ldap_hash(scheme: &str, body: &str) -> bool { - match scheme { + match scheme.to_ascii_uppercase().as_str() { "SHA" => b64_decoded_len_eq(body, 20), "SSHA" => b64_decoded_len_ge(body, 21), "SHA256" => b64_decoded_len_eq(body, 32), @@ -420,7 +409,7 @@ fn is_ldap_hash(scheme: &str, body: &str) -> bool { "SSHA512" => b64_decoded_len_ge(body, 65), "MD5" => b64_decoded_len_eq(body, 16), "ARGON2" | "ARGON2I" | "ARGON2ID" | "PBKDF2" => is_complete_phc(body), - "CRYPT" | "crypt" => is_password_hash(body) || is_unix_des_crypt(body), + "CRYPT" => is_password_hash(body) || is_unix_des_crypt(body), _ => false, } } @@ -492,7 +481,8 @@ mod tests { assert!(is_password_hash(sha256)); assert!(sha256_crypt::verify("test", sha256)); - let sha256_rounds = "$5$rounds=11858$WH1ABM5sKhxbkgCK$aTQsjPkz0rBsH3lQlJxw9HDTDXPKBxC0LlVeV69P.t1"; + let sha256_rounds = + "$5$rounds=11858$WH1ABM5sKhxbkgCK$aTQsjPkz0rBsH3lQlJxw9HDTDXPKBxC0LlVeV69P.t1"; assert!(is_password_hash(sha256_rounds)); assert!(sha256_crypt::verify("test", sha256_rounds)); @@ -503,6 +493,9 @@ mod tests { let s1 = sha1_crypt::hash("hello").unwrap(); assert!(is_password_hash(&s1), "sha1_crypt not detected: {s1}"); assert!(sha1_crypt::verify("hello", &s1)); + + let bsdi = "_J9..K0AyUubDkQmPLeM"; + assert!(is_password_hash(bsdi), "bsdi_crypt not detected: {bsdi}"); } #[test] @@ -554,7 +547,9 @@ mod tests { assert!(is_password_hash(&format!("{{CRYPT}}{inner}"))); assert!(is_password_hash(&format!("{{crypt}}{inner}"))); - assert!(is_password_hash("{CRYPT}$1$5pZSV9va$azfrPr6af3Fc7dLblQXVa0")); + assert!(is_password_hash( + "{CRYPT}$1$5pZSV9va$azfrPr6af3Fc7dLblQXVa0" + )); assert!(is_password_hash("{CRYPT}abcdefghij012")); assert!(is_password_hash("{CRYPT}_J9..K0AyUubDkQmPLeM")); @@ -569,6 +564,23 @@ mod tests { let p = Pbkdf2.hash_password(b"hello", &salt).unwrap().to_string(); assert!(is_password_hash(&format!("{{PBKDF2}}{p}"))); + + let mut h = Sha1::new(); + h.update(b"hello"); + let sha_lc = b64(&h.finalize()[..]); + assert!(is_password_hash(&format!("{{sha}}{sha_lc}"))); + + let mut h = Sha256::new(); + h.update(b"hello"); + h.update(b"saltbytes"); + let mut buf = h.finalize().to_vec(); + buf.extend_from_slice(b"saltbytes"); + let ssha256_lc = b64(&buf); + assert!(is_password_hash(&format!("{{ssha256}}{ssha256_lc}"))); + + let digest = md5::compute(b"hello"); + let md5_mc = b64(&digest[..]); + assert!(is_password_hash(&format!("{{Md5}}{md5_mc}"))); } #[test] @@ -619,7 +631,6 @@ mod tests { "{CRYPT}toolongtobeunixcryptbutshortbsdi", "{ARGON2ID}notaphcstring", "_short", - "_J9..K0AyUubDkQmPLeM", "_notvalidbsdi", "regular_password", "1234567890123", diff --git a/crates/jmap-proto/src/method/copy.rs b/crates/jmap-proto/src/method/copy.rs index de3e7610..7a01cb4f 100644 --- a/crates/jmap-proto/src/method/copy.rs +++ b/crates/jmap-proto/src/method/copy.rs @@ -28,6 +28,7 @@ pub struct CopyRequest<'x, T: JmapObject> { pub create: VecMap, Value<'x, T::Property, T::Element>>, pub on_success_destroy_original: Option, pub destroy_from_if_in_state: Option, + pub arguments: T::CopyArguments, } #[derive(Debug, Clone, serde::Serialize)] @@ -105,7 +106,7 @@ impl<'de, T: JmapObject> DeserializeArguments<'de> for CopyRequest<'de, T> { self.destroy_from_if_in_state = map.next_value()?; }, _ => { - let _ = map.next_value::()?; + self.arguments.deserialize_argument(key, map)?; } ); @@ -165,6 +166,7 @@ impl<'de, T: JmapObject> Default for CopyRequest<'de, T> { create: VecMap::new(), on_success_destroy_original: None, destroy_from_if_in_state: None, + arguments: T::CopyArguments::default(), } } } diff --git a/crates/jmap-proto/src/object/file_node.rs b/crates/jmap-proto/src/object/file_node.rs index 936e2a60..b492a34d 100644 --- a/crates/jmap-proto/src/object/file_node.rs +++ b/crates/jmap-proto/src/object/file_node.rs @@ -302,10 +302,36 @@ impl FileNodeProperty { #[derive(Debug, Clone, Default)] pub struct FileNodeSetArguments { pub on_destroy_remove_children: Option, - pub on_exists: Option, + pub on_exists: OnExists, pub compare_case_insensitively: Option, } +pub type FileNodeCopyArguments = FileNodeSetArguments; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum OnExists { + #[default] + Reject, + Replace, + Rename, + Newest, +} + +impl<'de> serde::Deserialize<'de> for OnExists { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value: Option> = Option::deserialize(deserializer)?; + Ok(match value.as_deref() { + Some("replace") => OnExists::Replace, + Some("rename") => OnExists::Rename, + Some("newest") => OnExists::Newest, + _ => OnExists::Reject, + }) + } +} + impl<'x> DeserializeArguments<'x> for FileNodeSetArguments { fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> where @@ -396,7 +422,7 @@ impl JmapObject for FileNode { type QueryArguments = FileNodeQueryArguments; - type CopyArguments = (); + type CopyArguments = FileNodeCopyArguments; type ParseArguments = (); diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index 54c7e99e..e5884be6 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -109,6 +109,9 @@ impl Response<'_> { CopyRequestMethod::ContactCard(request) => { request.resolve_references(self, 1, false)? } + CopyRequestMethod::FileNode(request) => { + request.resolve_references(self, 1, false)? + } CopyRequestMethod::Blob(_) => (), }, RequestMethod::ImportEmail(request) => request.resolve_references(self)?, diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index a1193453..6a43dfb6 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -159,6 +159,7 @@ impl MethodName { (MethodFunction::Query, MethodObject::FileNode) => "FileNode/query", (MethodFunction::QueryChanges, MethodObject::FileNode) => "FileNode/queryChanges", (MethodFunction::Set, MethodObject::FileNode) => "FileNode/set", + (MethodFunction::Copy, MethodObject::FileNode) => "FileNode/copy", (MethodFunction::Get, MethodObject::ShareNotification) => "ShareNotification/get", (MethodFunction::Changes, MethodObject::ShareNotification) => { @@ -294,6 +295,7 @@ impl MethodName { "FileNode/query" => (MethodObject::FileNode, MethodFunction::Query), "FileNode/queryChanges" => (MethodObject::FileNode, MethodFunction::QueryChanges), "FileNode/set" => (MethodObject::FileNode, MethodFunction::Set), + "FileNode/copy" => (MethodObject::FileNode, MethodFunction::Copy), "ShareNotification/get" => (MethodObject::ShareNotification, MethodFunction::Get), "ShareNotification/changes" => (MethodObject::ShareNotification, MethodFunction::Changes), diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index f894e3dd..d2db6915 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -125,6 +125,7 @@ pub enum CopyRequestMethod<'x> { Email(Box>), ContactCard(Box>), CalendarEvent(Box>), + FileNode(Box>), Blob(Box), } diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 49987b8f..b7603621 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -608,6 +608,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Copy, MethodObject::FileNode) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Copy(CopyRequestMethod::FileNode(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Lookup, MethodObject::Blob) => match seq.next_element() { Ok(Some(value)) => RequestMethod::LookupBlob(value), Err(err) => RequestMethod::invalid(err), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index a8b87636..670fe425 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -145,6 +145,7 @@ pub enum CopyResponseMethod { Email(CopyResponse), ContactCard(CopyResponse), CalendarEvent(CopyResponse), + FileNode(CopyResponse), Blob(CopyBlobResponse), } @@ -592,6 +593,12 @@ impl From> for ResponseMethod<'_> { } } +impl From> for ResponseMethod<'_> { + fn from(value: CopyResponse) -> Self { + ResponseMethod::Copy(CopyResponseMethod::FileNode(value)) + } +} + impl From for ResponseMethod<'_> { fn from(value: CalendarEventNotificationGetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::CalendarEventNotification(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index f86a5157..043411c9 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -245,6 +245,7 @@ impl JmapAuthorization for AccessToken { CopyRequestMethod::Blob(_) => Permission::JmapBlobCopy, CopyRequestMethod::ContactCard(_) => Permission::JmapContactCardCopy, CopyRequestMethod::CalendarEvent(_) => Permission::JmapCalendarEventCopy, + CopyRequestMethod::FileNode(_) => Permission::JmapFileNodeCopy, }, RequestMethod::ImportEmail(_) => Permission::JmapEmailImport, RequestMethod::Parse(m) => match &m { diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index f391b9ef..7badbc0f 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -26,7 +26,7 @@ use crate::{ copy::JmapEmailCopy, get::EmailGet, import::EmailImport, parse::EmailParse, query::EmailQuery, set::EmailSet, snippet::EmailSearchSnippet, }, - file::{get::FileNodeGet, query::FileNodeQuery, set::FileNodeSet}, + file::{copy::FileNodeCopy, get::FileNodeGet, query::FileNodeQuery, set::FileNodeSet}, identity::{get::IdentityGet, set::IdentitySet}, mailbox::{get::MailboxGet, query::MailboxQuery, set::MailboxSet}, participant_identity::{get::ParticipantIdentityGet, set::ParticipantIdentitySet}, @@ -615,6 +615,18 @@ impl RequestHandler for Server { .await? .into() } + CopyRequestMethod::FileNode(mut req) => { + set_account_id_if_missing(&mut req.from_account_id, access_token); + set_account_id_if_missing(&mut req.account_id, access_token); + + access_token + .assert_has_access(req.account_id, Collection::FileNode)? + .assert_has_access(req.from_account_id, Collection::FileNode)?; + + self.file_node_copy(*req, access_token, next_call, session) + .await? + .into() + } }, RequestMethod::ImportEmail(mut req) => { set_account_id_if_missing(&mut req.account_id, access_token); diff --git a/crates/jmap/src/file/copy.rs b/crates/jmap/src/file/copy.rs new file mode 100644 index 00000000..e22d19ee --- /dev/null +++ b/crates/jmap/src/file/copy.rs @@ -0,0 +1,460 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + api::acl::JmapAcl, + blob::download::BlobDownload, + changes::state::JmapCacheState, + file::set::{ + Collision, NoResolver, fetch_existing_modified, find_sibling_collision, pick_unique_rename, + update_file_node, validate_file_node_hierarchy, + }, +}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use groupware::{cache::GroupwareCache, file::FileNode}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::SetError, + method::{ + copy::{CopyRequest, CopyResponse}, + set::SetRequest, + }, + object::file_node::{self, FileNodeProperty, OnExists}, + request::{ + Call, IntoValid, MaybeInvalid, RequestMethod, SetRequestMethod, + method::{MethodFunction, MethodName, MethodObject}, + reference::MaybeResultReference, + }, + types::state::State, +}; +use store::{ + ValueKey, + ahash::{AHashMap, AHashSet}, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, BatchBuilder, now}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; +use utils::map::vec_map::VecMap; + +pub trait FileNodeCopy: Sync + Send { + fn file_node_copy<'x>( + &self, + request: CopyRequest<'x, file_node::FileNode>, + access_token: &AccessToken, + next_call: &mut Option>>, + session: &HttpSessionData, + ) -> impl Future>> + Send; +} + +impl FileNodeCopy for Server { + async fn file_node_copy<'x>( + &self, + request: CopyRequest<'x, file_node::FileNode>, + access_token: &AccessToken, + next_call: &mut Option>>, + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let from_account_id = request.from_account_id.document_id(); + + if account_id == from_account_id { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("From accountId is equal to fromAccountId")); + } + + let cache = self + .fetch_dav_resources(access_token.account_id(), account_id, SyncCollection::FileNode) + .await + .caused_by(trc::location!())?; + let old_state = cache.assert_state(false, &request.if_in_state)?; + let mut response = CopyResponse { + from_account_id: request.from_account_id, + account_id: request.account_id, + new_state: old_state.clone(), + old_state, + created: VecMap::with_capacity(request.create.len()), + not_created: VecMap::new(), + }; + + let from_cache = self + .fetch_dav_resources( + access_token.account_id(), + from_account_id, + SyncCollection::FileNode, + ) + .await + .caused_by(trc::location!())?; + let from_node_ids = if access_token.is_member(from_account_id) { + from_cache + .resources + .iter() + .map(|r| r.document_id) + .collect::() + } else { + let mut readable = + from_cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true); + readable |= from_cache.shared_items(access_token, [Acl::ReadItems], true); + readable + }; + + let is_shared = access_token.is_shared(account_id); + let can_add_to = if is_shared { + Some(cache.shared_containers(access_token, [Acl::AddItems], true)) + } else { + None + }; + let on_exists = request.arguments.on_exists; + let case_insensitive = request + .arguments + .compare_case_insensitively + .unwrap_or(false); + let on_destroy_remove_children = request + .arguments + .on_destroy_remove_children + .unwrap_or(false); + let on_success_delete = request.on_success_destroy_original.unwrap_or(false); + + let mut batch = BatchBuilder::new(); + let mut pending_names: AHashMap<(u32, String), Option> = AHashMap::new(); + let mut implicit_destroys: AHashSet = AHashSet::new(); + let mut created_folders = AHashMap::new(); + let mut destroy_ids = Vec::new(); + + 'create: for (id, create) in request.create.into_valid() { + let from_document_id = id.document_id(); + if !from_node_ids.contains(from_document_id) { + response.not_created.append( + id, + SetError::not_found().with_description(format!( + "Item {} not found in account {}.", + id, response.from_account_id + )), + ); + continue; + } + + let Some(source) = self + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::FileNode, + from_document_id, + )) + .await + .caused_by(trc::location!())? + else { + response.not_created.append( + id, + SetError::not_found().with_description(format!( + "Item {} not found in account {}.", + id, response.from_account_id + )), + ); + continue; + }; + + let mut file_node = source + .deserialize::() + .caused_by(trc::location!())?; + // ACLs are account-scoped; do not carry the source account's grants over. + file_node.acls.clear(); + + let has_acl_changes = match update_file_node(create, &mut file_node, true, &NoResolver) { + Ok(result) => { + if let Some(blob_id) = result.blob_id { + let file_details = file_node.file.get_or_insert_default(); + if !self.has_access_blob(&blob_id, access_token).await? { + response.not_created.append( + id, + SetError::forbidden().with_description(format!( + "You do not have access to blobId {blob_id}." + )), + ); + continue 'create; + } else if let Some(blob_contents) = self + .blob_store() + .get_blob(blob_id.hash.as_slice(), 0..usize::MAX) + .await? + { + file_details.size = blob_contents.len() as u32; + } else { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(FileNodeProperty::BlobId) + .with_description("Blob could not be found."), + ); + continue 'create; + } + file_details.blob_hash = blob_id.hash; + } + + if file_node + .file + .as_ref() + .is_some_and(|f| f.blob_hash.is_empty()) + { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(FileNodeProperty::BlobId) + .with_description("Missing blob id."), + ); + continue 'create; + } + + result.has_acl_changes + } + Err(err) => { + response.not_created.append(id, err); + continue 'create; + } + }; + + if let Err(err) = + validate_file_node_hierarchy(None, &file_node, is_shared, &cache, &created_folders) + { + response.not_created.append(id, err); + continue 'create; + } + + if file_node.modified == 0 { + file_node.modified = now() as i64; + } + + let renamed = match find_sibling_collision( + None, + &file_node, + &cache, + &pending_names, + case_insensitive, + ) { + Collision::None => false, + Collision::Existing(existing) => { + let effective = match on_exists { + OnExists::Newest => { + let existing_modified = + fetch_existing_modified(self.store(), account_id, existing).await?; + if file_node.modified > existing_modified { + OnExists::Replace + } else { + response.not_created.append( + id, + SetError::already_exists() + .with_existing_id(types::id::Id::from(existing)), + ); + continue 'create; + } + } + other => other, + }; + match effective { + OnExists::Reject => { + response.not_created.append( + id, + SetError::already_exists() + .with_existing_id(types::id::Id::from(existing)), + ); + continue 'create; + } + OnExists::Rename => { + file_node.name = pick_unique_rename( + &file_node.name, + None, + file_node.parent_id, + &cache, + &pending_names, + case_insensitive, + ); + true + } + OnExists::Replace => { + if let Some(target) = cache.any_resource_path_by_id(existing) { + let subtree_len = cache.subtree(target.path()).count(); + if subtree_len > 1 && !on_destroy_remove_children { + response + .not_created + .append(id, SetError::node_has_children()); + continue 'create; + } + } + implicit_destroys.insert(existing); + false + } + OnExists::Newest => unreachable!(), + } + } + Collision::Pending => match on_exists { + OnExists::Rename => { + file_node.name = pick_unique_rename( + &file_node.name, + None, + file_node.parent_id, + &cache, + &pending_names, + case_insensitive, + ); + true + } + OnExists::Reject | OnExists::Replace | OnExists::Newest => { + let key = crate::file::set::pending_key(&file_node, case_insensitive); + let mut err = SetError::already_exists(); + if let Some(Some(doc_id)) = pending_names.get(&key) { + err = err.with_existing_id(types::id::Id::from(*doc_id)); + } + response.not_created.append(id, err); + continue 'create; + } + }, + }; + + // Permission and ACL inheritance for the destination parent + if file_node.parent_id > 0 { + let parent_id = file_node.parent_id - 1; + + // The user must be allowed to add children to the destination parent + if let Some(allowed) = &can_add_to + && !created_folders.contains_key(&parent_id) + && !allowed.contains(parent_id) + { + response.not_created.append( + id, + SetError::forbidden().with_description( + "You are not allowed to create file nodes in this folder.", + ), + ); + continue 'create; + } + + let parent_acls = created_folders.get(&parent_id).cloned().or_else(|| { + cache + .container_resource_by_id(parent_id) + .and_then(|r| r.acls()) + .map(|a| a.to_vec()) + }); + if !has_acl_changes { + if let Some(parent_acls) = parent_acls { + file_node.acls = parent_acls; + } + } else if is_shared + && parent_acls + .is_none_or(|acls| !acls.effective_acl(access_token).contains(Acl::Share)) + { + response.not_created.append( + id, + SetError::forbidden() + .with_description("You are not allowed to share this file node."), + ); + continue 'create; + } + } else if is_shared { + response.not_created.append( + id, + SetError::forbidden() + .with_description("Cannot create top-level folder in a shared account."), + ); + continue 'create; + } + + if !file_node.acls.is_empty() { + if let Err(err) = self.acl_validate(&file_node.acls).await { + response.not_created.append(id, err.into()); + continue 'create; + } + self.refresh_acls(&file_node.acls, None) + .await + .caused_by(trc::location!())?; + } + + let document_id = self + .store() + .assign_document_ids(account_id, Collection::FileNode, 1) + .await + .caused_by(trc::location!())?; + if file_node.file.is_none() { + created_folders.insert(document_id, file_node.acls.clone()); + } + pending_names.insert( + crate::file::set::pending_key(&file_node, case_insensitive), + None, + ); + let final_name = file_node.name.clone(); + file_node + .insert( + access_token.account_tenant_ids(), + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + response.created(id, document_id); + if renamed + && let Some(value) = response.created.get_mut(&id) + && let jmap_tools::Value::Object(map) = value + { + map.insert_unchecked( + jmap_tools::Key::Property(FileNodeProperty::Name), + jmap_tools::Value::Str(std::borrow::Cow::Owned(final_name)), + ); + } + + if on_success_delete { + destroy_ids.push(MaybeInvalid::Value(id)); + } + } + + for did in &implicit_destroys { + let Some(node) = cache.any_resource_path_by_id(*did) else { + continue; + }; + let mut ids = cache.subtree(node.path()).collect::>(); + ids.sort_unstable_by_key(|b| std::cmp::Reverse(b.hierarchy_seq())); + let sorted = ids.into_iter().map(|a| a.document_id()).collect::>(); + groupware::DestroyArchive(sorted) + .delete_batch( + self, + access_token.account_tenant_ids(), + account_id, + cache.format_resource(node).into(), + &mut batch, + ) + .await + .caused_by(trc::location!())?; + } + + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + response.new_state = State::Exact(change_id); + } + + if on_success_delete && !destroy_ids.is_empty() { + *next_call = Call { + id: String::new(), + name: MethodName::new(MethodObject::FileNode, MethodFunction::Set), + method: RequestMethod::Set(SetRequestMethod::FileNode(Box::new(SetRequest { + account_id: request.from_account_id, + if_in_state: request.destroy_from_if_in_state, + create: None, + update: None, + destroy: MaybeResultReference::Value(destroy_ids).into(), + arguments: Default::default(), + }))), + } + .into(); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/file/mod.rs b/crates/jmap/src/file/mod.rs index c036acc9..a8757a97 100644 --- a/crates/jmap/src/file/mod.rs +++ b/crates/jmap/src/file/mod.rs @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +pub mod copy; pub mod get; pub mod query; pub mod set; diff --git a/crates/jmap/src/file/set.rs b/crates/jmap/src/file/set.rs index 9823e02a..34485030 100644 --- a/crates/jmap/src/file/set.rs +++ b/crates/jmap/src/file/set.rs @@ -14,7 +14,10 @@ use http_proto::HttpSessionData; use jmap_proto::{ error::set::SetError, method::set::{SetRequest, SetResponse}, - object::file_node::{self, FileNodeProperty, FileNodeValue}, + object::{ + AnyId, + file_node::{self, FileNodeProperty, FileNodeValue, OnExists}, + }, references::resolve::ResolveCreatedReference, request::IntoValid, types::state::State, @@ -71,16 +74,12 @@ impl FileNodeSet for Server { .arguments .on_destroy_remove_children .unwrap_or(false); - let on_exists = match request.arguments.on_exists.as_deref() { - Some("replace") => OnExists::Replace, - Some("rename") => OnExists::Rename, - _ => OnExists::Reject, - }; + let on_exists = request.arguments.on_exists; let case_insensitive = request .arguments .compare_case_insensitively .unwrap_or(false); - let mut pending_names: AHashSet<(u32, String)> = AHashSet::new(); + let mut pending_names: AHashMap<(u32, String), Option> = AHashMap::new(); let mut implicit_destroys: AHashSet = AHashSet::new(); // Process creates @@ -90,7 +89,7 @@ impl FileNodeSet for Server { let mut file_node = FileNode::default(); // Process changes - let has_acl_changes = match update_file_node(object, &mut file_node, &mut response) { + let has_acl_changes = match update_file_node(object, &mut file_node, true, &response) { Ok(result) => { if let Some(blob_id) = result.blob_id { let file_details = file_node.file.get_or_insert_default(); @@ -152,6 +151,10 @@ impl FileNodeSet for Server { continue 'create; } + if file_node.modified == 0 { + file_node.modified = now() as i64; + } + let renamed = match find_sibling_collision( None, &file_node, @@ -160,42 +163,71 @@ impl FileNodeSet for Server { case_insensitive, ) { Collision::None => false, - Collision::Existing(existing) => match on_exists { - OnExists::Reject => { - response.not_created.append( - id, - SetError::already_exists().with_existing_id(Id::from(existing)), - ); - continue 'create; - } - OnExists::Rename => { - file_node.name = pick_unique_rename( - &file_node.name, - None, - file_node.parent_id, - &cache, - &pending_names, - case_insensitive, - ); - true - } - OnExists::Replace => { - if let Some(target) = cache.any_resource_path_by_id(existing) { - let subtree_len = cache.subtree(target.path()).count(); - if subtree_len > 1 && !on_destroy_remove_children { - response - .not_created - .append(id, SetError::node_has_children()); + Collision::Existing(existing) => { + let effective = match on_exists { + OnExists::Newest => { + let existing_modified = fetch_existing_modified( + self.store(), + account_id, + existing, + ) + .await?; + if file_node.modified > existing_modified { + OnExists::Replace + } else { + response.not_created.append( + id, + SetError::already_exists() + .with_existing_id(Id::from(existing)), + ); continue 'create; } } - implicit_destroys.insert(existing); - false + other => other, + }; + match effective { + OnExists::Reject => { + response.not_created.append( + id, + SetError::already_exists().with_existing_id(Id::from(existing)), + ); + continue 'create; + } + OnExists::Rename => { + file_node.name = pick_unique_rename( + &file_node.name, + None, + file_node.parent_id, + &cache, + &pending_names, + case_insensitive, + ); + true + } + OnExists::Replace => { + if let Some(target) = cache.any_resource_path_by_id(existing) { + let subtree_len = cache.subtree(target.path()).count(); + if subtree_len > 1 && !on_destroy_remove_children { + response + .not_created + .append(id, SetError::node_has_children()); + continue 'create; + } + } + implicit_destroys.insert(existing); + false + } + OnExists::Newest => unreachable!(), } - }, + } Collision::Pending => match on_exists { - OnExists::Reject => { - response.not_created.append(id, SetError::already_exists()); + OnExists::Reject | OnExists::Replace | OnExists::Newest => { + let key = pending_key(&file_node, case_insensitive); + let mut err = SetError::already_exists(); + if let Some(Some(doc_id)) = pending_names.get(&key) { + err = err.with_existing_id(Id::from(*doc_id)); + } + response.not_created.append(id, err); continue 'create; } OnExists::Rename => { @@ -209,11 +241,6 @@ impl FileNodeSet for Server { ); true } - // TODO: support onExists=replace for within-batch pending collisions - OnExists::Replace => { - response.not_created.append(id, SetError::already_exists()); - continue 'create; - } }, }; @@ -265,7 +292,7 @@ impl FileNodeSet for Server { created_folders.insert(document_id, file_node.acls.clone()); } let final_name = file_node.name.clone(); - pending_names.insert(pending_key(&file_node, case_insensitive)); + pending_names.insert(pending_key(&file_node, case_insensitive), None); file_node .insert( access_token.account_tenant_ids(), @@ -316,7 +343,7 @@ impl FileNodeSet for Server { .caused_by(trc::location!())?; // Apply changes - let has_acl_changes = match update_file_node(object, &mut new_file_node, &mut response) + let has_acl_changes = match update_file_node(object, &mut new_file_node, false, &response) { Ok(result) => { if let Some(blob_id) = result.blob_id { @@ -376,42 +403,71 @@ impl FileNodeSet for Server { case_insensitive, ) { Collision::None => false, - Collision::Existing(existing) => match on_exists { - OnExists::Reject => { - response.not_updated.append( - id, - SetError::already_exists().with_existing_id(Id::from(existing)), - ); - continue 'update; - } - OnExists::Rename => { - new_file_node.name = pick_unique_rename( - &new_file_node.name, - Some(document_id), - new_file_node.parent_id, - &cache, - &pending_names, - case_insensitive, - ); - true - } - OnExists::Replace => { - if let Some(target) = cache.any_resource_path_by_id(existing) { - let subtree_len = cache.subtree(target.path()).count(); - if subtree_len > 1 && !on_destroy_remove_children { - response - .not_updated - .append(id, SetError::node_has_children()); + Collision::Existing(existing) => { + let effective = match on_exists { + OnExists::Newest => { + let existing_modified = fetch_existing_modified( + self.store(), + account_id, + existing, + ) + .await?; + if new_file_node.modified > existing_modified { + OnExists::Replace + } else { + response.not_updated.append( + id, + SetError::already_exists() + .with_existing_id(Id::from(existing)), + ); continue 'update; } } - implicit_destroys.insert(existing); - false + other => other, + }; + match effective { + OnExists::Reject => { + response.not_updated.append( + id, + SetError::already_exists().with_existing_id(Id::from(existing)), + ); + continue 'update; + } + OnExists::Rename => { + new_file_node.name = pick_unique_rename( + &new_file_node.name, + Some(document_id), + new_file_node.parent_id, + &cache, + &pending_names, + case_insensitive, + ); + true + } + OnExists::Replace => { + if let Some(target) = cache.any_resource_path_by_id(existing) { + let subtree_len = cache.subtree(target.path()).count(); + if subtree_len > 1 && !on_destroy_remove_children { + response + .not_updated + .append(id, SetError::node_has_children()); + continue 'update; + } + } + implicit_destroys.insert(existing); + false + } + OnExists::Newest => unreachable!(), } - }, + } Collision::Pending => match on_exists { - OnExists::Reject => { - response.not_updated.append(id, SetError::already_exists()); + OnExists::Reject | OnExists::Replace | OnExists::Newest => { + let key = pending_key(&new_file_node, case_insensitive); + let mut err = SetError::already_exists(); + if let Some(Some(doc_id)) = pending_names.get(&key) { + err = err.with_existing_id(Id::from(*doc_id)); + } + response.not_updated.append(id, err); continue 'update; } OnExists::Rename => { @@ -425,10 +481,6 @@ impl FileNodeSet for Server { ); true } - OnExists::Replace => { - response.not_updated.append(id, SetError::already_exists()); - continue 'update; - } }, }; @@ -466,7 +518,10 @@ impl FileNodeSet for Server { } let final_name = new_file_node.name.clone(); - pending_names.insert(pending_key(&new_file_node, case_insensitive)); + pending_names.insert( + pending_key(&new_file_node, case_insensitive), + Some(document_id), + ); // Update record new_file_node .update( @@ -585,15 +640,24 @@ impl FileNodeSet for Server { } } -struct UpdateResult { - has_acl_changes: bool, - blob_id: Option, +pub(super) struct UpdateResult { + pub(super) has_acl_changes: bool, + pub(super) blob_id: Option, } -fn update_file_node( +pub(super) struct NoResolver; + +impl ResolveCreatedReference for NoResolver { + fn get_created_id(&self, _: &str) -> Option { + None + } +} + +pub(super) fn update_file_node>( updates: Value<'_, FileNodeProperty, FileNodeValue>, file_node: &mut FileNode, - response: &mut SetResponse, + is_create: bool, + resolver: &R, ) -> Result> { let mut has_acl_changes = false; let mut blob_id = None; @@ -605,7 +669,7 @@ fn update_file_node( .with_description("Invalid property.")); }; - response.resolve_self_references(&mut value, 0, false)?; + resolver.resolve_self_references(&mut value, 0, false)?; match (property, value) { (FileNodeProperty::Name, Value::Str(value)) @@ -663,12 +727,19 @@ fn update_file_node( } // TODO: persist accessed per-user (draft-13 section 3.1) (FileNodeProperty::Accessed, _) => {} - // TODO: store nodeType explicitly and validate immutability after create - (FileNodeProperty::NodeType, _) => {} + (FileNodeProperty::NodeType, _) if is_create => {} + (FileNodeProperty::NodeType, _) => { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::NodeType) + .with_description("nodeType is immutable after creation.")); + } // TODO: implement symlink target storage and resolution (FileNodeProperty::Target, _) => {} - // TODO: server-set changed timestamp on every mutation - (FileNodeProperty::Changed, _) => {} + (FileNodeProperty::Changed, _) => { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::Changed) + .with_description("changed is server-set and not settable by clients.")); + } // TODO: store and validate FileNode role for directories (FileNodeProperty::Role, _) => {} (FileNodeProperty::ShareWith, value) => { @@ -714,7 +785,7 @@ fn update_file_node( }) } -fn validate_file_node_hierarchy( +pub(super) fn validate_file_node_hierarchy( document_id: Option, node: &FileNode, is_shared: bool, @@ -763,20 +834,33 @@ fn validate_file_node_hierarchy( } #[derive(Copy, Clone, PartialEq, Eq)] -enum OnExists { - Reject, - Rename, - Replace, -} - -#[derive(Copy, Clone, PartialEq, Eq)] -enum Collision { +pub(super) enum Collision { None, Existing(u32), Pending, } -fn names_equal(a: &str, b: &str, case_insensitive: bool) -> bool { +pub(super) async fn fetch_existing_modified( + store: &store::Store, + account_id: u32, + document_id: u32, +) -> trc::Result { + Ok(store + .get_value::>(ValueKey::archive( + account_id, + Collection::FileNode, + document_id, + )) + .await? + .map(|arch| { + arch.unarchive::() + .map(|node| node.modified.to_native()) + .unwrap_or(0) + }) + .unwrap_or(0)) +} + +pub(super) fn names_equal(a: &str, b: &str, case_insensitive: bool) -> bool { if case_insensitive { a.eq_ignore_ascii_case(b) } else { @@ -784,7 +868,7 @@ fn names_equal(a: &str, b: &str, case_insensitive: bool) -> bool { } } -fn pending_key(node: &FileNode, case_insensitive: bool) -> (u32, String) { +pub(super) fn pending_key(node: &FileNode, case_insensitive: bool) -> (u32, String) { ( node.parent_id, if case_insensitive { @@ -795,11 +879,11 @@ fn pending_key(node: &FileNode, case_insensitive: bool) -> (u32, String) { ) } -fn find_sibling_collision( +pub(super) fn find_sibling_collision( document_id: Option, node: &FileNode, cache: &DavResources, - pending: &AHashSet<(u32, String)>, + pending: &AHashMap<(u32, String), Option>, case_insensitive: bool, ) -> Collision { let node_parent_id = if node.parent_id == 0 { @@ -818,18 +902,18 @@ fn find_sibling_collision( return Collision::Existing(resource.document_id); } } - if pending.contains(&pending_key(node, case_insensitive)) { + if pending.contains_key(&pending_key(node, case_insensitive)) { return Collision::Pending; } Collision::None } -fn pick_unique_rename( +pub(super) fn pick_unique_rename( base: &str, document_id: Option, parent_id: u32, cache: &DavResources, - pending: &AHashSet<(u32, String)>, + pending: &AHashMap<(u32, String), Option>, case_insensitive: bool, ) -> String { let (stem, ext) = match base.rfind('.') { diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index c622de7d..28f2743c 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1453,6 +1453,7 @@ pub enum Permission { JmapFileNodeCreate = 92, JmapFileNodeUpdate = 93, JmapFileNodeDestroy = 94, + JmapFileNodeCopy = 659, JmapShareNotificationGet = 95, JmapShareNotificationChanges = 96, JmapShareNotificationQuery = 97, diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index 9612f457..f6ce64aa 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -6392,6 +6392,7 @@ impl EnumImpl for Permission { b"jmapFileNodeCreate" => Permission::JmapFileNodeCreate, b"jmapFileNodeUpdate" => Permission::JmapFileNodeUpdate, b"jmapFileNodeDestroy" => Permission::JmapFileNodeDestroy, + b"jmapFileNodeCopy" => Permission::JmapFileNodeCopy, b"jmapShareNotificationGet" => Permission::JmapShareNotificationGet, b"jmapShareNotificationChanges" => Permission::JmapShareNotificationChanges, b"jmapShareNotificationQuery" => Permission::JmapShareNotificationQuery, @@ -7057,6 +7058,7 @@ impl EnumImpl for Permission { Permission::JmapFileNodeCreate => "jmapFileNodeCreate", Permission::JmapFileNodeUpdate => "jmapFileNodeUpdate", Permission::JmapFileNodeDestroy => "jmapFileNodeDestroy", + Permission::JmapFileNodeCopy => "jmapFileNodeCopy", Permission::JmapShareNotificationGet => "jmapShareNotificationGet", Permission::JmapShareNotificationChanges => "jmapShareNotificationChanges", Permission::JmapShareNotificationQuery => "jmapShareNotificationQuery", @@ -7735,6 +7737,7 @@ impl EnumImpl for Permission { 92 => Some(Permission::JmapFileNodeCreate), 93 => Some(Permission::JmapFileNodeUpdate), 94 => Some(Permission::JmapFileNodeDestroy), + 659 => Some(Permission::JmapFileNodeCopy), 95 => Some(Permission::JmapShareNotificationGet), 96 => Some(Permission::JmapShareNotificationChanges), 97 => Some(Permission::JmapShareNotificationQuery), @@ -8303,7 +8306,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 659; + const COUNT: usize = 660; } impl serde::Serialize for Permission { diff --git a/crates/registry/src/schema/properties_impl.rs b/crates/registry/src/schema/properties_impl.rs index 0a81e9e7..a114fc73 100644 --- a/crates/registry/src/schema/properties_impl.rs +++ b/crates/registry/src/schema/properties_impl.rs @@ -3050,7 +3050,7 @@ impl EnumImpl for Property { } } - const COUNT: usize = 100; + const COUNT: usize = 875; } impl serde::Serialize for Property { diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index ac64b903..32683f3a 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index ab43e9c0..959acc96 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -2-nY_zZI8RUaBuk0LUqvnkfBeJgPdViYvXF3xHDF7cI \ No newline at end of file +YT3bugHVH3b13x2txy17kankEO0OqL9os_sojzqXlxo \ No newline at end of file diff --git a/tests/src/jmap/files/acl.rs b/tests/src/jmap/files/acl.rs index b74f027e..3df14bcc 100644 --- a/tests/src/jmap/files/acl.rs +++ b/tests/src/jmap/files/acl.rs @@ -481,7 +481,87 @@ pub async fn test(test: &TestServer) { .await .updated(&john_folder_id); - // Verify Jane can delete the folder + // FileNode/copy: Jane copies a node from her own account into John's shared folder + let jane_folder_id = jane + .jmap_create( + MethodObject::FileNode, + [json!({"name": "jane-src"})], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let copied = jane + .jmap_copy( + jane, + john, + MethodObject::FileNode, + [( + &jane_folder_id, + json!({ "parentId": &john_folder_id, "name": "copied-here" }), + )], + false, + ) + .await; + let copied_id = copied.copied(&jane_folder_id).id().to_string(); + assert_ne!(copied_id, jane_folder_id); + jane.jmap_get_account( + john, + MethodObject::FileNode, + [ + FileNodeProperty::Id, + FileNodeProperty::Name, + FileNodeProperty::ParentId, + ], + [copied_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &copied_id, + "name": "copied-here", + "parentId": &john_folder_id, + })); + // Original still exists in Jane's account (onSuccessDestroyOriginal=false) + jane.jmap_get( + MethodObject::FileNode, + [FileNodeProperty::Id], + [jane_folder_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ "id": &jane_folder_id })); + + // onExists=rename on copy: colliding into John's folder again must echo the new name + let renamed_copy = jane + .jmap_method_calls(json!([[ + "FileNode/copy", + { + "fromAccountId": jane.id_string(), + "accountId": john.id_string(), + "onExists": "rename", + "create": { + &jane_folder_id: { "parentId": &john_folder_id, "name": "copied-here" } + } + }, + "0" + ]])) + .await; + let renamed_entry = renamed_copy.copied(&jane_folder_id); + let renamed_copy_id = renamed_entry.id().to_string(); + assert_eq!(renamed_entry.text_field("name"), "copied-here (2)"); + + jane.jmap_destroy( + MethodObject::FileNode, + [&jane_folder_id], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // Verify Jane can delete the folder (and the node copied into it) assert_eq!( jane.jmap_destroy_account( john, @@ -491,8 +571,14 @@ pub async fn test(test: &TestServer) { ) .await .destroyed() - .collect::>(), - [john_folder_id.as_str()] + .collect::>(), + [ + john_folder_id.as_str(), + copied_id.as_str(), + renamed_copy_id.as_str() + ] + .into_iter() + .collect::>() ); // Destroy all mailboxes diff --git a/tests/src/jmap/files/node.rs b/tests/src/jmap/files/node.rs index 19267b90..c7a44822 100644 --- a/tests/src/jmap/files/node.rs +++ b/tests/src/jmap/files/node.rs @@ -468,6 +468,216 @@ pub async fn test(test: &TestServer) { .destroyed() .for_each(drop); + // Pending+Reject: two creates with the same name in one batch, default onExists + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({"name": "twin-reject"}), + json!({"name": "twin-reject"}), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let twin_first = response.created(0).id().to_string(); + let err = response.not_created(1); + assert_eq!(err.typ(), "alreadyExists"); + assert!( + err.pointer("/existingId").is_none(), + "Pending Create collision has no committed existingId, got {err:?}" + ); + account + .jmap_destroy( + MethodObject::FileNode, + [&twin_first], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // Pending+Rename: second create within the batch should auto-rename + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({"name": "twin-rename"}), + json!({"name": "twin-rename"}), + ], + [("onExists", "rename")], + ) + .await; + let twin_a = response.created(0).id().to_string(); + let twin_b_entry = response.created(1); + let twin_b = twin_b_entry.id().to_string(); + assert_eq!(twin_b_entry.text_field("name"), "twin-rename (2)"); + account + .jmap_destroy( + MethodObject::FileNode, + [&twin_a, &twin_b], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // Pending+Replace: within-batch replace is intentionally not supported; second is rejected + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({"name": "twin-replace"}), + json!({"name": "twin-replace"}), + ], + [("onExists", "replace")], + ) + .await; + let twin_survivor = response.created(0).id().to_string(); + let err = response.not_created(1); + assert_eq!(err.typ(), "alreadyExists"); + assert!( + err.pointer("/existingId").is_none(), + "Pending Create + Replace returns alreadyExists with no existingId, got {err:?}" + ); + account + .jmap_destroy( + MethodObject::FileNode, + [&twin_survivor], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // Pending+Newest: in-batch newest comparison is intentionally not supported; second is rejected + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({"name": "twin-newest", "modified": "2020-01-01T00:00:00Z"}), + json!({"name": "twin-newest", "modified": "2040-01-01T00:00:00Z"}), + ], + [("onExists", "newest")], + ) + .await; + let twin_keep = response.created(0).id().to_string(); + let err = response.not_created(1); + assert_eq!(err.typ(), "alreadyExists"); + account + .jmap_destroy( + MethodObject::FileNode, + [&twin_keep], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // Create+Update collision in one batch + let setup = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "lhs"})], + Vec::<(&str, &str)>::new(), + ) + .await; + let lhs_id = setup.created(0).id().to_string(); + let response = account + .jmap_method_calls(json!([[ + "FileNode/set", + { + "accountId": account.id_string(), + "update": { &lhs_id: { "name": "merged" } }, + "create": { "new1": { "name": "merged" } } + }, + "0" + ]])) + .await; + let created_new = response + .pointer("/methodResponses/0/1/created/new1") + .expect("new1 should be in created"); + let new1_id = created_new.id().to_string(); + let upd_err = response + .pointer(&format!("/methodResponses/0/1/notUpdated/{lhs_id}")) + .expect("update should fail"); + assert_eq!(upd_err.typ(), "alreadyExists"); + assert!( + upd_err.pointer("/existingId").is_none(), + "Pending-from-Create collision has no existingId, got {upd_err:?}" + ); + account + .jmap_destroy( + MethodObject::FileNode, + [&lhs_id, &new1_id], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // compareCaseInsensitively + Pending: in-batch "FOO"/"foo" collide when the flag is set + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "FOO"}), json!({"name": "foo"})], + [("compareCaseInsensitively", true)], + ) + .await; + let case_keep = response.created(0).id().to_string(); + assert_eq!(response.not_created(1).typ(), "alreadyExists"); + account + .jmap_destroy( + MethodObject::FileNode, + [&case_keep], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // onExists=newest: incoming must have a strictly later modified to win + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "stamped", "modified": "2030-01-01T00:00:00Z"})], + Vec::<(&str, &str)>::new(), + ) + .await; + let stamped_id = response.created(0).id().to_string(); + let older_attempt = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "stamped", "modified": "2020-01-01T00:00:00Z"})], + [("onExists", "newest")], + ) + .await; + let err = older_attempt.not_created(0); + assert_eq!(err.typ(), "alreadyExists"); + assert_eq!(err.text_field("existingId"), stamped_id.as_str()); + let newer_attempt = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "stamped", "modified": "2040-01-01T00:00:00Z"})], + [("onExists", "newest")], + ) + .await; + let stamped_winner = newer_attempt.created(0).id().to_string(); + assert_ne!(stamped_winner, stamped_id); + let destroyed = newer_attempt.destroyed().collect::>(); + assert!( + destroyed.contains(stamped_id.as_str()), + "Expected {stamped_id} to be destroyed by newer onExists=newest, got {destroyed:?}" + ); + account + .jmap_destroy( + MethodObject::FileNode, + [&stamped_winner], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + // Make sure everything is gone test.assert_is_empty().await; }