diff --git a/crates/common/src/config/mailstore/capabilities.rs b/crates/common/src/config/mailstore/capabilities.rs index 2f3392d0..98a48351 100644 --- a/crates/common/src/config/mailstore/capabilities.rs +++ b/crates/common/src/config/mailstore/capabilities.rs @@ -137,8 +137,23 @@ impl JmapConfig { Capabilities::FileNode(FileNodeCapabilities { max_file_node_depth: None, max_size_file_node_name: 255, + forbidden_name_chars: Some("/<>:\"\\|?*".to_string()), + forbidden_node_names: Some( + [ + ".", "..", "CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", + "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT0", "LPT1", "LPT2", + "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + ] + .into_iter() + .map(str::to_string) + .collect(), + ), file_node_query_sort_options: vec![], may_create_top_level_file_node: true, + case_insensitive_names: false, + web_trash_url: None, + web_url_template: None, + web_write_url_template: None, }), ); diff --git a/crates/jmap-proto/src/object/file_node.rs b/crates/jmap-proto/src/object/file_node.rs index 9fcd833b..936e2a60 100644 --- a/crates/jmap-proto/src/object/file_node.rs +++ b/crates/jmap-proto/src/object/file_node.rs @@ -27,15 +27,18 @@ pub enum FileNodeProperty { Size, Name, Type, + NodeType, + Target, Created, Modified, Accessed, + Changed, Executable, + Role, MyRights, ShareWith, IsSubscribed, - // Other IdValue(Id), Rights(FileNodeRight), Pointer(JsonPointer), @@ -44,10 +47,33 @@ pub enum FileNodeProperty { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum FileNodeRight { MayRead, - MayWrite, + MayAddChildren, + MayRename, + MayDelete, + MayModifyContent, MayShare, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FileNodeNodeType { + File, + Directory, + Symlink, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FileNodeRole { + Root, + Home, + Temp, + Trash, + Documents, + Downloads, + Music, + Pictures, + Videos, +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum FileNodeValue { Id(Id), @@ -79,10 +105,14 @@ impl Property for FileNodeProperty { FileNodeProperty::Size => "size", FileNodeProperty::Name => "name", FileNodeProperty::Type => "type", + FileNodeProperty::NodeType => "nodeType", + FileNodeProperty::Target => "target", FileNodeProperty::Created => "created", FileNodeProperty::Modified => "modified", FileNodeProperty::Accessed => "accessed", + FileNodeProperty::Changed => "changed", FileNodeProperty::Executable => "executable", + FileNodeProperty::Role => "role", FileNodeProperty::MyRights => "myRights", FileNodeProperty::ShareWith => "shareWith", FileNodeProperty::IsSubscribed => "isSubscribed", @@ -98,12 +128,91 @@ impl FileNodeRight { pub fn as_str(&self) -> &'static str { match self { FileNodeRight::MayRead => "mayRead", - FileNodeRight::MayWrite => "mayWrite", + FileNodeRight::MayAddChildren => "mayAddChildren", + FileNodeRight::MayRename => "mayRename", + FileNodeRight::MayDelete => "mayDelete", + FileNodeRight::MayModifyContent => "mayModifyContent", FileNodeRight::MayShare => "mayShare", } } } +impl FileNodeNodeType { + pub fn as_str(&self) -> &'static str { + match self { + FileNodeNodeType::File => "file", + FileNodeNodeType::Directory => "directory", + FileNodeNodeType::Symlink => "symlink", + } + } + + pub fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"file" => FileNodeNodeType::File, + b"directory" => FileNodeNodeType::Directory, + b"symlink" => FileNodeNodeType::Symlink, + ) + } +} + +impl FromStr for FileNodeNodeType { + type Err = (); + + fn from_str(s: &str) -> Result { + FileNodeNodeType::parse(s).ok_or(()) + } +} + +impl Display for FileNodeNodeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FileNodeRole { + pub fn as_str(&self) -> &'static str { + match self { + FileNodeRole::Root => "root", + FileNodeRole::Home => "home", + FileNodeRole::Temp => "temp", + FileNodeRole::Trash => "trash", + FileNodeRole::Documents => "documents", + FileNodeRole::Downloads => "downloads", + FileNodeRole::Music => "music", + FileNodeRole::Pictures => "pictures", + FileNodeRole::Videos => "videos", + } + } + + pub fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"root" => FileNodeRole::Root, + b"home" => FileNodeRole::Home, + b"temp" => FileNodeRole::Temp, + b"trash" => FileNodeRole::Trash, + b"documents" => FileNodeRole::Documents, + b"downloads" => FileNodeRole::Downloads, + b"music" => FileNodeRole::Music, + b"pictures" => FileNodeRole::Pictures, + b"videos" => FileNodeRole::Videos, + ) + } +} + +impl FromStr for FileNodeRole { + type Err = (); + + fn from_str(s: &str) -> Result { + FileNodeRole::parse(s).ok_or(()) + } +} + +impl Display for FileNodeRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + impl Element for FileNodeValue { type Property = FileNodeProperty; @@ -122,7 +231,8 @@ impl Element for FileNodeValue { }, FileNodeProperty::Created | FileNodeProperty::Modified - | FileNodeProperty::Accessed => { + | FileNodeProperty::Accessed + | FileNodeProperty::Changed => { UTCDate::from_str(value).ok().map(FileNodeValue::Date) } _ => None, @@ -151,15 +261,22 @@ impl FileNodeProperty { b"size" => FileNodeProperty::Size, b"name" => FileNodeProperty::Name, b"type" => FileNodeProperty::Type, + b"nodeType" => FileNodeProperty::NodeType, + b"target" => FileNodeProperty::Target, b"created" => FileNodeProperty::Created, b"modified" => FileNodeProperty::Modified, b"accessed" => FileNodeProperty::Accessed, + b"changed" => FileNodeProperty::Changed, b"executable" => FileNodeProperty::Executable, + b"role" => FileNodeProperty::Role, b"myRights" => FileNodeProperty::MyRights, b"shareWith" => FileNodeProperty::ShareWith, b"isSubscribed" => FileNodeProperty::IsSubscribed, b"mayRead" => FileNodeProperty::Rights(FileNodeRight::MayRead), - b"mayWrite" => FileNodeProperty::Rights(FileNodeRight::MayWrite), + b"mayAddChildren" => FileNodeProperty::Rights(FileNodeRight::MayAddChildren), + b"mayRename" => FileNodeProperty::Rights(FileNodeRight::MayRename), + b"mayDelete" => FileNodeProperty::Rights(FileNodeRight::MayDelete), + b"mayModifyContent" => FileNodeProperty::Rights(FileNodeRight::MayModifyContent), b"mayShare" => FileNodeProperty::Rights(FileNodeRight::MayShare), ) .or_else(|| { @@ -185,6 +302,8 @@ impl FileNodeProperty { #[derive(Debug, Clone, Default)] pub struct FileNodeSetArguments { pub on_destroy_remove_children: Option, + pub on_exists: Option, + pub compare_case_insensitively: Option, } impl<'x> DeserializeArguments<'x> for FileNodeSetArguments { @@ -192,8 +311,37 @@ impl<'x> DeserializeArguments<'x> for FileNodeSetArguments { where A: serde::de::MapAccess<'x>, { - if key == "onDestroyRemoveChildren" { - self.on_destroy_remove_children = map.next_value()?; + hashify::fnc_map!(key.as_bytes(), + b"onDestroyRemoveChildren" => { + self.on_destroy_remove_children = map.next_value()?; + }, + b"onExists" => { + self.on_exists = map.next_value()?; + }, + b"compareCaseInsensitively" => { + self.compare_case_insensitively = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct FileNodeGetArguments { + pub fetch_parents: Option, +} + +impl<'x> DeserializeArguments<'x> for FileNodeGetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'x>, + { + if key == "fetchParents" { + self.fetch_parents = map.next_value()?; } else { let _ = map.next_value::()?; } @@ -222,6 +370,7 @@ impl<'x> DeserializeArguments<'x> for FileNodeQueryArguments { } } + impl FromStr for FileNodeProperty { type Err = (); @@ -241,7 +390,7 @@ impl JmapObject for FileNode { type Comparator = FileNodeComparator; - type GetArguments = (); + type GetArguments = FileNodeGetArguments; type SetArguments<'de> = FileNodeSetArguments; @@ -269,22 +418,22 @@ impl From for FileNodeProperty { impl JmapRight for FileNodeRight { fn to_acl(&self) -> &'static [Acl] { match self { - FileNodeRight::MayShare => &[Acl::Share], FileNodeRight::MayRead => &[Acl::Read, Acl::ReadItems], - FileNodeRight::MayWrite => &[ - Acl::Modify, - Acl::AddItems, - Acl::ModifyItems, - Acl::Delete, - Acl::RemoveItems, - ], + FileNodeRight::MayAddChildren => &[Acl::AddItems], + FileNodeRight::MayRename => &[Acl::Modify], + FileNodeRight::MayDelete => &[Acl::Delete, Acl::RemoveItems], + FileNodeRight::MayModifyContent => &[Acl::ModifyItems], + FileNodeRight::MayShare => &[Acl::Share], } } fn all_rights() -> &'static [Self] { &[ FileNodeRight::MayRead, - FileNodeRight::MayWrite, + FileNodeRight::MayAddChildren, + FileNodeRight::MayRename, + FileNodeRight::MayDelete, + FileNodeRight::MayModifyContent, FileNodeRight::MayShare, ] } @@ -298,10 +447,13 @@ impl From for FileNodeProperty { #[derive(Debug, Clone, PartialEq, Eq)] pub enum FileNodeFilter { - HasParentId(bool), + IsTopLevel(bool), ParentId(MaybeInvalid), AncestorId(MaybeInvalid), - HasType(bool), + DescendantId(MaybeInvalid), + NodeType(String), + Role(String), + HasAnyRole(bool), BlobId(MaybeInvalid), IsExecutable(bool), CreatedBefore(UTCDate), @@ -328,6 +480,8 @@ pub enum FileNodeComparator { Created, Modified, Type, + NodeType, + Tree, _T(String), } @@ -337,8 +491,8 @@ impl<'de> DeserializeArguments<'de> for FileNodeFilter { A: serde::de::MapAccess<'de>, { hashify::fnc_map!(key.as_bytes(), - b"hasParentId" => { - *self = FileNodeFilter::HasParentId(map.next_value()?); + b"isTopLevel" => { + *self = FileNodeFilter::IsTopLevel(map.next_value()?); }, b"parentId" => { *self = FileNodeFilter::ParentId(map.next_value()?); @@ -346,8 +500,17 @@ impl<'de> DeserializeArguments<'de> for FileNodeFilter { b"ancestorId" => { *self = FileNodeFilter::AncestorId(map.next_value()?); }, - b"hasType" => { - *self = FileNodeFilter::HasType(map.next_value()?); + b"descendantId" => { + *self = FileNodeFilter::DescendantId(map.next_value()?); + }, + b"nodeType" => { + *self = FileNodeFilter::NodeType(map.next_value()?); + }, + b"role" => { + *self = FileNodeFilter::Role(map.next_value()?); + }, + b"hasAnyRole" => { + *self = FileNodeFilter::HasAnyRole(map.next_value()?); }, b"blobId" => { *self = FileNodeFilter::BlobId(map.next_value()?); @@ -430,6 +593,12 @@ impl<'de> DeserializeArguments<'de> for FileNodeComparator { b"type" => { *self = FileNodeComparator::Type; }, + b"nodeType" => { + *self = FileNodeComparator::NodeType; + }, + b"tree" => { + *self = FileNodeComparator::Tree; + }, _ => { *self = FileNodeComparator::_T(key.to_string()); } @@ -500,10 +669,13 @@ impl JmapObjectId for FileNodeValue { impl FileNodeFilter { pub fn into_string(self) -> Cow<'static, str> { match self { - FileNodeFilter::HasParentId(_) => "hasParentId", + FileNodeFilter::IsTopLevel(_) => "isTopLevel", FileNodeFilter::ParentId(_) => "parentId", FileNodeFilter::AncestorId(_) => "ancestorId", - FileNodeFilter::HasType(_) => "hasType", + FileNodeFilter::DescendantId(_) => "descendantId", + FileNodeFilter::NodeType(_) => "nodeType", + FileNodeFilter::Role(_) => "role", + FileNodeFilter::HasAnyRole(_) => "hasAnyRole", FileNodeFilter::BlobId(_) => "blobId", FileNodeFilter::IsExecutable(_) => "isExecutable", FileNodeFilter::CreatedBefore(_) => "createdBefore", @@ -534,6 +706,8 @@ impl FileNodeComparator { FileNodeComparator::Created => "created", FileNodeComparator::Modified => "modified", FileNodeComparator::Type => "type", + FileNodeComparator::NodeType => "nodeType", + FileNodeComparator::Tree => "tree", FileNodeComparator::_T(s) => s.as_ref(), } } @@ -545,6 +719,8 @@ impl FileNodeComparator { FileNodeComparator::Created => "created", FileNodeComparator::Modified => "modified", FileNodeComparator::Type => "type", + FileNodeComparator::NodeType => "nodeType", + FileNodeComparator::Tree => "tree", FileNodeComparator::_T(s) => return s.into(), } .into() diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index c3c25087..527b7d0f 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -267,10 +267,22 @@ pub struct FileNodeCapabilities { pub max_file_node_depth: Option, #[serde(rename(serialize = "maxSizeFileNodeName"))] pub max_size_file_node_name: u64, + #[serde(rename(serialize = "forbiddenNameChars"))] + pub forbidden_name_chars: Option, + #[serde(rename(serialize = "forbiddenNodeNames"))] + pub forbidden_node_names: Option>, #[serde(rename(serialize = "fileNodeQuerySortOptions"))] pub file_node_query_sort_options: Vec, #[serde(rename(serialize = "mayCreateTopLevelFileNode"))] pub may_create_top_level_file_node: bool, + #[serde(rename(serialize = "caseInsensitiveNames"))] + pub case_insensitive_names: bool, + #[serde(rename(serialize = "webTrashUrl"))] + pub web_trash_url: Option, + #[serde(rename(serialize = "webUrlTemplate"))] + pub web_url_template: Option, + #[serde(rename(serialize = "webWriteUrlTemplate"))] + pub web_write_url_template: Option, } #[derive(Debug, Clone, Default, serde::Serialize)] diff --git a/crates/jmap/src/file/get.rs b/crates/jmap/src/file/get.rs index 97f835cc..bbee2511 100644 --- a/crates/jmap/src/file/get.rs +++ b/crates/jmap/src/file/get.rs @@ -9,7 +9,7 @@ use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; use groupware::{cache::GroupwareCache, file::FileNode}; use jmap_proto::{ method::get::{GetRequest, GetResponse}, - object::file_node::{self, FileNodeProperty, FileNodeValue}, + object::file_node::{self, FileNodeNodeType, FileNodeProperty, FileNodeValue}, types::date::UTCDate, }; use jmap_tools::{Map, Value}; @@ -43,9 +43,22 @@ impl FileNodeGet for Server { let ids = request.unwrap_ids(self.core.jmap.get_max_objects)?; let properties = request.unwrap_properties(&[ FileNodeProperty::Id, - FileNodeProperty::Name, FileNodeProperty::ParentId, + FileNodeProperty::NodeType, + FileNodeProperty::BlobId, + FileNodeProperty::Target, FileNodeProperty::Size, + FileNodeProperty::Name, + FileNodeProperty::Type, + FileNodeProperty::Created, + FileNodeProperty::Modified, + FileNodeProperty::Accessed, + FileNodeProperty::Changed, + FileNodeProperty::Executable, + FileNodeProperty::IsSubscribed, + FileNodeProperty::MyRights, + FileNodeProperty::ShareWith, + FileNodeProperty::Role, ]); let account_id = request.account_id.document_id(); let cache = self @@ -65,7 +78,7 @@ impl FileNodeGet for Server { cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true) }; - let ids = if let Some(ids) = ids { + let mut ids = if let Some(ids) = ids { ids } else { file_node_ids @@ -74,6 +87,28 @@ impl FileNodeGet for Server { .map(Into::into) .collect::>() }; + + if request.arguments.fetch_parents.unwrap_or(false) { + let mut seen: RoaringBitmap = ids.iter().map(|i| i.document_id()).collect(); + let mut extra: Vec = Vec::new(); + for id in &ids { + let mut current = cache + .any_resource_path_by_id(id.document_id()) + .and_then(|r| r.parent_id()); + while let Some(parent_id) = current { + if !seen.insert(parent_id) { + break; + } + if file_node_ids.contains(parent_id) { + extra.push(parent_id.into()); + } + current = cache + .container_resource_by_id(parent_id) + .and_then(|r| r.parent_id()); + } + } + ids.extend(extra); + } let mut response = GetResponse { account_id: request.account_id.into(), state: cache.get_state(true).into(), @@ -225,6 +260,31 @@ impl FileNodeGet for Server { ))), ); } + FileNodeProperty::Changed => { + result.insert_unchecked( + FileNodeProperty::Changed, + Value::Element(FileNodeValue::Date(UTCDate::from_timestamp( + file_node.modified.to_native(), + ))), + ); + } + FileNodeProperty::NodeType => { + let node_type = if file_node.file.is_some() { + FileNodeNodeType::File + } else { + FileNodeNodeType::Directory + }; + result.insert_unchecked( + FileNodeProperty::NodeType, + Value::Str(node_type.as_str().into()), + ); + } + FileNodeProperty::Target => { + result.insert_unchecked(FileNodeProperty::Target, Value::Null); + } + FileNodeProperty::Role => { + result.insert_unchecked(FileNodeProperty::Role, Value::Null); + } FileNodeProperty::IsSubscribed => { result.insert_unchecked(FileNodeProperty::IsSubscribed, Value::Bool(true)); } diff --git a/crates/jmap/src/file/query.rs b/crates/jmap/src/file/query.rs index 9d9ff3ba..a63d331d 100644 --- a/crates/jmap/src/file/query.rs +++ b/crates/jmap/src/file/query.rs @@ -57,15 +57,30 @@ impl FileNodeQuery for Server { filters.push(SearchFilter::is_in_set(RoaringBitmap::new())); } } + FileNodeFilter::DescendantId(MaybeInvalid::Value(id)) => { + let mut ancestors = RoaringBitmap::new(); + let mut current = cache + .any_resource_path_by_id(id.document_id()) + .and_then(|r| r.parent_id()); + while let Some(parent_id) = current { + if !ancestors.insert(parent_id) { + break; + } + current = cache + .container_resource_by_id(parent_id) + .and_then(|r| r.parent_id()); + } + filters.push(SearchFilter::is_in_set(ancestors)); + } FileNodeFilter::ParentId(MaybeInvalid::Value(id)) => { filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( cache.children_ids(id.document_id()), ))); } - FileNodeFilter::HasParentId(has_parent_id) => { + FileNodeFilter::IsTopLevel(is_top_level) => { filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( cache.resources.iter().filter_map(|r| { - if has_parent_id == r.parent_id().is_some() { + if is_top_level == r.parent_id().is_none() { Some(r.document_id) } else { None @@ -73,6 +88,27 @@ impl FileNodeQuery for Server { }), ))); } + FileNodeFilter::NodeType(node_type) => { + let want_container = match node_type.as_str() { + "directory" => Some(true), + "file" => Some(false), + _ => None, + }; + let set = match want_container { + Some(is_container) => RoaringBitmap::from_iter( + cache.resources.iter().filter_map(|r| { + if r.is_container() == is_container { + Some(r.document_id) + } else { + None + } + }), + ), + // TODO: support symlink nodeType once target storage exists + None => RoaringBitmap::new(), + }; + filters.push(SearchFilter::is_in_set(set)); + } FileNodeFilter::Name(name) => { filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( cache.resources.iter().filter_map(|r| { @@ -119,11 +155,25 @@ impl FileNodeQuery for Server { }), ))); } - unsupported => { - return Err(trc::JmapEvent::UnsupportedFilter - .into_err() - .details(unsupported.into_string())); - } + // TODO: filters below require fetching archives or new indexes; ignore for now + FileNodeFilter::Role(_) + | FileNodeFilter::HasAnyRole(_) + | FileNodeFilter::BlobId(_) + | FileNodeFilter::IsExecutable(_) + | FileNodeFilter::CreatedBefore(_) + | FileNodeFilter::CreatedAfter(_) + | FileNodeFilter::ModifiedBefore(_) + | FileNodeFilter::ModifiedAfter(_) + | FileNodeFilter::AccessedBefore(_) + | FileNodeFilter::AccessedAfter(_) + | FileNodeFilter::Type(_) + | FileNodeFilter::TypeMatch(_) + | FileNodeFilter::Text(_) + | FileNodeFilter::Body(_) + | FileNodeFilter::AncestorId(_) + | FileNodeFilter::DescendantId(_) + | FileNodeFilter::ParentId(_) + | FileNodeFilter::_T(_) => {} }, Filter::And => { filters.push(SearchFilter::And); @@ -140,11 +190,7 @@ impl FileNodeQuery for Server { } } - if request.sort.as_ref().is_some_and(|s| !s.is_empty()) { - return Err(trc::JmapEvent::UnsupportedSort - .into_err() - .details("Sorting is not supported on FileNode")); - } + // TODO: implement FileNode/query sort (name, size, type, created, modified, nodeType, tree) let results = SearchQuery::new(SearchIndex::InMemory) .with_filters(filters) diff --git a/crates/jmap/src/file/set.rs b/crates/jmap/src/file/set.rs index bd42b7ab..9823e02a 100644 --- a/crates/jmap/src/file/set.rs +++ b/crates/jmap/src/file/set.rs @@ -23,7 +23,7 @@ use jmap_tools::{JsonPointerItem, Key, Value}; use store::{ ValueKey, ahash::{AHashMap, AHashSet}, - write::{AlignedBytes, Archive, BatchBuilder}, + write::{AlignedBytes, Archive, BatchBuilder, now}, }; use trc::AddContext; use types::{ @@ -33,6 +33,13 @@ use types::{ id::Id, }; +const FORBIDDEN_NAME_CHARS: &str = "/<>:\"\\|?*"; +const FORBIDDEN_NODE_NAMES: &[&str] = &[ + ".", "..", "CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", + "COM7", "COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", + "LPT9", +]; + pub trait FileNodeSet: Sync + Send { fn file_node_set( &self, @@ -58,8 +65,23 @@ impl FileNodeSet for Server { ) .await?; let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; - let will_destroy = request.unwrap_destroy().into_valid().collect::>(); + let mut will_destroy = request.unwrap_destroy().into_valid().collect::>(); let is_shared = access_token.is_shared(account_id); + let on_destroy_remove_children = request + .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 case_insensitive = request + .arguments + .compare_case_insensitively + .unwrap_or(false); + let mut pending_names: AHashSet<(u32, String)> = AHashSet::new(); + let mut implicit_destroys: AHashSet = AHashSet::new(); // Process creates let mut batch = BatchBuilder::new(); @@ -130,6 +152,71 @@ impl FileNodeSet for Server { continue 'create; } + let renamed = match find_sibling_collision( + None, + &file_node, + &cache, + &pending_names, + 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()); + continue 'create; + } + } + implicit_destroys.insert(existing); + false + } + }, + Collision::Pending => match on_exists { + OnExists::Reject => { + response.not_created.append(id, SetError::already_exists()); + continue 'create; + } + OnExists::Rename => { + file_node.name = pick_unique_rename( + &file_node.name, + None, + file_node.parent_id, + &cache, + &pending_names, + case_insensitive, + ); + true + } + // TODO: support onExists=replace for within-batch pending collisions + OnExists::Replace => { + response.not_created.append(id, SetError::already_exists()); + continue 'create; + } + }, + }; + // Inherit ACLs from parent if file_node.parent_id > 0 { let parent_id = file_node.parent_id - 1; @@ -177,6 +264,8 @@ impl FileNodeSet for Server { if file_node.file.is_none() { 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)); file_node .insert( access_token.account_tenant_ids(), @@ -185,13 +274,20 @@ impl FileNodeSet for Server { &mut batch, ) .caused_by(trc::location!())?; + let create_id = id.clone(); response.created(id, document_id); + if renamed && let Some(Value::Object(map)) = response.created.get_mut(&create_id) { + map.insert_unchecked( + Key::Property(FileNodeProperty::Name), + Value::Str(std::borrow::Cow::Owned(final_name)), + ); + } } // Process updates 'update: for (id, object) in request.unwrap_update().into_valid() { // Make sure id won't be destroyed - if will_destroy.contains(&id) { + if will_destroy.contains(&id) || implicit_destroys.contains(&id.document_id()) { response.not_updated.append(id, SetError::will_destroy()); continue 'update; } @@ -272,6 +368,70 @@ impl FileNodeSet for Server { continue 'update; } + let renamed = match find_sibling_collision( + Some(document_id), + &new_file_node, + &cache, + &pending_names, + 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()); + continue 'update; + } + } + implicit_destroys.insert(existing); + false + } + }, + Collision::Pending => match on_exists { + OnExists::Reject => { + response.not_updated.append(id, SetError::already_exists()); + 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 => { + response.not_updated.append(id, SetError::already_exists()); + continue 'update; + } + }, + }; + // Validate ACL if is_shared { let acl = file_node.inner.acls.effective_acl(access_token); @@ -305,6 +465,8 @@ impl FileNodeSet for Server { .caused_by(trc::location!())?; } + let final_name = new_file_node.name.clone(); + pending_names.insert(pending_key(&new_file_node, case_insensitive)); // Update record new_file_node .update( @@ -315,14 +477,26 @@ impl FileNodeSet for Server { &mut batch, ) .caused_by(trc::location!())?; - response.updated.append(id, None); + let updated_value = if renamed { + let mut map = jmap_tools::Map::with_capacity(1); + map.insert_unchecked( + Key::Property(FileNodeProperty::Name), + Value::Str(std::borrow::Cow::Owned(final_name)), + ); + Some(Value::Object(map)) + } else { + None + }; + response.updated.append(id, updated_value); } // Process deletions - let on_destroy_remove_children = request - .arguments - .on_destroy_remove_children - .unwrap_or(false); + for did in &implicit_destroys { + let id = Id::from(*did); + if !will_destroy.contains(&id) { + will_destroy.push(id); + } + } let mut destroy_ids = AHashSet::with_capacity(will_destroy.len()); 'destroy: for id in will_destroy { let document_id = id.document_id(); @@ -436,8 +610,10 @@ fn update_file_node( match (property, value) { (FileNodeProperty::Name, Value::Str(value)) if (1..=255).contains(&value.len()) - && !value.contains('/') - && ![".", ".."].contains(&value.as_ref()) => + && !value.contains(|c: char| FORBIDDEN_NAME_CHARS.contains(c)) + && !FORBIDDEN_NODE_NAMES + .iter() + .any(|n| n.eq_ignore_ascii_case(value.as_ref())) => { file_node.name = value.into_owned(); } @@ -460,7 +636,10 @@ fn update_file_node( (FileNodeProperty::Size, Value::Number(value)) => { file_node.file.get_or_insert_default().size = value.cast_to_u64() as u32; } - (FileNodeProperty::Type, Value::Str(value)) if (1..=30).contains(&value.len()) => { + (FileNodeProperty::Type, Value::Str(value)) + if (1..=256).contains(&value.len()) && value.contains('/') => + { + // TODO: validate full RFC 6838 Section 4.2 ABNF for media types file_node.file.get_or_insert_default().media_type = value.into_owned().into(); } (FileNodeProperty::Type, Value::Null) => { @@ -475,9 +654,23 @@ fn update_file_node( (FileNodeProperty::Created, Value::Element(FileNodeValue::Date(value))) => { file_node.created = value.timestamp(); } + // TODO: groupware::file::insert/update clobber modified with now(); preserve client-supplied value (FileNodeProperty::Modified, Value::Element(FileNodeValue::Date(value))) => { file_node.modified = value.timestamp(); } + (FileNodeProperty::Modified, Value::Null) => { + file_node.modified = now() as i64; + } + // TODO: persist accessed per-user (draft-13 section 3.1) + (FileNodeProperty::Accessed, _) => {} + // TODO: store nodeType explicitly and validate immutability after create + (FileNodeProperty::NodeType, _) => {} + // TODO: implement symlink target storage and resolution + (FileNodeProperty::Target, _) => {} + // TODO: server-set changed timestamp on every mutation + (FileNodeProperty::Changed, _) => {} + // TODO: store and validate FileNode role for directories + (FileNodeProperty::Role, _) => {} (FileNodeProperty::ShareWith, value) => { file_node.acls = JmapRights::acl_set::(value)?; has_acl_changes = true; @@ -528,13 +721,12 @@ fn validate_file_node_hierarchy( cache: &DavResources, created_folders: &AHashMap>, ) -> Result<(), SetError> { - let node_parent_id = if node.parent_id == 0 { + if node.parent_id == 0 { if is_shared && document_id.is_none() { return Err(SetError::invalid_properties() .with_property(FileNodeProperty::ParentId) .with_description("Cannot create top-level folder in a shared account.")); } - None } else { let parent_id = node.parent_id - 1; @@ -565,24 +757,98 @@ fn validate_file_node_hierarchy( .with_property(FileNodeProperty::ParentId) .with_description("Parent ID does not exist or is not a folder.")); } + } - Some(parent_id) + Ok(()) +} + +#[derive(Copy, Clone, PartialEq, Eq)] +enum OnExists { + Reject, + Rename, + Replace, +} + +#[derive(Copy, Clone, PartialEq, Eq)] +enum Collision { + None, + Existing(u32), + Pending, +} + +fn names_equal(a: &str, b: &str, case_insensitive: bool) -> bool { + if case_insensitive { + a.eq_ignore_ascii_case(b) + } else { + a == b + } +} + +fn pending_key(node: &FileNode, case_insensitive: bool) -> (u32, String) { + ( + node.parent_id, + if case_insensitive { + node.name.to_lowercase() + } else { + node.name.clone() + }, + ) +} + +fn find_sibling_collision( + document_id: Option, + node: &FileNode, + cache: &DavResources, + pending: &AHashSet<(u32, String)>, + case_insensitive: bool, +) -> Collision { + let node_parent_id = if node.parent_id == 0 { + None + } else { + Some(node.parent_id - 1) }; - - // Validate name uniqueness for resource in &cache.resources { if let DavResourceMetadata::File { name, parent_id, .. } = &resource.data && document_id.is_none_or(|id| id != resource.document_id) && node_parent_id == *parent_id - && node.name == *name + && names_equal(&node.name, name, case_insensitive) { - return Err(SetError::invalid_properties() - .with_property(FileNodeProperty::Name) - .with_description("A node with the same name already exists in this folder.")); + return Collision::Existing(resource.document_id); } } - - Ok(()) + if pending.contains(&pending_key(node, case_insensitive)) { + return Collision::Pending; + } + Collision::None +} + +fn pick_unique_rename( + base: &str, + document_id: Option, + parent_id: u32, + cache: &DavResources, + pending: &AHashSet<(u32, String)>, + case_insensitive: bool, +) -> String { + let (stem, ext) = match base.rfind('.') { + Some(i) if i > 0 && i < base.len() - 1 => (&base[..i], &base[i..]), + _ => (base, ""), + }; + let mut probe = FileNode { + parent_id, + name: String::new(), + ..FileNode::default() + }; + for n in 2u32.. { + probe.name = format!("{stem} ({n}){ext}"); + if matches!( + find_sibling_collision(document_id, &probe, cache, pending, case_insensitive), + Collision::None + ) { + return probe.name; + } + } + unreachable!() } diff --git a/crates/jmap/src/registry/mapping/queued_message.rs b/crates/jmap/src/registry/mapping/queued_message.rs index e88117d3..e5a89c77 100644 --- a/crates/jmap/src/registry/mapping/queued_message.rs +++ b/crates/jmap/src/registry/mapping/queued_message.rs @@ -169,7 +169,9 @@ pub(crate) async fn queued_message_set( } } - if let Some(next_retry) = set_next_retry { + if let Some(next_retry) = set_next_retry + && !matches!(queued_rcpt.status, Status::PermanentFailure(_)) + { let new_due = next_retry.timestamp() as u64; if queued_rcpt.retry.due != new_due { queued_rcpt.retry.due = new_due; diff --git a/tests/src/jmap/files/acl.rs b/tests/src/jmap/files/acl.rs index 77a3017c..b74f027e 100644 --- a/tests/src/jmap/files/acl.rs +++ b/tests/src/jmap/files/acl.rs @@ -48,7 +48,10 @@ pub async fn test(test: &TestServer) { "name": "Test #1", "myRights": { "mayRead": true, - "mayWrite": true, + "mayAddChildren": true, + "mayRename": true, + "mayDelete": true, + "mayModifyContent": true, "mayShare": true }, "shareWith": {} @@ -113,7 +116,10 @@ pub async fn test(test: &TestServer) { "shareWith": { &jane_id : { "mayRead": true, - "mayWrite": false, + "mayAddChildren": false, + "mayRename": false, + "mayDelete": false, + "mayModifyContent": false, "mayShare": false } } @@ -137,7 +143,10 @@ pub async fn test(test: &TestServer) { "name": "Test #1", "myRights": { "mayRead": true, - "mayWrite": false, + "mayAddChildren": false, + "mayRename": false, + "mayDelete": false, + "mayModifyContent": false, "mayShare": false } })); @@ -178,12 +187,18 @@ pub async fn test(test: &TestServer) { "objectId": &john_folder_id, "oldRights": { "mayRead": false, - "mayWrite": false, + "mayAddChildren": false, + "mayRename": false, + "mayDelete": false, + "mayModifyContent": false, "mayShare": false }, "newRights": { "mayRead": true, - "mayWrite": false, + "mayAddChildren": false, + "mayRename": false, + "mayDelete": false, + "mayModifyContent": false, "mayShare": false }, "name": null @@ -221,7 +236,10 @@ pub async fn test(test: &TestServer) { [( &john_folder_id, json!({ - format!("shareWith/{jane_id}/mayWrite"): true, + format!("shareWith/{jane_id}/mayAddChildren"): true, + format!("shareWith/{jane_id}/mayRename"): true, + format!("shareWith/{jane_id}/mayDelete"): true, + format!("shareWith/{jane_id}/mayModifyContent"): true, }), )], Vec::<(&str, &str)>::new(), @@ -245,7 +263,10 @@ pub async fn test(test: &TestServer) { "name": "Test #1", "myRights": { "mayRead": true, - "mayWrite": true, + "mayAddChildren": true, + "mayRename": true, + "mayDelete": true, + "mayModifyContent": true, "mayShare": false } })); @@ -286,12 +307,18 @@ pub async fn test(test: &TestServer) { "objectId": &john_folder_id, "oldRights": { "mayRead": true, - "mayWrite": false, + "mayAddChildren": false, + "mayRename": false, + "mayDelete": false, + "mayModifyContent": false, "mayShare": false }, "newRights": { "mayRead": true, - "mayWrite": true, + "mayAddChildren": true, + "mayRename": true, + "mayDelete": true, + "mayModifyContent": true, "mayShare": false }, "name": null @@ -419,12 +446,18 @@ pub async fn test(test: &TestServer) { "objectId": &john_folder_id, "oldRights": { "mayRead": true, - "mayWrite": true, + "mayAddChildren": true, + "mayRename": true, + "mayDelete": true, + "mayModifyContent": true, "mayShare": false }, "newRights": { "mayRead": false, - "mayWrite": false, + "mayAddChildren": false, + "mayRename": false, + "mayDelete": false, + "mayModifyContent": false, "mayShare": false }, "name": null @@ -437,7 +470,10 @@ pub async fn test(test: &TestServer) { &john_folder_id, json!({ format!("shareWith/{jane_id}/mayRead"): true, - format!("shareWith/{jane_id}/mayWrite"): true, + format!("shareWith/{jane_id}/mayAddChildren"): true, + format!("shareWith/{jane_id}/mayRename"): true, + format!("shareWith/{jane_id}/mayDelete"): true, + format!("shareWith/{jane_id}/mayModifyContent"): true, }), )], Vec::<(&str, &str)>::new(), diff --git a/tests/src/jmap/files/node.rs b/tests/src/jmap/files/node.rs index ec74394e..19267b90 100644 --- a/tests/src/jmap/files/node.rs +++ b/tests/src/jmap/files/node.rs @@ -197,10 +197,9 @@ pub async fn test(test: &TestServer) { Vec::<(&str, &str)>::new(), ) .await; - assert_eq!( - response.not_created(0).description(), - "A node with the same name already exists in this folder." - ); + let err = response.not_created(0); + assert_eq!(err.typ(), "alreadyExists"); + assert_eq!(err.text_field("existingId"), sub_folder_id.as_str()); assert_eq!( response.not_created(1).description(), "Parent ID does not exist or is not a folder." @@ -331,6 +330,144 @@ pub async fn test(test: &TestServer) { .collect::>() ); + // fetchParents: requesting a leaf should return its ancestors too + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({"name": "fp-root"}), + json!({"name": "fp-sub", "parentId": "#i0"}), + json!({"name": "fp-leaf", "parentId": "#i1"}), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let fp_root = response.created(0).id().to_string(); + let fp_sub = response.created(1).id().to_string(); + let fp_leaf = response.created(2).id().to_string(); + let response = account + .jmap_method_calls(json!([[ + "FileNode/get", + { + "accountId": account.id_string(), + "ids": [&fp_leaf], + "fetchParents": true, + "properties": ["id"] + }, + "0" + ]])) + .await; + let ids = response + .pointer("/methodResponses/0/1/list") + .and_then(|v| v.as_array()) + .map(|list| { + list.iter() + .map(|n| n.text_field("id").to_string()) + .collect::>() + }) + .expect("fetchParents response"); + assert_eq!( + ids, + [fp_leaf.as_str(), fp_sub.as_str(), fp_root.as_str()] + .into_iter() + .map(str::to_string) + .collect::>() + ); + account + .jmap_destroy( + MethodObject::FileNode, + [&fp_root], + [("onDestroyRemoveChildren", true)], + ) + .await + .destroyed() + .for_each(drop); + + // onExists=rename should produce a unique sibling name + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "dupe.txt", "parentId": null, "blobId": null})], + Vec::<(&str, &str)>::new(), + ) + .await; + let dupe_orig = response.created(0).id().to_string(); + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "dupe.txt"})], + [("onExists", "rename")], + ) + .await; + let dupe_renamed = response.created(0); + let dupe_renamed_id = dupe_renamed.id().to_string(); + assert_eq!(dupe_renamed.text_field("name"), "dupe (2).txt"); + + // onExists=reject (default) should return alreadyExists with existingId + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "dupe.txt"})], + Vec::<(&str, &str)>::new(), + ) + .await; + let err = response.not_created(0); + assert_eq!(err.typ(), "alreadyExists"); + assert_eq!(err.text_field("existingId"), dupe_orig.as_str()); + + // onExists=replace should destroy the existing sibling + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "dupe.txt"})], + [("onExists", "replace")], + ) + .await; + let dupe_replacement = response.created(0).id().to_string(); + let destroyed = response.destroyed().collect::>(); + assert!( + destroyed.contains(dupe_orig.as_str()), + "Expected old id {dupe_orig} to be destroyed, got {destroyed:?}" + ); + account + .jmap_destroy( + MethodObject::FileNode, + [&dupe_renamed_id, &dupe_replacement], + [("onDestroyRemoveChildren", true)], + ) + .await + .destroyed() + .for_each(drop); + + // compareCaseInsensitively should treat sibling names as case-insensitive + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "CASE"})], + Vec::<(&str, &str)>::new(), + ) + .await; + let case_id = response.created(0).id().to_string(); + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "case"})], + [("compareCaseInsensitively", true)], + ) + .await; + let err = response.not_created(0); + assert_eq!(err.typ(), "alreadyExists"); + assert_eq!(err.text_field("existingId"), case_id.as_str()); + account + .jmap_destroy( + MethodObject::FileNode, + [&case_id], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + // Make sure everything is gone test.assert_is_empty().await; }