Bump JMAP File Storage to draft-ietf-jmap-filenode-13
This commit is contained in:
@@ -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::<Vec<_>>()
|
||||
};
|
||||
|
||||
if request.arguments.fetch_parents.unwrap_or(false) {
|
||||
let mut seen: RoaringBitmap = ids.iter().map(|i| i.document_id()).collect();
|
||||
let mut extra: Vec<types::id::Id> = 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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
let mut will_destroy = request.unwrap_destroy().into_valid().collect::<Vec<_>>();
|
||||
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<u32> = 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::<file_node::FileNode>(value)?;
|
||||
has_acl_changes = true;
|
||||
@@ -528,13 +721,12 @@ fn validate_file_node_hierarchy(
|
||||
cache: &DavResources,
|
||||
created_folders: &AHashMap<u32, Vec<AclGrant>>,
|
||||
) -> Result<(), SetError<FileNodeProperty>> {
|
||||
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<u32>,
|
||||
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<u32>,
|
||||
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!()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user