From 7790645421b50a8f76545ad9b699b92c53f4e809 Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Sat, 16 May 2026 10:44:10 +0200 Subject: [PATCH] Bump JMAP File Storage to draft-ietf-jmap-filenode-14 --- crates/directory/src/core/secret.rs | 115 +++-- crates/jmap-proto/src/method/copy.rs | 4 +- crates/jmap-proto/src/object/file_node.rs | 30 +- crates/jmap-proto/src/references/resolve.rs | 3 + crates/jmap-proto/src/request/method.rs | 2 + crates/jmap-proto/src/request/mod.rs | 1 + crates/jmap-proto/src/request/parser.rs | 7 + crates/jmap-proto/src/response/mod.rs | 7 + crates/jmap/src/api/auth.rs | 1 + crates/jmap/src/api/request.rs | 14 +- crates/jmap/src/file/copy.rs | 460 ++++++++++++++++++ crates/jmap/src/file/mod.rs | 1 + crates/jmap/src/file/set.rs | 300 ++++++++---- crates/registry/src/schema/enums.rs | 1 + crates/registry/src/schema/enums_impl.rs | 5 +- crates/registry/src/schema/properties_impl.rs | 2 +- resources/schema/schema.json.gz | Bin 138956 -> 138964 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/jmap/files/acl.rs | 92 +++- tests/src/jmap/files/node.rs | 210 ++++++++ 20 files changed, 1087 insertions(+), 170 deletions(-) create mode 100644 crates/jmap/src/file/copy.rs 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 ac64b903c80f8254e568d11cc3cff364c2c96e28..32683f3a6b93f1bd0f3134fb89f5d143d06709f4 100644 GIT binary patch delta 21313 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 delta 21305 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%0DpM=fS5~g5hxgA*gM zWjdv4IFk8_=tFUTNp9ov+ig{pPbvP{%`Cdk^Id&#Dqxe!H%)u=maK$*2#UX4smlW% zD}zBJPam*hFpJVJy0MmYs4V}VITZxfF*&B9Lvo1KfbSjtvO%9UvdoE%`p9>5*MU>p z|0su|YV2E%(K^MS>dw&f?JgE;cdE-Dvosg|3L<&Uy~3G)5jK)y{UY5z1+i>(#O(-T zmSF8cADT*92Me&dLq)efdrunYDD@fk`>|8t`ta1^J|fCXmzO|avcM-We{l^ABXm7l z7jnQ4DKpqcmj6jjjGuaDNx7zdUQz$En)eBXGnOw2fpEr`txs}TE z%n>qhUZrM#eX-*8_u@gt8_+|#iudnGOvMpwET!oZI-%pcy|wk+=UF1(UW2dgRVkBf z_YZ$-otm{*r(sqj@X^|!phM@4=_q=d?+-b?$?$Uv@ZYfb0D+f?K{qhIk$~<|S^9(a z`E2t=cZ2>Wv-eZu!>ar??JRu#n@azg{9viYIq7$QaDa0N$`Um!jOV?mC*ge+4?_{J z^|ciiVIF6K$-7Jri;=^xD|Dybby<;HI>b6n@dZ0il!%gsy(id6uAi&cUVECTm9xHj z)?UKo#Xek3qFJ)ZM5PYiZ1G*PZ>U|_K8MewzT;$1rhnHiZPrI+sK6M0QcOE?SIRA=)7(^;_(lJWuoWridv>YluzPs?|m*U=){`c;_ zM2+=bg~i*2&o8Vi{qG$Od%MJqhQJqBt0Q}V6w*@&>_L&bp3~pgG-I$t*ZIbqJn(K` zM-k?t+_|Snt8Kj64Ooz7z__kcf9^oga4zM&UQPxd8`~+e%CoaVpGseU!;vHZ>Q9)8 z>n6u%6d06o{!! z*H+i>daApPiD+e1Ub56V`&pqkISqFfXlg|CYEL0(dKdxCA<4?G{!oq-ch6cfpHH7% z7j&bvBfpIolqdBw{?jl7A@KPv7~T=;q8tpR_f2u{rT1I!+^|o41?K%;9Dc@s6gimF zXV?==|{6_>(Jxsp6qy*_qu$|0_Pe zdgXm?TxLDp4UB;U)*7%FPVutTni(!XV5bLvCz&ZkhT!)Nuj!#H@PuQsrQW z8%)^?5@ui6m=eM}q^q;jX6o{PHp9t!)zG?hL{gM(r(n_%`wMTx#PPMXme$e1PABK# zxp%zU$3sdmnUC8NfT^R32kyL-i_w6BN=GKV;pval2~M(*u9(85J-@uZ?S?qts<&PE z+WI)Z?3N$v$AkGvZhUneUEJMHxtL92a-&UyUC+^o2gl6biDe(erUw>ZEuelQptONZhjR8sTjyWXF zqY>7J&#NYZN1fyty{bUTKmbk3pCWuEUiSxoQVsRfOHRJ}SxUaXT$$Wtm? zS29RXJ1kL|xl^G_g1bw+T_bBYcmeF0_9;N_?-{45fCchC@wHTxH>hEI*7YRnM!8mR zkn#4Cc3sqJ zXIr5{nQA*to=USvPX*HO2y{@S_4ds6%l5cZ$zAKNx_fS(46pL_0HdxvWr^s#@=(MT zv*sbTWHY&c!ZRC2PV2)%ra@>be=~CADm(HfIeN0y1%nJ4q@$+W(;t1xvr7-N=Hqsq zA+ZmkHTlCT*S87|gc(t2-9E5SDo!qUW;|EX(M*pL?T#S^haDORw8)@9u3=*jqT1ZH{lg?6hH)a8nJjfPv11_EdF47e%mTp#md z9mqgK4)@W+A<|qW(-s2|rdRBgJ3b^d)3QB(RD}ZKB2yY5ii_Zt7im+8J|pJYdFz7laaKNI^y8ryX( wSn<}GInGIV4o@O1::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; }