From 4f226d5a27e04ce68cdc6c78a092133706c489f4 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Mar 2026 08:47:41 +0100 Subject: [PATCH 1/9] sharing: fix property overlay add/replace/remove --- radicale/sharing/__init__.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 801f6719..eeda39ad 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -921,6 +921,29 @@ class BaseSharing: if share is None: return httputils.NOT_FOUND + if 'Properties' in request_data: + if Properties is None: + # clear properties + Properties = {} + elif Properties == {}: + # empty, nothing to do + pass + else: + # replace properties + for prop in share['Properties']: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": check for existing property %r", prop) + if prop not in Properties: + # overtake + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": overtake property %r", prop) + Properties[prop] = share['Properties'][prop] + elif Properties[prop] == '': + # unset, do nothing + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": clear property %r", prop) + del Properties[prop] + if user == share['Owner']: if PathMapped is not None: # check access Permissions @@ -929,10 +952,6 @@ class BaseSharing: logger.warning(api_info + ": access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED - if 'Properties' in request_data and Properties is None: - # clear properties - Properties = {} - result = self.database_update_sharing( ShareType=ShareType, PathMapped=PathMapped, @@ -967,10 +986,6 @@ class BaseSharing: return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED - if 'Properties' in request_data and Properties is None: - # clear properties - Properties = {} - # limited update as user result = self.database_update_sharing( ShareType=ShareType, From d7bedcb20f06652cedcd34efc16ecc11eeeb7bee Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Mar 2026 08:48:03 +0100 Subject: [PATCH 2/9] sharing: add testcases for property overlay management --- radicale/tests/test_sharing.py | 323 ++++++++++++++++++++++++++++++++- 1 file changed, 322 insertions(+), 1 deletion(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 8be2575c..d0b6ae42 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -77,7 +77,7 @@ class TestSharingApiSanity(BaseTest): response = responses[path] assert not isinstance(response, int) status, prop = response["ICAL:calendar-color"] - logging.debug("calendar-color: %r", prop.text) + logging.debug("ICAL:calendar-color: %r", prop.text) assert status == 200 return prop.text @@ -115,6 +115,56 @@ class TestSharingApiSanity(BaseTest): assert status == 200 and not prop.text return + def _propfind_calendar_description(self, path, login): + _, responses = self.propfind(path=path, data="""\ + + + + + +""", login=login) + logging.info("response: %r", responses) + response = responses[path] + assert not isinstance(response, int) + status, prop = response["C:calendar-description"] + logging.debug("C:calendar-description: %r", prop.text) + assert status == 200 + return prop.text + + def _proppatch_calendar_description(self, path, login, description) -> None: + _, responses = self.proppatch(path=path, data="""\ + + + + + """ + description + """ + + +""", login=login) + logging.info("response: %r", responses) + response = responses[path] + assert not isinstance(response, int) and len(response) == 1 + status, prop = response["C:calendar-description"] + assert status == 200 and not prop.text + return + + def _proppatch_calendar_description_remove(self, path, login) -> None: + _, responses = self.proppatch(path=path, data="""\ + + + + + + + +""", login=login) + logging.info("response: %r", responses) + response = responses[path] + assert not isinstance(response, int) and len(response) == 1 + status, prop = response["C:calendar-description"] + assert status == 200 and not prop.text + return + # Test functions def test_sharing_api_base_csv_custom(self) -> None: self.database_path = os.path.join(self.colpath, "collection-db/test.csv") @@ -3591,3 +3641,274 @@ permissions: RrWw""") logging.info("\n*** PROPFIND collection owner (visible change) -> ok") color = self._propfind_calendar_color(path_mapped, login="owner:ownerpw") assert color == "#FFFFFF" + + def test_sharing_api_map_propfind_overlay_partial(self) -> None: + """share-by-map API usage tests related to partial overlay.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_map": True, + "permit_create_token": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_map": "True", + "collection_by_token": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "True", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + logging.info("\n*** prepare and test access") + + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + path_mapped = "/owner/calendarPFP-" + db_type + ".ics/" + path_shared_r = "/user/calendarPFP-shared-by-owner-r-" + db_type + ".ics/" + self.mkcalendar(path_mapped, login="owner:ownerpw") + + # check PROPFIND as owner + logging.info("\n*** PROPFIND collection owner -> ok") + _, responses = self.propfind(path_mapped, """\ + + + + + +""", login="owner:ownerpw") + logging.info("response: %r", responses) + response = responses[path_mapped] + assert not isinstance(response, int) and len(response) == 1 + status, prop = response["D:current-user-principal"] + assert status == 200 and len(prop) == 1 + element = prop.find(xmlutils.make_clark("D:href")) + assert element is not None and element.text == "/owner/" + + # execute PROPPATCH color as owner + logging.info("\n*** PROPPATCH color collection owner -> ok") + self._proppatch_calendar_color(path_mapped, login="owner:ownerpw", color="#AAAAAA") + + # verify PROPPATCH color by owner + logging.info("\n*** PROPFIND color collection owner (verify collection change) -> ok") + color = self._propfind_calendar_color(path_mapped, login="owner:ownerpw") + assert color == "#AAAAAA" + + # execute PROPPATCH description as owner + logging.info("\n*** PROPPATCH description collection owner -> ok") + self._proppatch_calendar_description(path_mapped, login="owner:ownerpw", description="OWNER") + + # verify PROPPATCH description by owner + logging.info("\n*** PROPFIND description collection owner (verify collection change) -> ok") + description = self._propfind_calendar_description(path_mapped, login="owner:ownerpw") + assert description == "OWNER" + + # create map + logging.info("\n*** create map user/owner:rP -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Permissions'] = "rP" + json_dict['Enabled'] = True + json_dict['Hidden'] = False + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # enable map by user + logging.info("\n*** enable map by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + # verify PROPPATCH as user + logging.info("\n*** PROPFIND color collection collection user -> ok") + color = self._propfind_calendar_color(path_shared_r, login="user:userpw") + assert color == "#AAAAAA" + + logging.info("\n*** PROPFIND description collection collection user -> ok") + description = self._propfind_calendar_description(path_shared_r, login="user:userpw") + assert description == "OWNER" + + # execute PROPPATCH color as user + logging.info("\n*** PROPPATCH color collection user -> ok") + self._proppatch_calendar_color(path_shared_r, login="user:userpw", color="#BBBBBB") + + # one property has to be visible + logging.info("\n*** list (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' in answer_dict['Content'][0]['Properties'] + assert answer_dict['Content'][0]['Properties']['ICAL:calendar-color'] == "#BBBBBB" + + # execute PROPPATCH description as user + logging.info("\n*** PROPPATCH description collection user -> ok") + self._proppatch_calendar_description(path_shared_r, login="user:userpw", description="USER") + + # both properties have to be visible + logging.info("\n*** list check for both properties (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' in answer_dict['Content'][0]['Properties'] + assert answer_dict['Content'][0]['Properties']['ICAL:calendar-color'] == "#BBBBBB" + assert 'C:calendar-description' in answer_dict['Content'][0]['Properties'] + assert answer_dict['Content'][0]['Properties']['C:calendar-description'] == "USER" + + # verify PROPPATCH as user + logging.info("\n*** PROPFIND color collection collection user -> ok") + color = self._propfind_calendar_color(path_shared_r, login="user:userpw") + assert color == "#BBBBBB" + + logging.info("\n*** PROPFIND description collection collection user -> ok") + description = self._propfind_calendar_description(path_shared_r, login="user:userpw") + assert description == "USER" + + # execute PROPPATCH DELETE description as user + logging.info("\n*** PROPPATCH DELETE description collection user -> ok") + self._proppatch_calendar_description_remove(path_shared_r, login="user:userpw") + + # one property has to survive + logging.info("\n*** list check for still one property (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' in answer_dict['Content'][0]['Properties'] + assert answer_dict['Content'][0]['Properties']['ICAL:calendar-color'] == "#BBBBBB" + + # set properties by user using API + logging.info("\n*** set properties by user color overwrite (form)") + form_array = [] + form_array.append("PathOrToken=" + path_shared_r) + form_array.append("Properties='ICAL:calendar-color'='#CCCCCC'") + _, headers, answer = self._sharing_api_form("map", "update", check=200, login="user:userpw", form_array=form_array) + + # one property has to survive + logging.info("\n*** list check for still one property (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' in answer_dict['Content'][0]['Properties'] + assert answer_dict['Content'][0]['Properties']['ICAL:calendar-color'] == "#CCCCCC" + assert 'C:calendar-description' not in answer_dict['Content'][0]['Properties'] + + # set property by user using API + logging.info("\n*** set properties by user description extension (form)") + form_array = [] + form_array.append("PathOrToken=" + path_shared_r) + form_array.append("Properties='C:calendar-description'='USER-OWNER'") + _, headers, answer = self._sharing_api_form("map", "update", check=200, login="user:userpw", form_array=form_array) + + # both properties have to be visible + logging.info("\n*** list check for both properties (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' in answer_dict['Content'][0]['Properties'] + assert answer_dict['Content'][0]['Properties']['ICAL:calendar-color'] == "#CCCCCC" + assert 'C:calendar-description' in answer_dict['Content'][0]['Properties'] + assert answer_dict['Content'][0]['Properties']['C:calendar-description'] == "USER-OWNER" + + # delete property by user using API + logging.info("\n*** delete property by user color (form)") + form_array = [] + form_array.append("PathOrToken=" + path_shared_r) + form_array.append("Properties='ICAL:calendar-color'=''") + _, headers, answer = self._sharing_api_form("map", "update", check=200, login="user:userpw", form_array=form_array) + + # only one property has to be visible + logging.info("\n*** list check for single properties (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' not in answer_dict['Content'][0]['Properties'] + assert 'C:calendar-description' in answer_dict['Content'][0]['Properties'] + assert answer_dict['Content'][0]['Properties']['C:calendar-description'] == "USER-OWNER" + + # clear all propertie by user using API + logging.info("\n*** delete property by user color (form)") + form_array = [] + form_array.append("PathOrToken=" + path_shared_r) + form_array.append("Properties=") + _, headers, answer = self._sharing_api_form("map", "update", check=200, login="user:userpw", form_array=form_array) + + # no property has to be visible + logging.info("\n*** list check empty properties (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' not in answer_dict['Content'][0]['Properties'] + assert 'C:calendar-description' not in answer_dict['Content'][0]['Properties'] + + # set properties by user using API + logging.info("\n*** set properties by user (json)") + json_dict = {} + json_dict["PathOrToken"] = path_shared_r + json_dict["Properties"] = {'C:calendar-description': 'USER-OWNER', 'ICAL:calendar-color': '#DDDDDD'} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="user:userpw", json_dict=json_dict) + + # both properties have to be visible + logging.info("\n*** list check for both properties (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' in answer_dict['Content'][0]['Properties'] + assert 'C:calendar-description' in answer_dict['Content'][0]['Properties'] + + # delete on property by user using API + logging.info("\n*** delete property by user color (json)") + json_dict = {} + json_dict["PathOrToken"] = path_shared_r + json_dict["Properties"] = {'C:calendar-description': ''} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="user:userpw", json_dict=json_dict) + + # one property have to be visible + logging.info("\n*** list check for one property (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' in answer_dict['Content'][0]['Properties'] + assert 'C:calendar-description' not in answer_dict['Content'][0]['Properties'] + + # delete all propertie by user using API + logging.info("\n*** delete all properties by user (json)") + json_dict = {} + json_dict["PathOrToken"] = path_shared_r + json_dict["Properties"] = {} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="user:userpw", json_dict=json_dict) + + # no property has to be visible + logging.info("\n*** list check empty properties (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' not in answer_dict['Content'][0]['Properties'] + assert 'C:calendar-description' not in answer_dict['Content'][0]['Properties'] From e45c7da414601b557b1a3b39095602660d1d9e41 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Mar 2026 12:17:40 +0100 Subject: [PATCH 3/9] sharing: fix typo and type def --- radicale/sharing/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index eeda39ad..d8b9b93f 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -80,13 +80,13 @@ API_TYPES_V1: dict[str, type] = { "PermittedCreateCollectionByToken": bool, "ShareType": str, "PathOrToken": str, - "PathMapped:": str, + "PathMapped": str, "Owner": str, "User": str, "Permissions": str, "Enabled": bool, "Hidden": bool, - "Properties": str} + "Properties": dict} TOKEN_PATTERN_V1: str = "(v1/[a-zA-Z0-9_=\\-]{44})" From 713e93e932d357b6841567908a8108ea23bc54db Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Mar 2026 12:18:22 +0100 Subject: [PATCH 4/9] sharing: fix json parser for properties in csv --- radicale/sharing/__init__.py | 24 ++++++++++++++++---- radicale/sharing/csv.py | 43 ++++++++++++++++++++++++++---------- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index d8b9b93f..49514353 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -34,9 +34,6 @@ from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none") DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated', 'Properties') -DB_FIELDS_V1_BOOL: Sequence[str] = ('EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser') -DB_FIELDS_V1_INT: Sequence[str] = ('TimestampCreated', 'TimestampUpdated') -DB_FIELDS_V1_USER_PERMITTED: Sequence[str] = ('EnabledByUser', 'HiddenByUser', 'Properties') # ShareType: # PathOrToken: [PrimaryKey] # PathMapped: @@ -49,9 +46,28 @@ DB_FIELDS_V1_USER_PERMITTED: Sequence[str] = ('EnabledByUser', 'HiddenByUser', ' # HiddenByUser: True|False (share exposure controlled by user) - check skipped if Owner==User # TimestampCreated: (when created) # TimestampUpdated: (last update) -# Properties: Overlay of collection properties +# Properties: Overlay of collection properties in JSON + +DB_TYPES_V1: dict[str, type] = { + "ShareType": str, + "PathOrToken": str, + "PathMapped": str, + "Owner": str, + "User": str, + "Permissions": str, + "EnabledByOwner": bool, + "HiddenByOwner": bool, + "EnabledByUser": bool, + "HiddenByUser": bool, + "TimestampCreated": int, + "TimestampUpdated": int, + "Properties": dict +} + +DB_FIELDS_V1_USER_PERMITTED: Sequence[str] = ('EnabledByUser', 'HiddenByUser', 'Properties') SHARE_TYPES: Sequence[str] = ('token', 'map', 'all') + SHARE_TYPES_V1: Sequence[str] = ('token', 'map') # token: share by secret token (does not require authentication) # map : share by mapping collection of one user to another as virtual diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 62c8a9dc..4d65de5e 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -15,6 +15,7 @@ # along with Radicale. If not, see . import csv +import json import logging import os from typing import Union @@ -406,21 +407,39 @@ class Sharing(sharing.BaseSharing): return False # convert txt to bool or int if self._lines > 0: - for fieldname in sharing.DB_FIELDS_V1_BOOL: - try: - row[fieldname] = config._convert_to_bool(row[fieldname]) - except Exception as e: - logger.error("sharing database row error fieldname=%r row=%r error: %r", fieldname, row, e) - for fieldname in sharing.DB_FIELDS_V1_INT: - row[fieldname] = int(row[fieldname]) + for fieldname in row: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/_load: test fieldname=%r", fieldname) + if fieldname not in sharing.DB_TYPES_V1: + logger.error("sharing database row error, unsupported fieldname found: %r", fieldname) + return False + if sharing.DB_TYPES_V1[fieldname] is bool: + try: + row[fieldname] = config._convert_to_bool(row[fieldname]) + except Exception as e: + logger.error("sharing database row error in type conversion fieldname=%r row=%r error: %r", fieldname, row, e) + return False + elif sharing.DB_TYPES_V1[fieldname] is int: + try: + row[fieldname] = int(row[fieldname]) + except Exception as e: + logger.error("sharing database row error in type conversion fieldname=%r row=%r error: %r", fieldname, row, e) + return False + elif sharing.DB_TYPES_V1[fieldname] is dict: + if row[fieldname] is None or row[fieldname] == '': + row[fieldname] = {} + else: + field = row[fieldname].lstrip('"').rstrip('"').replace("'", '"') + try: + row[fieldname] = json.loads(field) + except Exception as e: + logger.error("sharing database row error in type conversion fieldname=%r field=%r row=%r error: %r", fieldname, field, row, e) + return False # check for duplicates - dup = False for row_cached in self._sharing_cache: if row == row_cached: - dup = True - break - if dup: - continue + logger.error("sharing database row duplicate row=%r", row) + return False # logger.debug("sharing database load add: %r", row) self._sharing_cache.append(row) self._lines += 1 From 2852ebdd9b8ecd31981842d2007bea0b53d14782 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Mar 2026 12:18:48 +0100 Subject: [PATCH 5/9] sharing: check that Properties are not none --- radicale/sharing/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 49514353..4c034afa 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -944,7 +944,7 @@ class BaseSharing: elif Properties == {}: # empty, nothing to do pass - else: + elif share['Properties'] is not None: # replace properties for prop in share['Properties']: if logger.isEnabledFor(logging.DEBUG): From 69fd1f1073975dd92f11a0a8a57e75c1fc705195 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Mar 2026 12:19:04 +0100 Subject: [PATCH 6/9] sharing: additonal test cases --- radicale/tests/test_sharing.py | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index d0b6ae42..889bfa4f 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -3370,6 +3370,16 @@ permissions: RrWw""") color = self._propfind_calendar_color(path_shared_r, login="user:userpw") assert color == "#BBBBBB" + # one property have to be visible + logging.info("\n*** list check for one property (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' in answer_dict['Content'][0]['Properties'] + assert answer_dict['Content'][0]['Properties']['ICAL:calendar-color'] == "#BBBBBB" + # update map by owner logging.info("\n*** update map by owner (disable property overlay)") json_dict = {} @@ -3396,6 +3406,16 @@ permissions: RrWw""") json_dict['Properties'] = {"ICAL:calendar-color": "#CCCCCC"} _, headers, answer = self._sharing_api_json("map", "update", check=403, login="user:userpw", json_dict=json_dict) + # one property have to be visible + logging.info("\n*** list check for one property (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' in answer_dict['Content'][0]['Properties'] + assert answer_dict['Content'][0]['Properties']['ICAL:calendar-color'] == "#BBBBBB" + # verify overlay as user logging.info("\n*** PROPFIND collection user (overlay) -> ok") color = self._propfind_calendar_color(path_shared_r, login="user:userpw") @@ -3410,6 +3430,16 @@ permissions: RrWw""") json_dict['User'] = "user" _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + # one property have to be visible + logging.info("\n*** list check for one property (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' in answer_dict['Content'][0]['Properties'] + assert answer_dict['Content'][0]['Properties']['ICAL:calendar-color'] == "#BBBBBB" + logging.info("\n*** update map by user (json) -> 200 (overlay permitted by share permissions)") json_dict = {} json_dict['PathOrToken'] = path_shared_r @@ -3431,6 +3461,16 @@ permissions: RrWw""") json_dict['Permissions'] = "rp" _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + # one property have to be visible + logging.info("\n*** list check for one property (json->json)") + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert 'ICAL:calendar-color' in answer_dict['Content'][0]['Properties'] + assert answer_dict['Content'][0]['Properties']['ICAL:calendar-color'] == "#CCCCCC" + logging.info("\n*** update map by user (json) -> 403 (overlay permitted but denied by share permissions)") json_dict = {} json_dict['PathOrToken'] = path_shared_r From 6699e11bb6f19ea7905eab0bd73bdc0b4d85f474 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Mar 2026 12:28:44 +0100 Subject: [PATCH 7/9] sharing: database verify type of field --- radicale/sharing/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 4c034afa..38867755 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -277,6 +277,16 @@ class BaseSharing: with self._storage.acquire_lock("r"): for entry in self.database_list_sharing(): logger.debug("analyze: %r", entry) + + # check type + for fieldname in entry: + if fieldname not in DB_TYPES_V1: + logger.error("sharing database row error, unsupported fieldname found: %r", fieldname) + return False + if type(entry[fieldname]) != DB_TYPES_V1[fieldname]: + logger.error("sharing database entry type error fieldname=%r is %r should %r entry=%r", fieldname, type(fieldname), DB_TYPES_V1[fieldname], entry) + return False + if entry['ShareType'] not in SHARE_TYPES_V1: logger.error("ShareType not supported: %r", entry['ShareType']) return False From b09dcc1ead0b14ad746ed6f229e628564dcfb3cb Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Mar 2026 12:33:44 +0100 Subject: [PATCH 8/9] sharing: database verification related to Permissions --- radicale/sharing/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 38867755..d4883133 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -300,6 +300,13 @@ class BaseSharing: else: pass + # permissions + try: + permissions = config.rights_permission(entry['Permissions']) + except ValueError as e: + logger.error("Permissions contain invalid entry: %r", entry['Permissions']) + return False + # check PathMapped exists with self._storage.acquire_lock("r", path=entry['PathMapped']): item = next(iter(self._storage.discover(entry['PathMapped'])), None) From d58181597308c9b260e7cd83e3c9c28c7edbc8c6 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Mar 2026 12:37:36 +0100 Subject: [PATCH 9/9] sharing: flake8 fixes --- radicale/sharing/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index d4883133..6e2ce07b 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -283,7 +283,7 @@ class BaseSharing: if fieldname not in DB_TYPES_V1: logger.error("sharing database row error, unsupported fieldname found: %r", fieldname) return False - if type(entry[fieldname]) != DB_TYPES_V1[fieldname]: + if type(entry[fieldname]) is not DB_TYPES_V1[fieldname]: logger.error("sharing database entry type error fieldname=%r is %r should %r entry=%r", fieldname, type(fieldname), DB_TYPES_V1[fieldname], entry) return False @@ -302,8 +302,9 @@ class BaseSharing: # permissions try: - permissions = config.rights_permission(entry['Permissions']) - except ValueError as e: + # test + config.rights_permission(entry['Permissions']) + except ValueError: logger.error("Permissions contain invalid entry: %r", entry['Permissions']) return False