From 7d580661e7d175cbc2f57137f6ef78bf082a9558 Mon Sep 17 00:00:00 2001 From: jo-pol Date: Tue, 26 May 2026 13:49:36 +0200 Subject: [PATCH 1/7] squash of DANS PR242 up to e24bc721 - EditDatafilesPage: message shows all conflicting files, not just the first - a file with a directory conflicting with an existing full path is rejected - files with a full path that conflicts with an existing directory will get a sequence number added - additional unit test - manual test script - scripts to detect (latest version of) datasets with conflicting directory paths Note that directory does not just mean directoryLabel, but also the parents in the directoryLabel --- doc/sphinx-guides/source/api/native-api.rst | 2 +- .../source/user/dataset-management.rst | 2 +- .../dirs-duplicating-files/find_duplicates.py | 107 ++++++++++ .../find_duplicates.sql | 35 ++++ .../dirs-duplicating-files/find_dv_ids.sql | 35 ++++ .../dirs-duplicating-files/test-apis.py | 158 +++++++++++++++ .../iq/dataverse/EditDatafilesPage.java | 4 +- .../harvard/iq/dataverse/api/Datasets.java | 5 +- .../edu/harvard/iq/dataverse/api/Files.java | 6 +- .../iq/dataverse/ingest/IngestUtil.java | 79 ++++++-- src/main/java/propertyFiles/Bundle.properties | 2 +- .../iq/dataverse/ingest/IngestUtilTest.java | 187 +++++++++++++++++- 12 files changed, 601 insertions(+), 21 deletions(-) create mode 100644 scripts/issues/dirs-duplicating-files/find_duplicates.py create mode 100644 scripts/issues/dirs-duplicating-files/find_duplicates.sql create mode 100644 scripts/issues/dirs-duplicating-files/find_dv_ids.sql create mode 100644 scripts/issues/dirs-duplicating-files/test-apis.py diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 1a1604886c6..4a7760be882 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -3245,7 +3245,7 @@ The fully expanded example above (without environment variables) looks like this .. code-block:: bash - curl -H "X-Dataverse-key:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X POST "https://demo.dataverse.org/api/datasets/:persistentId/files/metadata?:persistentId=doi:10.5072/FK2/J8SJZB" --upload-file file-metadata-update.json + curl -H "X-Dataverse-key:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X POST "https://demo.dataverse.org/api/datasets/:persistentId/files/metadata?persistentId=doi:10.5072/FK2/J8SJZB" --upload-file file-metadata-update.json The ``file-metadata-update.json`` file should contain a JSON array of objects, each representing a file to be updated. Here's an example structure: diff --git a/doc/sphinx-guides/source/user/dataset-management.rst b/doc/sphinx-guides/source/user/dataset-management.rst index 9c389ef4be3..ebe9270e79f 100755 --- a/doc/sphinx-guides/source/user/dataset-management.rst +++ b/doc/sphinx-guides/source/user/dataset-management.rst @@ -145,7 +145,7 @@ Beginning with Dataverse Software 5.0, the way a Dataverse installation handles - Files with the same checksum can be included in a dataset, even if the files are in the same directory. - Files with the same filename can be included in a dataset as long as the files are in different directories. - If a user uploads a file to a directory where a file already exists with that directory/filename combination, the Dataverse installation will adjust the file path and names by adding "-1" or "-2" as applicable. This change will be visible in the list of files being uploaded. -- If the directory or name of an existing or newly uploaded file is edited in such a way that would create a directory/filename combination that already exists, the Dataverse installation will display an error. +- If the directory or name of an existing or newly uploaded file is edited in such a way that would create a directory/filename combination that already exists, or the new directory/filename exists as directory, the Dataverse installation will display an error. - If a user attempts to replace a file with another file that has the same checksum, an error message will be displayed and the file will not be able to be replaced. - If a user attempts to replace a file with a file that has the same checksum as a different file in the dataset, a warning will be displayed. diff --git a/scripts/issues/dirs-duplicating-files/find_duplicates.py b/scripts/issues/dirs-duplicating-files/find_duplicates.py new file mode 100644 index 00000000000..89290311cc9 --- /dev/null +++ b/scripts/issues/dirs-duplicating-files/find_duplicates.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +import argparse +import psycopg2 +from pathlib import Path +from textwrap import dedent + +def read_sql(path: Path) -> str: + text = path.read_text(encoding="utf-8") + return "\n".join( + line for line in text.splitlines() if not line.lstrip().startswith("\\") + ) + + +def fetch_dv_ids(conn, find_dv_ids_sql: str) -> list[int]: + with conn.cursor() as cur: + cur.execute(find_dv_ids_sql) + rows = cur.fetchall() + + # Query returns dv_id as first selected column in your file. + return [int(row[0]) for row in rows] + + +def fetch_dataset_info(conn, datasetversion_id: int): + dataset_query = """ + SELECT dso.protocol, dso.authority, dso.identifier, dv.versionnumber, dv.minorversionnumber + FROM datasetversion dv + JOIN dvobject dso ON dso.id = dv.dataset_id + WHERE dv.id = %s \ + """ + with conn.cursor() as cur: + cur.execute(dataset_query, (datasetversion_id,)) + return cur.fetchone() + return None + + +def run_find_duplicates(conn, find_duplicates_sql: str): + last_dv_id = None + last_info = ("", "", "", "", "") + + with conn.cursor() as cur: + cur.execute(find_duplicates_sql) + cols = [d[0] for d in cur.description] + + extra_cols = ["protocol", "authority", "dataset_id", "versionnumber", "minorversionnumber"] + print("\t".join(cols + extra_cols)) + + for row in cur: + dv_id = int(row[0]) # datasetversion_id + + if dv_id != last_dv_id: + fetched = fetch_dataset_info(conn, dv_id) + last_info = fetched if fetched is not None else ("", "", "", "", "") + last_dv_id = dv_id + + print("\t".join("" if v is None else str(v) for v in (tuple(row) + tuple(last_info)))) + + +def main(): + class RawDefaultsFormatter( + argparse.ArgumentDefaultsHelpFormatter, + argparse.RawDescriptionHelpFormatter, + ): + pass + + parser = argparse.ArgumentParser( + description=dedent(""" + Execute as owner of dvndb. + + `find_duplicates.sql` is executed for dv_ids returned by `find_dv_ids.sql`. + `find_dv_ids.sql` returns the latest version per dataset. + """), + formatter_class=RawDefaultsFormatter, + ) + parser.add_argument("--min-id", type=int, default=0, help="first dataset-version-id examined by `find_dv_ids.sql`") + parser.add_argument("--nr-of-ids", type=int, default=50, help="number of ID's returned by `find_dv_ids.sql`") + args = parser.parse_args() + conn_kwargs = {"dbname": 'dvndb'} + + script_dir = Path(__file__).resolve().parent + + dup_sql_raw = read_sql(script_dir / "find_duplicates.sql") + + dv_sql = read_sql(script_dir / "find_dv_ids.sql") + dv_sql = dv_sql.replace(":min_id", str(args.min_id)) + dv_sql = dv_sql.replace(":nr_of_ids", str(args.nr_of_ids)) + + try: + with psycopg2.connect(**conn_kwargs) as conn: + dv_ids = fetch_dv_ids(conn, dv_sql) + + if not dv_ids: + print("No dv_id values returned by find_dv_ids.sql") + return + + ids_csv = ",".join(str(i) for i in dv_ids) + print(f"dataset version ids: {ids_csv}") + run_find_duplicates(conn, dup_sql_raw.replace(":ids", ids_csv)) + except psycopg2.OperationalError as e: + msg = str(e) + if "no password supplied" in msg.lower(): + parser.print_help() + raise SystemExit(2) + print(f"Database connection failed: {e}") + raise SystemExit(1) + +if __name__ == "__main__": + main() diff --git a/scripts/issues/dirs-duplicating-files/find_duplicates.sql b/scripts/issues/dirs-duplicating-files/find_duplicates.sql new file mode 100644 index 00000000000..e96420e9684 --- /dev/null +++ b/scripts/issues/dirs-duplicating-files/find_duplicates.sql @@ -0,0 +1,35 @@ +\set ids 5,7,9 +WITH dir_ancestors AS ( + SELECT DISTINCT + datasetversion_id, + array_to_string((string_to_array(path, '/'))[1:n], '/') AS path + FROM ( + SELECT DISTINCT + datasetversion_id, + NULLIF(BTRIM(directorylabel), '') AS path + FROM filemetadata + WHERE datasetversion_id IN (:ids) + AND NULLIF(BTRIM(directorylabel), '') IS NOT NULL + ) dirs + CROSS JOIN LATERAL generate_series( + 1, cardinality(string_to_array(path, '/')) + ) AS g(n) + ), + file_paths AS ( + SELECT DISTINCT + datasetversion_id, + CASE + WHEN NULLIF(BTRIM(directorylabel), '') IS NULL THEN label + ELSE NULLIF(BTRIM(directorylabel), '') || '/' || label + END AS path + FROM filemetadata + WHERE datasetversion_id IN (:ids) + ) +SELECT datasetversion_id, path +FROM dir_ancestors + +INTERSECT + +SELECT datasetversion_id, path +FROM file_paths +ORDER BY datasetversion_id, path; diff --git a/scripts/issues/dirs-duplicating-files/find_dv_ids.sql b/scripts/issues/dirs-duplicating-files/find_dv_ids.sql new file mode 100644 index 00000000000..52cb99aef6e --- /dev/null +++ b/scripts/issues/dirs-duplicating-files/find_dv_ids.sql @@ -0,0 +1,35 @@ +\set min_id 0 +\set nr_of_ids 50 + +WITH ranked AS ( + SELECT + dso.id AS dso_id, + dso.protocol, + dso.authority, + dso.identifier, + dv.id AS dv_id, + dv.versionnumber, + dv.minorversionnumber, + ROW_NUMBER() OVER ( + PARTITION BY dso.id + ORDER BY + dv.versionnumber DESC, + dv.minorversionnumber DESC, + dv.id DESC + ) AS rn + FROM datasetversion dv + JOIN dvobject dso ON dso.id = dv.dataset_id +) +SELECT + dv_id, + dso_id, + protocol, + authority, + identifier, + versionnumber, + minorversionnumber +FROM ranked +WHERE rn = 1 + AND dv_id >= :min_id +ORDER BY dv_id + LIMIT :nr_of_ids; diff --git a/scripts/issues/dirs-duplicating-files/test-apis.py b/scripts/issues/dirs-duplicating-files/test-apis.py new file mode 100644 index 00000000000..2b60b884cd0 --- /dev/null +++ b/scripts/issues/dirs-duplicating-files/test-apis.py @@ -0,0 +1,158 @@ +import requests +from requests_toolbelt.multipart.encoder import MultipartEncoder +from datetime import datetime +import json + +########################## configuration for a draft dataset without files + +dataverse_server = 'https://dev.archaeology.datastations.nl' +api_key = '5623d6e3-bc94-40a5-8de0-8ebdf9f58cbc' +persistentId = 'doi:10.5072/DAR/HBGPN5' + +#################### +print (' preparation: add file foo/bar ' + ('-' * 40)) + +url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) +files = {'file': ('bar', ('content2: %s' % datetime.now()))} +jason_data = {"jsonData": json.dumps({"directoryLabel": "foo"})}# conflicting dir +r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +print (r.status_code) +print (r.json()) + +#################### +print (' preparation: add file foo.tab/bar ' + ('-' * 40)) + +url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) +files = {'file': ('bar', ('content2: %s' % datetime.now()))} +jason_data = {"jsonData": json.dumps({"directoryLabel": "foo.tab"})}# conflicting dir +r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +print (r.status_code) +print (r.json()) + +#################### +print (' preparation: add file x to have a file to change ' + ('-' * 40)) + +### +url = '%s/api/datasets/:persistentId/add?&persistentId=%s' % (dataverse_server, persistentId) +unique_content = 'content2: %s' % datetime.now() +files = {'file': ('x', unique_content)} +jason_data = {"jsonData": json.dumps({"label": "x"})} +r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +print (r.status_code) +print (r.json()) + +file_id = r.json()['data']['files'][0]['dataFile']['id'] + +#################### +print (' file conflicting with existing dir gets sequence number ' + ('-' * 40)) + +### +url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) +files = {'file': ('foo', ('content2: %s' % datetime.now()))} +jason_data = {"jsonData": json.dumps({"label": "foo"})} +r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) + +print (r.json()) +print (r.status_code) + +#################### +print (' tabular file conflicting with existing dir gets seq nr once converted to .tab ' + ('-' * 40)) + +url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) +files = {'file': ('foo.csv', ('header1,header2\nvalue1,%s' % datetime.now()))} +jason_data = {"jsonData": json.dumps({"label": "foo.csv"})} +r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +print (r.status_code) +print (r.json()) + +#################### +print (' files API metadata: dir foo/bar conflicts with previously created file foo/bar: returns bad-request ' + ('-' * 40)) + +### files API https://guides.dataverse.org/en/latest/api/native-api.html#updating-file-metadata +url = f'{dataverse_server}/api/files/{file_id}/metadata' +files = {'jsonData': (None, '{"directoryLabel": "foo/bar", "label": "files-api.txt"} ' + ('-' * 40))} +r = requests.post(url, headers={'X-Dataverse-key': api_key}, files=files, verify=False) + +print(r.status_code) +print(r.text) + +#################### +print ('datasets API update existing file into name conflicting with existing dir: returns bad-request ' + ('-' * 40)) + +### datasets API https://guides.dataverse.org/en/latest/api/native-api.html#update-file-metadata +url = f'{dataverse_server}/api/datasets/:persistentId/files/metadata?key={api_key}&persistentId={persistentId}' +json_content = [{"dataFileId": file_id, "directoryLabel": "foo/bar", "label": "datasets-api.txt"}] +headers = {'X-Dataverse-key': api_key, 'Content-Type': 'application/json'} +r = requests.post(url, headers=headers, json=json_content, verify=False) + +print(r.status_code) +print(r.text) + +#################### +print ('datasets API add file conflicting with existing file: gets seq nr ' + ('-' * 40)) + +url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) +files = {'file': ('fox', ('content2: %s' % datetime.now()))} +jason_data = {"jsonData": json.dumps({"label": "x"})} +r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) + +print (r.json()) +print (r.status_code) + +#################### +print ('dataset API add dir conflicting with existing file: returns bad-request ' + ('-' * 40)) + +url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) +files = {'file': ('foo', ('content2: %s' % datetime.now()))} +jason_data = {"jsonData": json.dumps({"label": "dir-conflicts-with-file.txt", "directoryLabel": "foo/bar"})} +r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) + +print (r.json()) +print (r.status_code) + +#################### +print (' datasets API: another file on existing dir is OK ' + ('-' * 40)) + +url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) +files = {'file': ('beer', ('content2: %s' % datetime.now()))} +jason_data = {"jsonData": json.dumps({"directoryLabel": "foo"})}# conflicting dir +r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +print (r.status_code) +print (r.json()) + +#################### +print (' datasets API: a file with different capitalization is OK ' + ('-' * 40)) + +url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) +files = {'file': ('Beer', ('content2: %s' % datetime.now()))} +jason_data = {"jsonData": json.dumps({"directoryLabel": "foo"})}# conflicting dir +r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +print (r.status_code) +print (r.json()) + +#################### +print (' files API replace: dir foo/bar conflicts with previously created file: returns bad-request ' + ('-' * 40)) + +url = f'{dataverse_server}/api/files/{file_id}/replace' +files = { + 'jsonData': (None, '{"directoryLabel": "foo/bar", "label": "x", "forceReplace":true} ' + ('-' * 40)), + 'file': ('foo', ('content2: %s' % datetime.now())) +} +r = requests.post(url, headers={'X-Dataverse-key': api_key}, files=files, verify=False) + +print(r.status_code) +print(r.text) + +#################### +# not configured on DANS VM? Might also have no added value over previous test. +# +# print (' datasets API remote file: file foo conflicts with previously created dir: returns bad-request ???? ' + ('-' * 40)) +# +# url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) +# files = { +# 'jsonData': (None, '{"directoryLabel": "foo/bar", "label": "x", "forceReplace":true, "description":"A remote image.","storageIdentifier":"file://themes/custom/qdr/images/01234567890-012345678901","checksumType":"MD5","md5Hash":"509ef88afa907eaf2c17c1c8d8fde77e","fileName":"testlogo.png","mimeType":"image/png"} ' + ('-' * 40)), +# } +# r = requests.post(url, headers={'X-Dataverse-key': api_key}, files=files, verify=False) +# +# print(r.status_code) +# print(r.text) diff --git a/src/main/java/edu/harvard/iq/dataverse/EditDatafilesPage.java b/src/main/java/edu/harvard/iq/dataverse/EditDatafilesPage.java index 46d17d05363..f2b263f7ab3 100644 --- a/src/main/java/edu/harvard/iq/dataverse/EditDatafilesPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/EditDatafilesPage.java @@ -1068,7 +1068,9 @@ public String save() { storageSizeStr = null; // Let this re-calculate after the calling save() Collection duplicates = IngestUtil.findDuplicateFilenames(workingVersion, newFiles); if (!duplicates.isEmpty()) { - JH.addMessage(FacesMessage.SEVERITY_ERROR, BundleUtil.getStringFromBundle("dataset.message.filesFailure"), BundleUtil.getStringFromBundle("dataset.message.editMetadata.duplicateFilenames", new ArrayList<>(duplicates))); + var arguments = List.of(String.join(", ", duplicates)); + JH.addMessage(FacesMessage.SEVERITY_ERROR, BundleUtil.getStringFromBundle("dataset.message.filesFailure"), BundleUtil.getStringFromBundle("dataset.message.editMetadata.duplicateFilenames", + arguments)); return null; } if (!saveEnabled) { diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java index 136b6dbb69b..183b1e9b7ac 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -4899,9 +4899,10 @@ public Response updateMultipleFileMetadata(@Context ContainerRequestContext crc, List fmdListMinusCurrentFile = new ArrayList<>(fileMetadataMapCopy.values()); - if (IngestUtil.conflictsWithExistingFilenames(pathPlusFilename, fmdListMinusCurrentFile)) { + var conflictingPart = IngestUtil.findConflictingPathPart(pathPlusFilename, fmdListMinusCurrentFile); + if (conflictingPart.isPresent()) { return error(BAD_REQUEST, BundleUtil.getStringFromBundle("files.api.metadata.update.duplicateFile", - Arrays.asList(pathPlusFilename))); + conflictingPart.stream().toList())); } // Apply optional params diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Files.java b/src/main/java/edu/harvard/iq/dataverse/api/Files.java index 0a1b19985a4..2aad5533c00 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Files.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Files.java @@ -512,8 +512,10 @@ public Response updateFileMetadata(@Context ContainerRequestContext crc, @FormDa } } - if (IngestUtil.conflictsWithExistingFilenames(pathPlusFilename, fmdListMinusCurrentFile)) { - return error(BAD_REQUEST, BundleUtil.getStringFromBundle("files.api.metadata.update.duplicateFile", Arrays.asList(pathPlusFilename))); + var conflictingPart = IngestUtil.findConflictingPathPart(pathPlusFilename, fmdListMinusCurrentFile); + if (conflictingPart.isPresent()) { + return error(BAD_REQUEST, BundleUtil.getStringFromBundle("files.api.metadata.update.duplicateFile", + conflictingPart.stream().toList())); } optionalFileParams.addOptionalParams(upFmd); diff --git a/src/main/java/edu/harvard/iq/dataverse/ingest/IngestUtil.java b/src/main/java/edu/harvard/iq/dataverse/ingest/IngestUtil.java index 3d30f7e6ec3..a2eb0f70ec3 100644 --- a/src/main/java/edu/harvard/iq/dataverse/ingest/IngestUtil.java +++ b/src/main/java/edu/harvard/iq/dataverse/ingest/IngestUtil.java @@ -31,11 +31,10 @@ import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.logging.Logger; -import jakarta.json.Json; -import jakarta.json.JsonArrayBuilder; -import jakarta.json.JsonObjectBuilder; + import org.dataverse.unf.UNFUtil; import org.dataverse.unf.UnfException; @@ -60,7 +59,8 @@ public static void checkForDuplicateFileNamesFinal(DatasetVersion version, List< // Step 1: create list of existing path names from all FileMetadata in the DatasetVersion // unique path name: directoryLabel + file separator + fileLabel - Set pathNamesExisting = existingPathNamesAsSet(version, ((fileToReplace == null) ? null : fileToReplace.getFileMetadata())); + Set pathNamesExisting = existingPathNamesAsSet(version, ((fileToReplace == null) ? null : fileToReplace.getFileMetadata())); + var existingWithoutNew = new HashSet<>(pathNamesExisting); // avoid side effect of duplicateFilenameCheck // Step 2: check each new DataFile against the list of path names, if a duplicate create a new unique file name for (Iterator dfIt = newFiles.iterator(); dfIt.hasNext();) { @@ -68,11 +68,23 @@ public static void checkForDuplicateFileNamesFinal(DatasetVersion version, List< fm.setLabel(duplicateFilenameCheck(fm, pathNamesExisting)); } + // Step 3: get all potential new directories + var newDirs = new HashSet(); + for (Iterator dfIt = newFiles.iterator(); dfIt.hasNext();) { + FileMetadata fm = dfIt.next().getFileMetadata(); + newDirs.addAll(getPathAndParents(fm.getDirectoryLabel())); + } + // Step 4: check if new directories do not yet exist as filename + newDirs.retainAll(existingWithoutNew); + if (!newDirs.isEmpty()) { + logger.warning("Incoming file(s) have one or more directories conflicting with an existing path: " + newDirs); + newFiles.clear(); + } } /** * Checks if the unique file path of the supplied fileMetadata is already on - * the list of the existing files; and if so, keeps generating a new name + * the list of the existing files and directories; and if so, keeps generating a new name * until it is unique. Returns the final file name. (i.e., it only modifies * the filename, and not the folder name, in order to achieve uniqueness) * @@ -84,12 +96,14 @@ public static String duplicateFilenameCheck(FileMetadata fileMetadata, Set fileMetadatas) { - List filePathsAndNames = getPathsAndFileNames(fileMetadatas); - return filePathsAndNames.contains(pathPlusFilename); + public static Optional findConflictingPathPart(String newPathPlusFilename, List fileMetadatas) { + var newPathAndParents = getPathAndParents(newPathPlusFilename); + var existingPathsAndParents = getPathsAndFileNames(fileMetadatas); + for (var pathOrDir : existingPathsAndParents) { + if (newPathAndParents.contains(pathOrDir)) { + return Optional.of(pathOrDir); + } + } + return Optional.empty(); } + /** * Given a DatasetVersion, and the newFiles about to be added to the * version iterate across all the files (including their @@ -164,9 +186,11 @@ private static Set findDuplicates(Collection collection) { /** * @return A List of Strings in the form of path/to/file.txt + * path and path/to are also added to the list, but only once when they are parents for multiple files. */ public static List getPathsAndFileNames(List fileMetadatas) { List allFileNamesWithPaths = new ArrayList<>(); + Set allPaths = new HashSet<>(); for (FileMetadata fileMetadata : fileMetadatas) { String directoryLabel = fileMetadata.getDirectoryLabel(); String path = ""; @@ -174,11 +198,30 @@ public static List getPathsAndFileNames(List fileMetadatas path = directoryLabel + "/"; } String pathAndfileName = path + fileMetadata.getLabel(); - allFileNamesWithPaths.add(pathAndfileName); + allFileNamesWithPaths.add((pathAndfileName)); + allPaths.addAll(getPathAndParents(directoryLabel)); } + allFileNamesWithPaths.addAll(allPaths); return allFileNamesWithPaths; } + private static List getPathAndParents(String directory) { + List paths = new ArrayList<>(); + if (directory == null || directory.isEmpty()) { + return paths; + } + String current = directory; + while (current != null && !current.isEmpty()) { + paths.add(current); + int lastSlash = current.lastIndexOf('/'); + if (lastSlash == -1) { + break; + } + current = current.substring(0, lastSlash); + } + return paths; + } + // This method is called on a single file, when we need to modify the name // of an already ingested/persisted datafile. For ex., when we have converted // a file to tabular data, and want to update the extension accordingly. @@ -187,6 +230,7 @@ public static void modifyExistingFilename(DatasetVersion version, FileMetadata f // unique path name: directoryLabel + file separator + fileLabel fileMetadata.setLabel(newFilename); Set pathNamesExisting = existingPathNamesAsSet(version, fileMetadata); + pathNamesExisting.addAll(dirsOfFullPaths(pathNamesExisting)); fileMetadata.setLabel(duplicateFilenameCheck(fileMetadata, pathNamesExisting)); } @@ -240,6 +284,17 @@ public static String generateNewFileName(final String fileName) { return newName; } + public static Set dirsOfFullPaths(Collection fullPaths) { + Set dirs = new HashSet<>(); + fullPaths.forEach(fullPath -> { + int lastSlash = fullPath.lastIndexOf('/'); + if (lastSlash != -1) { + dirs.addAll(getPathAndParents(fullPath.substring(0, lastSlash))); + } + }); + return dirs; + } + // list of existing unique path name: directoryLabel + file separator + fileLabel public static Set existingPathNamesAsSet(DatasetVersion version) { return existingPathNamesAsSet(version, null); diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 9a8c97fe429..54d95b9cbe0 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -1721,7 +1721,7 @@ dataset.message.uploadFilesSingle.message=All file types are supported for uploa dataset.message.uploadFilesMultiple.message=Multiple file upload/download methods are available for this dataset. Once you upload a file using one of these methods, your choice will be locked in for this dataset. dataset.message.editMetadata.label=Edit Dataset Metadata dataset.message.editMetadata.message=Add more metadata about this dataset to help others easily find it. -dataset.message.editMetadata.duplicateFilenames=Duplicate filenames: {0} +dataset.message.editMetadata.duplicateFilenames=File path and name duplicate those of an existing file or directory: {0} dataset.message.editMetadata.invalid.TOUA.message=Datasets with restricted files are required to have Request Access enabled or Terms of Access to help people access the data. Please edit the dataset to confirm Request Access or provide Terms of Access to be in compliance with the policy. dataset.message.toua.invalid=Terms of Use and Access are invalid. You must enable request access or add terms of access in datasets with restricted files. diff --git a/src/test/java/edu/harvard/iq/dataverse/ingest/IngestUtilTest.java b/src/test/java/edu/harvard/iq/dataverse/ingest/IngestUtilTest.java index 955070a662a..19f839ce55e 100644 --- a/src/test/java/edu/harvard/iq/dataverse/ingest/IngestUtilTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/ingest/IngestUtilTest.java @@ -13,16 +13,20 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Date; +import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Stream; import jakarta.validation.ConstraintViolation; import org.dataverse.unf.UNFUtil; import org.dataverse.unf.UnfException; import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.as; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; public class IngestUtilTest { @@ -376,6 +380,158 @@ public void testCheckForDuplicateFileNamesWithDirectories() throws Exception { assertTrue(file3NameAltered); } + @Test + /** + * Test adding a file with a full path duplicating an existing directory + * or with an ancestor that duplicates the full path of an existing file. + */ + public void testCheckFilesDuplicatingDirectories() throws Exception { + + class Params { + final int iteration; + final FileMetadata fmd; + + public Params(int iteration, String dir, String fileLabel) { + this.iteration = iteration; + + var datafile = new DataFile("application/octet-stream"); + + fmd = new FileMetadata(); + fmd.setLabel(fileLabel); + fmd.setDirectoryLabel(dir); + fmd.setDataFile(datafile); + datafile.getFileMetadatas().add(fmd); + } + } + // each iteration adds one or more files to the dataset and + // verifies what names would be added if all would be added (again) + // the adjusted names are used to add files in the next iteration + var paramsList = Arrays.asList( + new Params(0, "foo","bar"), + new Params(1, null, "foo"), // file/dir conflict: "foo" + new Params(1, null, "bar"), + new Params(2, null, "bar"), + new Params(3, "bar/foo","pint"), // dir/file conflict: "bar" + new Params(4, "bar/foo/pint", "beer") // dir-ancestor/file conflict: "bar/foo/pint" + ); + // more than 10 List.of elements cause subtle type problems for the assertions + var expectedFilesInDataset = Arrays.asList( + List.of("foo/bar"), + List.of("foo/bar", "null/foo-1", "null/bar"), + List.of("foo/bar", "null/foo-1", "null/bar", "null/bar-2"), + List.of("foo/bar", "null/foo-1", "null/bar", "null/bar-2", "bar/foo/pint"), + List.of("foo/bar", "null/foo-1", "null/bar", "null/bar-2", "bar/foo/pint", "bar/foo/pint/beer") + ); + var expectedLabelsAfterTest = Arrays.asList( + List.of( "foo/bar-1", "null/foo-1", "null/bar", "null/bar-1", "bar/foo/pint", "bar/foo/pint/beer"), + List.of( "foo/bar-1", "null/foo-2", "null/bar-1", "null/bar-2", "bar/foo/pint", "bar/foo/pint/beer"), + List.of( "foo/bar-1", "null/foo-2", "null/bar-1", "null/bar-3", "bar/foo/pint", "bar/foo/pint/beer"), + List.of( "foo/bar-1", "null/foo-2", "null/bar-1", "null/bar-3", "bar/foo/pint-1", "bar/foo/pint/beer"), + List.of( "foo/bar-1", "null/foo-2", "null/bar-1", "null/bar-3", "bar/foo/pint-1", "bar/foo/pint/beer-1") + ); + List> expectedNewDataFilesAfterTest = Arrays.asList( + List.of( "foo/bar-1", "null/foo-1", "null/bar","null/bar-1", "bar/foo/pint", "bar/foo/pint/beer"), + List.of(), + List.of(), // ??? + List.of(), + List.of() + ); + + // create dataset version + var dataset = makeDataset(); + var datasetVersion = dataset.getLatestVersion(); + datasetVersion.setFileMetadatas(new ArrayList<>()); + + for (int i=0; i(); + paramsList.forEach(p -> newDataFiles.add(p.fmd.getDataFile())); + // select files to add to the dataset for the current iteration + var ii = i; + var filesToAdd = paramsList.stream() + .filter(p -> p.iteration == ii) + .map(p -> p.fmd).toList(); + // add files to dataset + for (var fmd: filesToAdd) { + var fmdClone = new FileMetadata(); + fmdClone.setId(MocksFactory.nextId()); + fmdClone.setLabel(fmd.getLabel()); + fmdClone.setDirectoryLabel(fmd.getDirectoryLabel()); + fmdClone.setDatasetVersion(fmd.getDatasetVersion()); + if (fmd.getDataFile() != null) { + var df = fmd.getDataFile(); + var dfClone = new DataFile(df.getContentType()); + fmdClone.setDataFile(dfClone); + dfClone.getFileMetadatas().add(fmdClone); + } + datasetVersion.getFileMetadatas().add(fmdClone); + fmdClone.setDatasetVersion(datasetVersion); + } + + // precondition + var actualFilesInDataset = datasetVersion.getFileMetadatas().stream() + .map(fmd -> fmd.getDirectoryLabel() + "/" + fmd.getLabel()).toList(); + assertThat(actualFilesInDataset) + .withFailMessage("expectedFilesInDataset %d \n expected %s \n but got %s", i, expectedFilesInDataset.get(i), actualFilesInDataset) + .containsExactlyInAnyOrderElementsOf(expectedFilesInDataset.get(i)); + + // method under test + IngestUtil.checkForDuplicateFileNamesFinal(datasetVersion, newDataFiles, null); + + // postconditions + var actualPaths = paramsList.stream() + .map(p -> p.fmd.getDirectoryLabel() + "/" + p.fmd.getLabel()).toList(); + assertThat(actualPaths) + .withFailMessage("expectedLabelsAfterTest %d \n expected %s \n but got %s", i, expectedLabelsAfterTest.get(i), actualPaths) + .containsExactlyInAnyOrderElementsOf(expectedLabelsAfterTest.get(i)); + var actualNewDataFiles = newDataFiles.stream() + .map(p -> p.getFileMetadata().getDirectoryLabel() + "/" + p.getFileMetadata().getLabel()).toList(); + assertThat(actualNewDataFiles) + .withFailMessage("expectedNewDataFilesAfterTest %d \n expected %s \n but got %s", i, expectedNewDataFilesAfterTest.get(i), actualNewDataFiles) + .containsExactlyInAnyOrderElementsOf(expectedNewDataFilesAfterTest.get(i)); + + } + } + + @Test + /** + * Test adding files to a dataset having a file with a full path duplicating a directory. + */ + public void testExistingFilesDuplicatingDirectories() throws Exception { + + // create dataset version + var dataset = makeDataset(); + var datasetVersion = dataset.getLatestVersion(); + datasetVersion.setFileMetadatas(new ArrayList<>()); + + // add files to dataset + Stream.of( + Arrays.asList("foo","bar"), + Arrays.asList(null, "foo"), // file/dir conflict: "foo" + Arrays.asList(null, "bar"), + Arrays.asList("bar/foo","pint"), // dir/file conflict: "bar" + Arrays.asList("bar/foo/pint", "beer") // subdir/file conflict: "bar/foo/pint" + ).forEach(l -> { + var dir = l.get(0); + var fileLabel = l.get(1); + var datafile = new DataFile("application/octet-stream"); + var fmd = new FileMetadata(); + fmd.setId(MocksFactory.nextId()); + fmd.setLabel(fileLabel); + fmd.setDirectoryLabel(dir); + fmd.setDataFile(datafile); + datafile.getFileMetadatas().add(fmd); + + // add file to dataset + datasetVersion.getFileMetadatas().add(fmd); + fmd.setDatasetVersion(datasetVersion); + }); + + // EditDataFilesPage.save() would create an error message if result is not empty + var duplicates = IngestUtil.findDuplicateFilenames(datasetVersion, List.of()); + + assertThat(duplicates).containsExactlyInAnyOrderElementsOf(List.of("bar", "foo", "bar/foo/pint")); + } + @Test /** * Test tabular files (e.g., .dta) are changed when .tab files with the same @@ -727,7 +883,36 @@ public void renameFileToSameName() { FileMetadata file2 = new FileMetadata(); file2.setLabel("README2.md"); List fileMetadatas = Arrays.asList(file1, file2); - assertTrue(IngestUtil.conflictsWithExistingFilenames(pathPlusFilename, fileMetadatas)); + assertThat(IngestUtil.findConflictingPathPart(pathPlusFilename, fileMetadatas)) + .hasValue("README.md"); + } + + @Test + public void addDirConflictingWithFile() { + FileMetadata fmd = new FileMetadata(); + fmd.setDirectoryLabel("foo"); + fmd.setLabel("bar"); + var fileMetadatas = Arrays.asList(fmd); + assertThat(IngestUtil.findConflictingPathPart("foo/bar/pint", fileMetadatas)) + .hasValue("foo/bar"); + } + + @Test + public void addParentDirConflictingWithFile() { + FileMetadata fmd = new FileMetadata(); + fmd.setLabel("foo"); + var fileMetadatas = Arrays.asList(fmd); + assertThat(IngestUtil.findConflictingPathPart("foo/bar/pint", fileMetadatas)) + .hasValue("foo"); + } + + @Test + public void noConflict() { + FileMetadata fmd = new FileMetadata(); + fmd.setLabel("foo"); + var fileMetadatas = Arrays.asList(fmd); + assertThat(IngestUtil.findConflictingPathPart("bar", fileMetadatas)) + .isEmpty(); } } From 79e15ce022f7dc7a5be7d31ffad64d27988da6f8 Mon Sep 17 00:00:00 2001 From: jo-pol Date: Tue, 26 May 2026 14:33:29 +0200 Subject: [PATCH 2/7] renamed directory in scripts/issues --- .../issues/{dirs-duplicating-files => 12407}/find_duplicates.py | 0 .../issues/{dirs-duplicating-files => 12407}/find_duplicates.sql | 0 scripts/issues/{dirs-duplicating-files => 12407}/find_dv_ids.sql | 0 scripts/issues/{dirs-duplicating-files => 12407}/test-apis.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename scripts/issues/{dirs-duplicating-files => 12407}/find_duplicates.py (100%) rename scripts/issues/{dirs-duplicating-files => 12407}/find_duplicates.sql (100%) rename scripts/issues/{dirs-duplicating-files => 12407}/find_dv_ids.sql (100%) rename scripts/issues/{dirs-duplicating-files => 12407}/test-apis.py (100%) diff --git a/scripts/issues/dirs-duplicating-files/find_duplicates.py b/scripts/issues/12407/find_duplicates.py similarity index 100% rename from scripts/issues/dirs-duplicating-files/find_duplicates.py rename to scripts/issues/12407/find_duplicates.py diff --git a/scripts/issues/dirs-duplicating-files/find_duplicates.sql b/scripts/issues/12407/find_duplicates.sql similarity index 100% rename from scripts/issues/dirs-duplicating-files/find_duplicates.sql rename to scripts/issues/12407/find_duplicates.sql diff --git a/scripts/issues/dirs-duplicating-files/find_dv_ids.sql b/scripts/issues/12407/find_dv_ids.sql similarity index 100% rename from scripts/issues/dirs-duplicating-files/find_dv_ids.sql rename to scripts/issues/12407/find_dv_ids.sql diff --git a/scripts/issues/dirs-duplicating-files/test-apis.py b/scripts/issues/12407/test-apis.py similarity index 100% rename from scripts/issues/dirs-duplicating-files/test-apis.py rename to scripts/issues/12407/test-apis.py From b14a4bc971c5979782e6de4b4cdc76df08e44d27 Mon Sep 17 00:00:00 2001 From: jo-pol Date: Thu, 28 May 2026 09:26:52 +0200 Subject: [PATCH 3/7] github-advanced-security log warning --- scripts/issues/12407/test-apis.py | 80 +++++++++++++++---------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/scripts/issues/12407/test-apis.py b/scripts/issues/12407/test-apis.py index 2b60b884cd0..9b7c88deb73 100644 --- a/scripts/issues/12407/test-apis.py +++ b/scripts/issues/12407/test-apis.py @@ -15,9 +15,9 @@ url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) files = {'file': ('bar', ('content2: %s' % datetime.now()))} jason_data = {"jsonData": json.dumps({"directoryLabel": "foo"})}# conflicting dir -r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) -print (r.status_code) -print (r.json()) +response = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +print (response.status_code) +print (response.json()) #################### print (' preparation: add file foo.tab/bar ' + ('-' * 40)) @@ -25,9 +25,9 @@ url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) files = {'file': ('bar', ('content2: %s' % datetime.now()))} jason_data = {"jsonData": json.dumps({"directoryLabel": "foo.tab"})}# conflicting dir -r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) -print (r.status_code) -print (r.json()) +response = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +print (response.status_code) +print (response.json()) #################### print (' preparation: add file x to have a file to change ' + ('-' * 40)) @@ -37,11 +37,11 @@ unique_content = 'content2: %s' % datetime.now() files = {'file': ('x', unique_content)} jason_data = {"jsonData": json.dumps({"label": "x"})} -r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) -print (r.status_code) -print (r.json()) +response = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +print (response.status_code) +print (response.json()) -file_id = r.json()['data']['files'][0]['dataFile']['id'] +file_id = response.json()['data']['files'][0]['dataFile']['id'] #################### print (' file conflicting with existing dir gets sequence number ' + ('-' * 40)) @@ -50,10 +50,10 @@ url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) files = {'file': ('foo', ('content2: %s' % datetime.now()))} jason_data = {"jsonData": json.dumps({"label": "foo"})} -r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +response = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) -print (r.json()) -print (r.status_code) +print (response.json()) +print (response.status_code) #################### print (' tabular file conflicting with existing dir gets seq nr once converted to .tab ' + ('-' * 40)) @@ -61,9 +61,9 @@ url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) files = {'file': ('foo.csv', ('header1,header2\nvalue1,%s' % datetime.now()))} jason_data = {"jsonData": json.dumps({"label": "foo.csv"})} -r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) -print (r.status_code) -print (r.json()) +response = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +print (response.status_code) +print (response.json()) #################### print (' files API metadata: dir foo/bar conflicts with previously created file foo/bar: returns bad-request ' + ('-' * 40)) @@ -71,10 +71,10 @@ ### files API https://guides.dataverse.org/en/latest/api/native-api.html#updating-file-metadata url = f'{dataverse_server}/api/files/{file_id}/metadata' files = {'jsonData': (None, '{"directoryLabel": "foo/bar", "label": "files-api.txt"} ' + ('-' * 40))} -r = requests.post(url, headers={'X-Dataverse-key': api_key}, files=files, verify=False) +response = requests.post(url, headers={'X-Dataverse-key': api_key}, files=files, verify=False) -print(r.status_code) -print(r.text) +print(response.status_code) +print(response.text) #################### print ('datasets API update existing file into name conflicting with existing dir: returns bad-request ' + ('-' * 40)) @@ -83,10 +83,10 @@ url = f'{dataverse_server}/api/datasets/:persistentId/files/metadata?key={api_key}&persistentId={persistentId}' json_content = [{"dataFileId": file_id, "directoryLabel": "foo/bar", "label": "datasets-api.txt"}] headers = {'X-Dataverse-key': api_key, 'Content-Type': 'application/json'} -r = requests.post(url, headers=headers, json=json_content, verify=False) +response = requests.post(url, headers=headers, json=json_content, verify=False) -print(r.status_code) -print(r.text) +print(response.status_code) +print(response.text) #################### print ('datasets API add file conflicting with existing file: gets seq nr ' + ('-' * 40)) @@ -94,10 +94,10 @@ url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) files = {'file': ('fox', ('content2: %s' % datetime.now()))} jason_data = {"jsonData": json.dumps({"label": "x"})} -r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +response = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) -print (r.json()) -print (r.status_code) +print (response.json()) +print (response.status_code) #################### print ('dataset API add dir conflicting with existing file: returns bad-request ' + ('-' * 40)) @@ -105,10 +105,10 @@ url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) files = {'file': ('foo', ('content2: %s' % datetime.now()))} jason_data = {"jsonData": json.dumps({"label": "dir-conflicts-with-file.txt", "directoryLabel": "foo/bar"})} -r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +response = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) -print (r.json()) -print (r.status_code) +print (response.json()) +print (response.status_code) #################### print (' datasets API: another file on existing dir is OK ' + ('-' * 40)) @@ -116,9 +116,9 @@ url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) files = {'file': ('beer', ('content2: %s' % datetime.now()))} jason_data = {"jsonData": json.dumps({"directoryLabel": "foo"})}# conflicting dir -r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) -print (r.status_code) -print (r.json()) +response = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +print (response.status_code) +print (response.json()) #################### print (' datasets API: a file with different capitalization is OK ' + ('-' * 40)) @@ -126,9 +126,9 @@ url = '%s/api/datasets/:persistentId/add?persistentId=%s' % (dataverse_server, persistentId) files = {'file': ('Beer', ('content2: %s' % datetime.now()))} jason_data = {"jsonData": json.dumps({"directoryLabel": "foo"})}# conflicting dir -r = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) -print (r.status_code) -print (r.json()) +response = requests.post(url, headers={'X-Dataverse-key': api_key}, data=jason_data, files=files, verify=False) +print (response.status_code) +print (response.json()) #################### print (' files API replace: dir foo/bar conflicts with previously created file: returns bad-request ' + ('-' * 40)) @@ -138,10 +138,10 @@ 'jsonData': (None, '{"directoryLabel": "foo/bar", "label": "x", "forceReplace":true} ' + ('-' * 40)), 'file': ('foo', ('content2: %s' % datetime.now())) } -r = requests.post(url, headers={'X-Dataverse-key': api_key}, files=files, verify=False) +response = requests.post(url, headers={'X-Dataverse-key': api_key}, files=files, verify=False) -print(r.status_code) -print(r.text) +print(response.status_code) +print(response.text) #################### # not configured on DANS VM? Might also have no added value over previous test. @@ -152,7 +152,7 @@ # files = { # 'jsonData': (None, '{"directoryLabel": "foo/bar", "label": "x", "forceReplace":true, "description":"A remote image.","storageIdentifier":"file://themes/custom/qdr/images/01234567890-012345678901","checksumType":"MD5","md5Hash":"509ef88afa907eaf2c17c1c8d8fde77e","fileName":"testlogo.png","mimeType":"image/png"} ' + ('-' * 40)), # } -# r = requests.post(url, headers={'X-Dataverse-key': api_key}, files=files, verify=False) +# response = requests.post(url, headers={'X-Dataverse-key': api_key}, files=files, verify=False) # -# print(r.status_code) -# print(r.text) +# print(response.status_code) +# print(response.text) From 51b136c0404b64c2b05e0fd3d456e07710f1a794 Mon Sep 17 00:00:00 2001 From: jo-pol Date: Thu, 28 May 2026 09:33:10 +0200 Subject: [PATCH 4/7] api-key change-me --- scripts/issues/12407/test-apis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/issues/12407/test-apis.py b/scripts/issues/12407/test-apis.py index 9b7c88deb73..22cc38f3dba 100644 --- a/scripts/issues/12407/test-apis.py +++ b/scripts/issues/12407/test-apis.py @@ -6,7 +6,7 @@ ########################## configuration for a draft dataset without files dataverse_server = 'https://dev.archaeology.datastations.nl' -api_key = '5623d6e3-bc94-40a5-8de0-8ebdf9f58cbc' +api_key = 'change-me' persistentId = 'doi:10.5072/DAR/HBGPN5' #################### From 538e4cc564e93d40144b22b8e802c641744a24fe Mon Sep 17 00:00:00 2001 From: jo-pol Date: Thu, 28 May 2026 11:30:00 +0200 Subject: [PATCH 5/7] moved and documented scripts --- scripts/issues/12407/README.md | 13 +++++++ scripts/tests/issues/12407/README.md | 34 ++++++++++++++++++ scripts/tests/issues/12407/after-deploy.png | Bin 0 -> 35320 bytes scripts/tests/issues/12407/before-deploy.png | Bin 0 -> 40488 bytes .../issues/12407/dirs-duplicating-files.py} | 27 +++++++------- 5 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 scripts/issues/12407/README.md create mode 100644 scripts/tests/issues/12407/README.md create mode 100644 scripts/tests/issues/12407/after-deploy.png create mode 100644 scripts/tests/issues/12407/before-deploy.png rename scripts/{issues/12407/test-apis.py => tests/issues/12407/dirs-duplicating-files.py} (89%) diff --git a/scripts/issues/12407/README.md b/scripts/issues/12407/README.md new file mode 100644 index 00000000000..92c4936d496 --- /dev/null +++ b/scripts/issues/12407/README.md @@ -0,0 +1,13 @@ +Detect existing datasets with directories duplicating full file-paths +===================================================================== + +Downloaded zips with directories conflicting with file paths result in an error message when trying to extract the files. This [pull request](https://github.com/IQSS/dataverse/pull/12407) prevents those conflicts. + +After deploying, users will get error messages when trying ta add files to a dataset with a conflicting file/directory path. +The file metadata of the conflicting files should be fixed manually, prefereably before deploying the pull request, to avoid confusion for users. + +`scripts/issues/12407/find_duplicates.py` should be executed by the user that owns the `dvndb`. + +These scripts scan for conflicting datasets. Depending on your preferences and the size of your database you might want a variation of the scripts. + +In small databases you can drop both `WHERE datasetversion_id IN (:ids)` checks and directly run `find-duplicates.sql`. Another option s to divide the query in a chunks with a between-clause on the datasetversion_id. In that case also older versions of datasets are checked, not just the latest version. \ No newline at end of file diff --git a/scripts/tests/issues/12407/README.md b/scripts/tests/issues/12407/README.md new file mode 100644 index 00000000000..42b5c33f2e1 --- /dev/null +++ b/scripts/tests/issues/12407/README.md @@ -0,0 +1,34 @@ +Semi-automated test +=================== + +This is a semi-automated test to check the API endpoints that changed by this [pull request](https://github.com/IQSS/dataverse/pull/12407). + +Adjust the configuration variables at the start of the script. +* Run the _python3_ script before deploying the pull request. +* Remove the resulting draft version of the dataset. +* Deploy the pull request. +* Run the script again. + +Result before deploy +-------------------- + +All requests to the API endpoints return 200-OK status code. +As a result the dataset will contain conflicting file/directorry paths for foo and foo/bar. + +Running `scripts/issues/12407/find_duplicates.py` should show the conflicting dataset and file metadata. Note that a draft dataset has no version number. Currently `foo.tab` is a false detection. + +### Example of results + +|datasetversion_id|path|protocol|authority|dataset_id|versionnumber|minorversionnumber| +|---|---|---|---|---|---|---| +|4|foo|doi|10.5072|DAR/HBGPN5 +|4|foo/bar|doi|10.5072|DAR/HBGPN5 +|4|foo.tab|doi|10.5072|DAR/HBGPN5 + +![](before-deploy.png) + +Result after deploy +------------------- +Output with dashed lines show expected status codes and further notes. + +![](after-deploy.png) diff --git a/scripts/tests/issues/12407/after-deploy.png b/scripts/tests/issues/12407/after-deploy.png new file mode 100644 index 0000000000000000000000000000000000000000..a9a020a6adb6de791090173e9c40199c057d27c2 GIT binary patch literal 35320 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 qoy5M* Date: Thu, 28 May 2026 12:05:10 +0200 Subject: [PATCH 6/7] elaborated result examples --- scripts/tests/issues/12407/README.md | 31 +++++++++++++++---- .../issues/12407/dirs-duplicating-files.py | 2 +- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/scripts/tests/issues/12407/README.md b/scripts/tests/issues/12407/README.md index 42b5c33f2e1..c16e92fcd0b 100644 --- a/scripts/tests/issues/12407/README.md +++ b/scripts/tests/issues/12407/README.md @@ -15,15 +15,34 @@ Result before deploy All requests to the API endpoints return 200-OK status code. As a result the dataset will contain conflicting file/directorry paths for foo and foo/bar. -Running `scripts/issues/12407/find_duplicates.py` should show the conflicting dataset and file metadata. Note that a draft dataset has no version number. Currently `foo.tab` is a false detection. +Running `scripts/issues/12407/find_duplicates.py` should show the conflicting dataset and file metadata. Note that a draft dataset has no version number. ### Example of results -|datasetversion_id|path|protocol|authority|dataset_id|versionnumber|minorversionnumber| -|---|---|---|---|---|---|---| -|4|foo|doi|10.5072|DAR/HBGPN5 -|4|foo/bar|doi|10.5072|DAR/HBGPN5 -|4|foo.tab|doi|10.5072|DAR/HBGPN5 +| datasetversion_id | path | protocol | authority | dataset_id | versionnumber | minorversionnumber | +|-------------------|---------|-----------|------------|-------------|---------------|--------------------| +| 4 | foo | doi | 10.5072 | DAR/HBGPN5 | | | +| 4 | foo/bar | doi | 10.5072 | DAR/HBGPN5 | | | +| 4 | foo.tab | doi | 10.5072 | DAR/HBGPN5 | | | + +`select directorylabel,label,datasetversion_id from filemetadata;` + +| directorylabel | label | datasetversion_id | +|------------------|-----------------------------|-------------------| +| | original-metadata.zip | 4 | +| foo | bar | 4 | +| accessibilities | anonymous.txt | 4 | +| accessibilities | request.txt | 4 | +| foo.tab | bar | 4 | +| | foo | 4 | +| | foo.tab | 4 | +| foo/bar | datasets-api.txt | 4 | +| | x | 4 | +| foo/bar | dir-conflicts-with-file.txt | 4 | +| foo | beer | 4 | +| foo | Beer | 4 | + + ![](before-deploy.png) diff --git a/scripts/tests/issues/12407/dirs-duplicating-files.py b/scripts/tests/issues/12407/dirs-duplicating-files.py index 075ae8e7f8a..30cb0297cb8 100644 --- a/scripts/tests/issues/12407/dirs-duplicating-files.py +++ b/scripts/tests/issues/12407/dirs-duplicating-files.py @@ -6,7 +6,7 @@ ########################## configuration for a draft dataset without files dataverse_server = 'https://dev.archaeology.datastations.nl' -api_key = 'change-me' +api_key = '5623d6e3-bc94-40a5-8de0-8ebdf9f58cbc' persistentId = 'doi:10.5072/DAR/HBGPN5' # an existing dataset without any files verify_cert=False # set to False for testing on DANS VM with self-signed cert; not recommended for production use From ce6d552070e86446ca0055a3900efab59d88064b Mon Sep 17 00:00:00 2001 From: jo-pol Date: Thu, 28 May 2026 12:51:04 +0200 Subject: [PATCH 7/7] extended test scenario --- scripts/tests/issues/12407/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/tests/issues/12407/README.md b/scripts/tests/issues/12407/README.md index c16e92fcd0b..8b11ba2709b 100644 --- a/scripts/tests/issues/12407/README.md +++ b/scripts/tests/issues/12407/README.md @@ -5,8 +5,11 @@ This is a semi-automated test to check the API endpoints that changed by this [p Adjust the configuration variables at the start of the script. * Run the _python3_ script before deploying the pull request. -* Remove the resulting draft version of the dataset. +* Download all files from the dataset, the resulting zip will not extract. +* Try to add a non-conflicting file to the dataset, saving the changes succeeds. * Deploy the pull request. +* Again try to add a non-conflicting file to the dataset, saving the changes now fails. +* Remove the resulting draft version of the dataset. * Run the script again. Result before deploy