Bump JMAP File Storage to draft-ietf-jmap-filenode-13

This commit is contained in:
Maurus Decimus
2026-05-15 19:08:54 +02:00
parent e049f9ff4f
commit b9794315aa
9 changed files with 829 additions and 79 deletions

View File

@@ -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));
}

View File

@@ -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)

View File

@@ -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!()
}

View File

@@ -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;