From 19e0b5eedb92e8c06415d775ee320b97fd7d0d64 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 1 Oct 2026 13:49:16 +0200 Subject: [PATCH 1/2] List all unsupported file names in v2 push error --- server/mergin/sync/errors.py | 13 +++++++ server/mergin/sync/files.py | 33 ++++++++++------ server/mergin/sync/public_api_v2.yaml | 16 ++++++++ .../mergin/sync/public_api_v2_controller.py | 10 ++++- .../mergin/tests/test_project_controller.py | 4 +- server/mergin/tests/test_public_api_v2.py | 39 ++++++++++++++++++- 6 files changed, 99 insertions(+), 16 deletions(-) diff --git a/server/mergin/sync/errors.py b/server/mergin/sync/errors.py index e12d762f..34b75ebe 100644 --- a/server/mergin/sync/errors.py +++ b/server/mergin/sync/errors.py @@ -92,6 +92,19 @@ def to_dict(self) -> Dict: return data +class UnsupportedFilesDetected(UploadError): + code = "UnsupportedFilesDetected" + + def __init__(self, error: str, unsupported_files: List[str]): + super().__init__(error) + self.unsupported_files = unsupported_files + + def to_dict(self) -> Dict: + data = super().to_dict() + data["unsupported_files"] = self.unsupported_files + return data + + class BigChunkError(ResponseError): code = "BigChunkError" detail = f"Chunk size exceeds maximum allowed size {MAX_CHUNK_SIZE} MB" diff --git a/server/mergin/sync/files.py b/server/mergin/sync/files.py index 880d744f..b7d68425 100644 --- a/server/mergin/sync/files.py +++ b/server/mergin/sync/files.py @@ -168,6 +168,17 @@ class UploadFileSchema(FileSchema): diff = fields.Nested(FileSchema(), many=False, load_default=None) +class UnsupportedFileNamesError(ValidationError): + """Upload changes contain file paths with invalid characters""" + + def __init__(self, paths: List[str]): + self.paths = paths + files = ", ".join(f"'{path}'" for path in paths) + super().__init__( + f"Unsupported files detected: {files}. Please remove the invalid characters." + ) + + class ChangesSchema(ma.Schema): """Schema for upload changes""" @@ -211,28 +222,28 @@ def validate(self, data, **kwargs): if len(set(changes_files)) != len(changes_files): raise ValidationError("Not unique changes") + # collect all unsupported file names so clients can fix them at once + unsupported_files = [] + for file in data["added"] + data["updated"]: + if not is_valid_path(file["path"]): + unsupported_files.append(file["path"]) + diff = file.get("diff") + if diff and not is_valid_path(diff["path"]): + unsupported_files.append(diff["path"]) + if unsupported_files: + raise UnsupportedFileNamesError(unsupported_files) + # check if all files are valid for file in data["added"] + data["updated"]: file_path = file["path"] if is_versioned_file(file_path) and file["size"] == 0: raise ValidationError("File is not valid") - if not is_valid_path(file_path): - raise ValidationError( - f"Unsupported file name detected: '{file_path}'. Please remove the invalid characters." - ) - if not is_supported_extension(file_path): raise ValidationError( f"Unsupported file type detected: '{file_path}'. " f"Please remove the file or try compressing it into a ZIP file before uploading.", ) - - diff = file.get("diff") - if diff and not is_valid_path(diff["path"]): - raise ValidationError( - f"Unsupported file name detected: '{diff['path']}'. Please remove the invalid characters." - ) # new checks must restrict only new files not to block existing projects for file in data["added"]: file_path = file["path"] diff --git a/server/mergin/sync/public_api_v2.yaml b/server/mergin/sync/public_api_v2.yaml index 32b232e7..086ab765 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -396,6 +396,7 @@ paths: schema: anyOf: - $ref: "#/components/schemas/UploadError" + - $ref: "#/components/schemas/UnsupportedFilesDetected" - $ref: "#/components/schemas/TrialExpired" - $ref: "#/components/schemas/StorageLimitHit" - $ref: "#/components/schemas/DataSyncError" @@ -737,6 +738,21 @@ components: example: code: UploadError detail: "Project version could not be created (UploadError)" + UnsupportedFilesDetected: + allOf: + - $ref: "#/components/schemas/CustomError" + type: object + properties: + unsupported_files: + type: array + items: + type: string + example: + code: UnsupportedFilesDetected + detail: "Unsupported files detected: 'notes:draft.txt', 'photos|old/tree.jpg'. Please remove the invalid characters. (UnsupportedFilesDetected)" + unsupported_files: + - "notes:draft.txt" + - "photos|old/tree.jpg" BatchItemError: type: object properties: diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 83cef245..6b143d45 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -30,9 +30,15 @@ ProjectLocked, ProjectVersionExists, StorageLimitHit, + UnsupportedFilesDetected, UploadError, ) -from .files import ChangesSchema, DeltaChangeRespSchema, ProjectFileSchema +from .files import ( + ChangesSchema, + DeltaChangeRespSchema, + ProjectFileSchema, + UnsupportedFileNamesError, +) from .events import SyncEventType from ..audit import emit from ..audit.listeners import actor_context, audit_session_flags @@ -279,6 +285,8 @@ def create_project_version(id): try: ChangesSchema().validate(changes) upload_changes = ChangesSchema().dump(changes) + except UnsupportedFileNamesError as err: + return UnsupportedFilesDetected(err.messages[0], err.paths).response(422) except ValidationError as err: msg = err.messages[0] if type(err.messages) == list else "Invalid input data" return UploadError(error=msg).response(422) diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index a646ff1b..61dcc2e8 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -2712,7 +2712,7 @@ def test_filepath_manipulation(client): assert resp.status_code == 400 assert ( resp.json["detail"] - == f"Unsupported file name detected: '{manipulated_path}'. Please remove the invalid characters." + == f"Unsupported files detected: '{manipulated_path}'. Please remove the invalid characters." ) @@ -2749,7 +2749,7 @@ def test_diff_filepath_manipulation(client): assert resp.status_code == 400 assert ( resp.json["detail"] - == f"Unsupported file name detected: '{manipulated_diff_path}'. Please remove the invalid characters." + == f"Unsupported files detected: '{manipulated_diff_path}'. Please remove the invalid characters." ) diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 7722f54a..76fc6c26 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -62,6 +62,7 @@ ProjectVersionExists, AnotherUploadRunning, StorageLimitHit, + UnsupportedFilesDetected, UploadError, ) from mergin.sync.files import ChangesSchema @@ -920,7 +921,7 @@ def _get_changes_with_diff_added(project_dir): "changes": _get_changes_with_diff_updated(test_project_dir), }, 422, - UploadError.code, + UnsupportedFilesDetected.code, ), # contains already uploaded file ( @@ -971,7 +972,7 @@ def _get_changes_with_diff_added(project_dir): ( {"version": "v1", "changes": _get_changes_with_diff_added(test_project_dir)}, 422, - UploadError.code, + UnsupportedFilesDetected.code, ), ( { @@ -1052,6 +1053,40 @@ def test_create_version(client, data, expected, err_code): assert failure.error_type == "project_push" +def test_create_version_unsupported_file_names(client): + """Test all files with unsupported names are listed in the error response""" + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + changes = _get_changes_with_diff_updated(test_project_dir) + changes["added"] = [ + { + "path": path, + "size": 1234, + "checksum": "9adb76bf81a34880209040ffe5ee262a090b62ab", + "chunks": [], + } + for path in ("notes.txt", "notes:draft.txt", "photos|old/tree.jpg") + ] + invalid_diff_path = changes["updated"][2]["diff"]["path"] + + response = client.post( + f"v2/projects/{project.id}/versions", + json={"version": "v1", "changes": changes, "check_only": True}, + ) + assert response.status_code == 422 + assert response.json["code"] == UnsupportedFilesDetected.code + assert response.json["unsupported_files"] == [ + "notes:draft.txt", + "photos|old/tree.jpg", + invalid_diff_path, + ] + assert response.json["detail"] == ( + f"Unsupported files detected: 'notes:draft.txt', 'photos|old/tree.jpg', '{invalid_diff_path}'. " + "Please remove the invalid characters. (UnsupportedFilesDetected)" + ) + + def test_create_version_failures(client): """Test various project push failures beyond invalid payload""" project = Project.query.filter_by( From 867d843638440f693586e0826504f6950be3df69 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Fri, 2 Oct 2026 12:49:58 +0200 Subject: [PATCH 2/2] Collect unsupported file names in the existing validation loop --- server/mergin/sync/files.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/server/mergin/sync/files.py b/server/mergin/sync/files.py index b7d68425..175c4b3a 100644 --- a/server/mergin/sync/files.py +++ b/server/mergin/sync/files.py @@ -222,28 +222,28 @@ def validate(self, data, **kwargs): if len(set(changes_files)) != len(changes_files): raise ValidationError("Not unique changes") - # collect all unsupported file names so clients can fix them at once - unsupported_files = [] - for file in data["added"] + data["updated"]: - if not is_valid_path(file["path"]): - unsupported_files.append(file["path"]) - diff = file.get("diff") - if diff and not is_valid_path(diff["path"]): - unsupported_files.append(diff["path"]) - if unsupported_files: - raise UnsupportedFileNamesError(unsupported_files) - # check if all files are valid + unsupported_files = [] for file in data["added"] + data["updated"]: file_path = file["path"] if is_versioned_file(file_path) and file["size"] == 0: raise ValidationError("File is not valid") + if not is_valid_path(file_path): + unsupported_files.append(file_path) + if not is_supported_extension(file_path): raise ValidationError( f"Unsupported file type detected: '{file_path}'. " f"Please remove the file or try compressing it into a ZIP file before uploading.", ) + + diff = file.get("diff") + if diff and not is_valid_path(diff["path"]): + unsupported_files.append(diff["path"]) + + if unsupported_files: + raise UnsupportedFileNamesError(unsupported_files) # new checks must restrict only new files not to block existing projects for file in data["added"]: file_path = file["path"]