From 7036802368e3b77fb07e4bab16d3c876de0d514f Mon Sep 17 00:00:00 2001 From: Aaron Zielstorff Date: Fri, 3 Jul 2026 10:19:00 +0200 Subject: [PATCH 1/2] Secure repository file paths and stream downloads Centralize repository file handling through FileRepositoryHelper for AAS thumbnails, submodel attachments, and AASX packages. Uploaded logical filenames are now validated as display names only, while stored repository ids are generated server-side. Stream thumbnail and attachment HTTP responses from the repository instead of materializing repository ids as local filesystem paths. Keep legacy File APIs compatible via safe temp-file/default fallbacks. Update replacement/delete flows to update domain references safely and clean up old repository entries after successful updates. Add regression coverage for path traversal names, absolute repository keys, stream handling, and authorization behavior. --- .../base/DefaultAASEnvironment.java | 25 +- .../backend/CrudAasRepository.java | 5 + .../client/ConnectedAasRepository.java | 5 + .../basyx/aasrepository/AasRepository.java | 68 +++-- .../AasRepositoryAasServiceWrapper.java | 5 + .../AuthorizedAasRepository.java | 83 +++--- .../DiscoveryIntegrationAasRepository.java | 5 + .../feature/kafka/KafkaAasRepository.java | 5 + .../feature/mqtt/MqttAasRepository.java | 5 + .../RegistryIntegrationAasRepository.java | 5 + .../feature/search/SearchAasRepository.java | 5 + .../http/AasRepositoryApiHTTPController.java | 17 +- .../backend/AasThumbnailOperations.java | 84 +++++- .../aasservice/backend/CrudAasService.java | 5 + .../backend/AasThumbnailOperationsTest.java | 257 +++++++++++++++++ .../client/ConnectedAasService.java | 26 ++ .../basyx/aasservice/AasService.java | 48 +++- .../feature/mqtt/MqttAasService.java | 5 + .../backend/CrudAASXFileServer.java | 28 +- .../core/AASXFileServerSuite.java | 20 ++ .../filerepository/FileRepositoryHelper.java | 208 ++++++++++++-- .../FileRepositoryHelperTest.java | 197 +++++++++++++ .../backend/CrudSubmodelRepository.java | 5 + .../client/ConnectedSubmodelRepository.java | 5 + .../SubmodelRepository.java | 41 ++- ...modelRepositorySubmodelServiceWrapper.java | 5 + .../AuthorizedSubmodelRepository.java | 13 + .../kafka/KafkaSubmodelRepository.java | 5 + .../feature/mqtt/MqttSubmodelRepository.java | 5 + ...OperationDelegationSubmodelRepository.java | 5 + ...RegistryIntegrationSubmodelRepository.java | 5 + .../search/SearchSubmodelRepository.java | 5 + .../SubmodelRepositoryApiHTTPController.java | 13 +- .../backend/CrudSubmodelService.java | 55 +--- .../backend/SubmodelFileOperations.java | 198 +++++++------ .../CrudSubmodelServiceFileHandlingTest.java | 265 ++++++++++++++++++ .../client/ConnectedSubmodelService.java | 25 ++ .../submodelservice/SubmodelService.java | 39 ++- .../AuthorizedSubmodelService.java | 7 + ...thorizedSubmodelServiceFileStreamTest.java | 99 +++++++ .../feature/kafka/KafkaSubmodelService.java | 6 + .../feature/mqtt/MqttSubmodelService.java | 5 + .../AbstractSubmodelServiceDecorator.java | 8 +- .../SubmodelServiceHTTPApiController.java | 10 +- 44 files changed, 1660 insertions(+), 275 deletions(-) create mode 100644 basyx.aasservice/basyx.aasservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperationsTest.java create mode 100644 basyx.common/basyx.filerepository-backend/src/test/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelperTest.java create mode 100644 basyx.submodelservice/basyx.submodelservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelServiceFileHandlingTest.java create mode 100644 basyx.submodelservice/basyx.submodelservice-feature-authorization/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelServiceFileStreamTest.java diff --git a/basyx.aasenvironment/basyx.aasenvironment-core/src/main/java/org/eclipse/digitaltwin/basyx/aasenvironment/base/DefaultAASEnvironment.java b/basyx.aasenvironment/basyx.aasenvironment-core/src/main/java/org/eclipse/digitaltwin/basyx/aasenvironment/base/DefaultAASEnvironment.java index 16ae54715..c98155f11 100644 --- a/basyx.aasenvironment/basyx.aasenvironment-core/src/main/java/org/eclipse/digitaltwin/basyx/aasenvironment/base/DefaultAASEnvironment.java +++ b/basyx.aasenvironment/basyx.aasenvironment-core/src/main/java/org/eclipse/digitaltwin/basyx/aasenvironment/base/DefaultAASEnvironment.java @@ -371,7 +371,9 @@ private void handleFileError(File file) { private byte[] getFileContent(String submodelId, File file) throws IOException { - return submodelRepository.getFileByFilePath(submodelId, file.getValue()).readAllBytes(); + try (InputStream fileContent = submodelRepository.getFileByFilePath(submodelId, file.getValue())) { + return fileContent.readAllBytes(); + } } private boolean isFileAlreadyAdded(String filePath){ @@ -386,7 +388,7 @@ private void addThumbnailToRelatedFiles(String aasId, Resource thumbnail, List> getAllAas(List assetIds, String idShort, PaginationInfo pInfo); /** * Retrieves a specific AAS - * + * * @param aasId * the id of the AAS * @return the requested AAS @@ -64,7 +67,7 @@ public interface AasRepository { /** * Creates a new AAS at the endpoint - * + * * @param aas * the AAS to be created * @throws MissingIdentifierException @@ -74,7 +77,7 @@ public interface AasRepository { /** * Deletes a specific AAS - * + * * @param aasId * the id of the AAS to be deleted */ @@ -82,14 +85,14 @@ public interface AasRepository { /** * Overwrites an existing AAS - * + * * @param aas */ public void updateAas(String aasId, AssetAdministrationShell aas); /** * Returns a List of References to Submodels - * + * * @param aasId * @param pInfo * @return @@ -98,21 +101,21 @@ public interface AasRepository { /** * Adds a Submodel Reference - * + * * @param submodelReference */ public void addSubmodelReference(String aasId, Reference submodelReference); /** * Removes a Submodel Reference - * + * * @param submodelId */ public void removeSubmodelReference(String aasId, String submodelId); /** * Sets the asset-information of a specific AAS - * + * * @param aasId * the id of the AAS */ @@ -120,26 +123,45 @@ public interface AasRepository { /** * Retrieves the asset-information of a specific AAS - * + * * @param aasId * the id of the AAS - * + * * @return the requested AAS */ public AssetInformation getAssetInformation(String aasId) throws ElementDoesNotExistException; /** * Get Thumbnail of the specific aas - * + * * @param aasId * the id of the AAS * @return the file of the thumbnail */ public File getThumbnail(String aasId); + /** + * Get Thumbnail content of the specific aas as a stream + *

+ * The caller is responsible for closing the returned stream. + * + * @param aasId + * the id of the AAS + * @return the stream of the thumbnail + * @throws org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException + * if no thumbnail is stored + */ + public default InputStream getThumbnailInputStream(String aasId) { + try { + return new FileInputStream(getThumbnail(aasId)); + } catch (FileNotFoundException e) { + throw new FileHandlingException("Could not open thumbnail stream.", e); + } + } + /** * Set Thumbnail of the AAS - * + * * @param aasId * the id of the AAS * @param fileName @@ -153,19 +175,19 @@ public interface AasRepository { /** * Delete the thumbnail file of the AAS - * + * * @param aasId * the id of the AAS */ public void deleteThumbnail(String aasId); - + /** * Returns the name of the repository - * + * * @return repoName */ public default String getName() { return "aas-repo"; } - + } diff --git a/basyx.aasrepository/basyx.aasrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/AasRepositoryAasServiceWrapper.java b/basyx.aasrepository/basyx.aasrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/AasRepositoryAasServiceWrapper.java index f8d0c21f5..8ffbf7ac2 100644 --- a/basyx.aasrepository/basyx.aasrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/AasRepositoryAasServiceWrapper.java +++ b/basyx.aasrepository/basyx.aasrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/AasRepositoryAasServiceWrapper.java @@ -88,6 +88,11 @@ public File getThumbnail() { return repoApi.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream() { + return repoApi.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String fileName, String contentType, InputStream inputStream) { repoApi.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/authorization/AuthorizedAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/authorization/AuthorizedAasRepository.java index 5530b173a..d535d2861 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/authorization/AuthorizedAasRepository.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/authorization/AuthorizedAasRepository.java @@ -1,6 +1,6 @@ /******************************************************************************* * Copyright (C) 2023 the Eclipse BaSyx Authors - * + * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including @@ -8,10 +8,10 @@ * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: - * + * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. - * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND @@ -19,7 +19,7 @@ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - * + * * SPDX-License-Identifier: MIT ******************************************************************************/ @@ -62,7 +62,7 @@ public class AuthorizedAasRepository implements AasRepository { private static final Logger LOGGER = LoggerFactory.getLogger(AuthorizedAasRepository.class); private AasRepository decorated; private RbacPermissionResolver permissionResolver; - + public AuthorizedAasRepository(AasRepository decorated, RbacPermissionResolver permissionResolver) { this.decorated = decorated; @@ -72,15 +72,15 @@ public AuthorizedAasRepository(AasRepository decorated, RbacPermissionResolver> getAllAas(List assetIds, String idShort, PaginationInfo pInfo) { boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new AasTargetInformation(getIdAsList("*"))); - + if (isAuthorized) return decorated.getAllAas(assetIds, idShort, pInfo); - + List targetInformations = permissionResolver.getMatchingTargetInformationInRules(Action.READ, new AasTargetInformation(getIdAsList("*"))); - + List allIds = targetInformations.stream().map(AasTargetInformation.class::cast) .map(AasTargetInformation::getAasIds).flatMap(List::stream).collect(Collectors.toList()); - + List shells = allIds.stream().map(id -> { try { return getAas(id); @@ -92,7 +92,7 @@ public CursorResult> getAllAas(List aasMap = shells.stream().collect(Collectors.toMap(AssetAdministrationShell::getId, aas -> aas, (a, b) -> a, TreeMap::new)); PaginationSupport paginationSupport = new PaginationSupport<>(aasMap, AssetAdministrationShell::getId); @@ -103,120 +103,129 @@ public CursorResult> getAllAas(List> getSubmodelReferences(String shellId, PaginationInfo paginationInfo) { boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + return decorated.getSubmodelReferences(shellId, paginationInfo); } @Override public void addSubmodelReference(String shellId, Reference submodelReference) { boolean isAuthorized = permissionResolver.hasPermission(Action.UPDATE, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + decorated.addSubmodelReference(shellId, submodelReference); } @Override public void removeSubmodelReference(String shellId, String submodelId) { boolean isAuthorized = permissionResolver.hasPermission(Action.UPDATE, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + decorated.removeSubmodelReference(shellId, submodelId); } @Override public void setAssetInformation(String shellId, AssetInformation shellInfo) throws ElementDoesNotExistException { boolean isAuthorized = permissionResolver.hasPermission(Action.UPDATE, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + decorated.setAssetInformation(shellId, shellInfo); } @Override public AssetInformation getAssetInformation(String shellId) throws ElementDoesNotExistException { boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + return decorated.getAssetInformation(shellId); } @Override public File getThumbnail(String shellId) { boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + return decorated.getThumbnail(shellId); } + @Override + public InputStream getThumbnailInputStream(String shellId) { + boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new AasTargetInformation(getIdAsList(shellId))); + + throwExceptionIfInsufficientPermission(isAuthorized); + + return decorated.getThumbnailInputStream(shellId); + } + @Override public void setThumbnail(String shellId, String fileName, String contentType, InputStream inputStream) { boolean isAuthorized = permissionResolver.hasPermission(Action.UPDATE, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + decorated.setThumbnail(shellId, fileName, contentType, inputStream); } @Override public void deleteThumbnail(String shellId) { boolean isAuthorized = permissionResolver.hasPermission(Action.UPDATE, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + decorated.deleteThumbnail(shellId); } - + @Override public String getName() { return decorated.getName(); } - + private ArrayList getIdAsList(String id) { return new ArrayList<>(Arrays.asList(id)); } - + private void throwExceptionIfInsufficientPermission(boolean isAuthorized) { if (!isAuthorized) throw new InsufficientPermissionException("Insufficient Permission: The current subject does not have the required permissions for this operation."); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-discovery-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/discoveryintegration/DiscoveryIntegrationAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-discovery-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/discoveryintegration/DiscoveryIntegrationAasRepository.java index b711a4746..5412d75a4 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-discovery-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/discoveryintegration/DiscoveryIntegrationAasRepository.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-discovery-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/discoveryintegration/DiscoveryIntegrationAasRepository.java @@ -135,6 +135,11 @@ public File getThumbnail(String aasId) { return decorated.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream(String aasId) { + return decorated.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String aasId, String fileName, String contentType, InputStream inputStream) { decorated.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/kafka/KafkaAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/kafka/KafkaAasRepository.java index e95af50c7..7f274fc9a 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/kafka/KafkaAasRepository.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/kafka/KafkaAasRepository.java @@ -119,6 +119,11 @@ public File getThumbnail(String aasId) { return decorated.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream(String aasId) { + return decorated.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String aasId, String fileName, String contentType, InputStream inputStream) { decorated.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepository.java index 95a0cf662..f42d9e96f 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepository.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepository.java @@ -195,6 +195,11 @@ public File getThumbnail(String aasId) { return decorated.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream(String aasId) { + return decorated.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String aasId, String fileName, String contentType, InputStream inputStream) { decorated.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/RegistryIntegrationAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/RegistryIntegrationAasRepository.java index 544600f07..229a2b605 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/RegistryIntegrationAasRepository.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/RegistryIntegrationAasRepository.java @@ -181,6 +181,11 @@ public File getThumbnail(String aasId) { return decorated.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream(String aasId) { + return decorated.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String aasId, String fileName, String contentType, InputStream inputStream) { decorated.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/search/SearchAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/search/SearchAasRepository.java index 8cac5deea..ef9ebbd5b 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/search/SearchAasRepository.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/search/SearchAasRepository.java @@ -121,6 +121,11 @@ public File getThumbnail(String aasId) { return decorated.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream(String aasId) { + return decorated.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String aasId, String fileName, String contentType, InputStream inputStream) { decorated.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasrepository/basyx.aasrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/http/AasRepositoryApiHTTPController.java b/basyx.aasrepository/basyx.aasrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/http/AasRepositoryApiHTTPController.java index 85c6c28b1..0d872f5c2 100644 --- a/basyx.aasrepository/basyx.aasrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/http/AasRepositoryApiHTTPController.java +++ b/basyx.aasrepository/basyx.aasrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/http/AasRepositoryApiHTTPController.java @@ -41,6 +41,7 @@ import org.eclipse.digitaltwin.basyx.aasrepository.http.pagination.GetAssetAdministrationShellsResult; import org.eclipse.digitaltwin.basyx.aasrepository.http.pagination.GetReferencesResult; import org.eclipse.digitaltwin.basyx.core.exceptions.CollidingSubmodelReferenceException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; import org.eclipse.digitaltwin.basyx.http.BaSyxMediaType; @@ -49,7 +50,7 @@ import org.eclipse.digitaltwin.basyx.http.pagination.PagedResult; import org.eclipse.digitaltwin.basyx.http.pagination.PagedResultPagingMetadata; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -201,14 +202,22 @@ public ResponseEntity deleteThumbnailAasRepository(Base64UrlEncodedIdentif @Override public ResponseEntity getThumbnailAasRepository(Base64UrlEncodedIdentifier aasIdentifier) { String aasId = aasIdentifier.getIdentifier(); - Resource resource = new FileSystemResource(aasRepository.getThumbnail(aasId)); - String contentType = aasRepository.getAssetInformation(aasId).getDefaultThumbnail().getContentType(); + org.eclipse.digitaltwin.aas4j.v3.model.Resource thumbnail = aasRepository.getAssetInformation(aasId).getDefaultThumbnail(); + + if (!isThumbnailSet(thumbnail)) + throw new FileDoesNotExistException(); + + Resource resource = new InputStreamResource(aasRepository.getThumbnailInputStream(aasId)); return ResponseEntity.ok() - .contentType(BaSyxMediaType.parseOrOctetStream(contentType)) + .contentType(BaSyxMediaType.parseOrOctetStream(thumbnail.getContentType())) .body(resource); } + private static boolean isThumbnailSet(org.eclipse.digitaltwin.aas4j.v3.model.Resource thumbnail) { + return thumbnail != null && thumbnail.getPath() != null && !thumbnail.getPath().isBlank(); + } + @Override public ResponseEntity putThumbnailAasRepository(Base64UrlEncodedIdentifier aasIdentifier, String fileName, @Valid MultipartFile file) { InputStream fileInputstream = null; diff --git a/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperations.java b/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperations.java index b737beda1..ecb67ce40 100644 --- a/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperations.java +++ b/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperations.java @@ -51,23 +51,97 @@ public AasThumbnailOperations(AasOperations aasOperations, FileRepository fileRe this.fileRepository = fileRepository; } + /** + * Retrieves the AAS thumbnail as a temporary local file. + *

+ * The caller owns the returned temporary file and is responsible for deleting + * it when it is no longer needed. + * + * @param aasId + * the id of the AAS + * @return the thumbnail file + * @throws FileDoesNotExistException + * if no thumbnail is stored + */ public File getThumbnail(String aasId) { return FileRepositoryHelper.fetchAndStoreFileLocally(fileRepository, getThumbnailResourcePathOrThrow(aasOperations.getAssetInformation(aasId))); } + /** + * Retrieves the AAS thumbnail content as a stream. + *

+ * The caller is responsible for closing the returned stream. + * + * @param aasId + * the id of the AAS + * @return the thumbnail content stream + * @throws FileDoesNotExistException + * if no thumbnail is stored + */ + public InputStream getThumbnailInputStream(String aasId) { + return FileRepositoryHelper.getFileInputStream(fileRepository, getThumbnailResourcePathOrThrow(aasOperations.getAssetInformation(aasId))); + } + + /** + * Stores a new AAS thumbnail and updates the thumbnail resource path. + * + * @param aasId + * the id of the AAS + * @param fileName + * the logical display name of the uploaded thumbnail + * @param contentType + * the thumbnail MIME type + * @param inputStream + * the thumbnail content stream + */ public void setThumbnail(String aasId, String fileName, String contentType, InputStream inputStream) { - String filePath = FileRepositoryHelper.saveOrOverwriteFile(fileRepository, fileName, contentType, inputStream); - aasOperations.setAssetInformation(aasId, configureAssetInformationThumbnail(aasOperations.getAssetInformation(aasId), contentType, filePath)); + AssetInformation assetInformation = aasOperations.getAssetInformation(aasId); + Resource oldThumbnail = assetInformation.getDefaultThumbnail(); + String oldThumbnailPath = getThumbnailResourcePath(assetInformation).orElse(null); + + FileRepositoryHelper.saveAndUpdateReference(fileRepository, oldThumbnailPath, fileName, contentType, inputStream, filePath -> setThumbnailReference(aasId, assetInformation, oldThumbnail, contentType, filePath)); } + /** + * Removes the thumbnail reference and deletes the stored thumbnail content. + * + * @param aasId + * the id of the AAS + * @throws FileDoesNotExistException + * if no thumbnail is stored + */ public void deleteThumbnail(String aasId) { AssetInformation assetInformation = aasOperations.getAssetInformation(aasId); - FileRepositoryHelper.removeFileIfExists(fileRepository, getThumbnailResourcePathOrThrow(assetInformation)); - aasOperations.setAssetInformation(aasId, configureAssetInformationThumbnail(assetInformation, "", "")); + Resource oldThumbnail = assetInformation.getDefaultThumbnail(); + String thumbnailPath = getThumbnailResourcePathOrThrow(assetInformation); + + FileRepositoryHelper.updateReferenceAndDeleteFile(fileRepository, thumbnailPath, () -> clearThumbnailReference(aasId, assetInformation, oldThumbnail)); + } + + private void setThumbnailReference(String aasId, AssetInformation assetInformation, Resource oldThumbnail, String contentType, String filePath) { + try { + aasOperations.setAssetInformation(aasId, configureAssetInformationThumbnail(assetInformation, contentType, filePath)); + } catch (RuntimeException e) { + assetInformation.setDefaultThumbnail(oldThumbnail); + throw e; + } + } + + private void clearThumbnailReference(String aasId, AssetInformation assetInformation, Resource oldThumbnail) { + try { + aasOperations.setAssetInformation(aasId, configureAssetInformationThumbnail(assetInformation, "", "")); + } catch (RuntimeException e) { + assetInformation.setDefaultThumbnail(oldThumbnail); + throw e; + } } private static String getThumbnailResourcePathOrThrow(AssetInformation assetInformation) { - return Optional.ofNullable(assetInformation).map(AssetInformation::getDefaultThumbnail).map(Resource::getPath).orElseThrow(FileDoesNotExistException::new); + return getThumbnailResourcePath(assetInformation).orElseThrow(FileDoesNotExistException::new); + } + + private static Optional getThumbnailResourcePath(AssetInformation assetInformation) { + return Optional.ofNullable(assetInformation).map(AssetInformation::getDefaultThumbnail).map(Resource::getPath); } private static AssetInformation configureAssetInformationThumbnail(AssetInformation assetInformation, String contentType, String filePath) { diff --git a/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/CrudAasService.java b/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/CrudAasService.java index 156f4b351..385e680cf 100644 --- a/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/CrudAasService.java +++ b/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/CrudAasService.java @@ -95,6 +95,11 @@ public File getThumbnail() { return thumbnailOperations.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream() { + return thumbnailOperations.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String fileName, String contentType, InputStream inputStream) { thumbnailOperations.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasservice/basyx.aasservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperationsTest.java b/basyx.aasservice/basyx.aasservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperationsTest.java new file mode 100644 index 000000000..c8f3ce792 --- /dev/null +++ b/basyx.aasservice/basyx.aasservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperationsTest.java @@ -0,0 +1,257 @@ +/******************************************************************************* + * Copyright (C) 2026 the Eclipse BaSyx Authors + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * SPDX-License-Identifier: MIT + ******************************************************************************/ + +package org.eclipse.digitaltwin.basyx.aasservice.backend; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.digitaltwin.aas4j.v3.model.AssetAdministrationShell; +import org.eclipse.digitaltwin.aas4j.v3.model.AssetInformation; +import org.eclipse.digitaltwin.aas4j.v3.model.Reference; +import org.eclipse.digitaltwin.aas4j.v3.model.Resource; +import org.eclipse.digitaltwin.aas4j.v3.model.SpecificAssetId; +import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultAssetInformation; +import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultResource; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; +import org.eclipse.digitaltwin.basyx.core.filerepository.FileMetadata; +import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepository; +import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; +import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; +import org.junit.Test; + +public class AasThumbnailOperationsTest { + + private static final String AAS_ID = "aas-1"; + + @Test + public void setThumbnailRejectsPathTraversalFileName() throws IOException { + Path victimFile = Files.createTempDirectory("basyx-victim").resolve("pwned.txt"); + AasThumbnailOperations thumbnailOperations = new AasThumbnailOperations(new InMemoryAasOperations(), new GridFsLikeFileRepository()); + + try { + assertThrows(SecurityException.class, () -> thumbnailOperations.setThumbnail(AAS_ID, victimFile.toAbsolutePath().toString(), "image/png", new ByteArrayInputStream("payload".getBytes(UTF_8)))); + assertFalse(Files.exists(victimFile)); + } finally { + Files.deleteIfExists(victimFile.getParent()); + } + } + + @Test + public void getThumbnailWithAbsoluteResourcePathWritesOnlyTempFile() throws IOException { + Path victimDirectory = Files.createTempDirectory("basyx-victim"); + Path victimFile = victimDirectory.resolve("pwned.txt"); + String maliciousRepositoryKey = victimFile.toAbsolutePath().toString(); + byte[] attackerPayload = "attacker bytes".getBytes(UTF_8); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(maliciousRepositoryKey, attackerPayload); + + InMemoryAasOperations aasOperations = new InMemoryAasOperations(); + aasOperations.setAssetInformation(AAS_ID, createAssetInformationWithThumbnail(maliciousRepositoryKey)); + AasThumbnailOperations thumbnailOperations = new AasThumbnailOperations(aasOperations, fileRepository); + + try { + java.io.File localFile = thumbnailOperations.getThumbnail(AAS_ID); + + assertTrue(localFile.exists()); + assertNotEquals(victimFile.toAbsolutePath().toString(), localFile.toPath().toAbsolutePath().toString()); + assertFalse(Files.exists(victimFile)); + assertArrayEquals(attackerPayload, Files.readAllBytes(localFile.toPath())); + Files.deleteIfExists(localFile.toPath()); + } finally { + Files.deleteIfExists(victimFile); + Files.deleteIfExists(victimDirectory); + } + } + + @Test + public void getThumbnailInputStreamWithAbsoluteResourcePathDoesNotWriteToDisk() throws IOException { + Path victimDirectory = Files.createTempDirectory("basyx-victim"); + Path victimFile = victimDirectory.resolve("pwned.txt"); + String maliciousRepositoryKey = victimFile.toAbsolutePath().toString(); + byte[] attackerPayload = "attacker bytes".getBytes(UTF_8); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(maliciousRepositoryKey, attackerPayload); + + InMemoryAasOperations aasOperations = new InMemoryAasOperations(); + aasOperations.setAssetInformation(AAS_ID, createAssetInformationWithThumbnail(maliciousRepositoryKey)); + AasThumbnailOperations thumbnailOperations = new AasThumbnailOperations(aasOperations, fileRepository); + + try (InputStream inputStream = thumbnailOperations.getThumbnailInputStream(AAS_ID)) { + assertArrayEquals(attackerPayload, inputStream.readAllBytes()); + assertFalse(Files.exists(victimFile)); + } finally { + Files.deleteIfExists(victimFile); + Files.deleteIfExists(victimDirectory); + } + } + + @Test + public void setThumbnailCleansNewFileAndKeepsOldFileWhenAssetInformationUpdateFails() { + String oldThumbnailPath = "old-thumbnail"; + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(oldThumbnailPath, "old".getBytes(UTF_8)); + FailingAasOperations aasOperations = new FailingAasOperations(); + aasOperations.assetInformation = createAssetInformationWithThumbnail(oldThumbnailPath); + AasThumbnailOperations thumbnailOperations = new AasThumbnailOperations(aasOperations, fileRepository); + + assertThrows(RuntimeException.class, () -> thumbnailOperations.setThumbnail(AAS_ID, "new.png", "image/png", new ByteArrayInputStream("new".getBytes(UTF_8)))); + + assertTrue(fileRepository.exists(oldThumbnailPath)); + assertEquals(1, fileRepository.size()); + assertEquals(oldThumbnailPath, aasOperations.getAssetInformation(AAS_ID).getDefaultThumbnail().getPath()); + } + + @Test + public void deleteThumbnailKeepsFileWhenAssetInformationUpdateFails() { + String oldThumbnailPath = "old-thumbnail"; + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(oldThumbnailPath, "old".getBytes(UTF_8)); + FailingAasOperations aasOperations = new FailingAasOperations(); + aasOperations.assetInformation = createAssetInformationWithThumbnail(oldThumbnailPath); + AasThumbnailOperations thumbnailOperations = new AasThumbnailOperations(aasOperations, fileRepository); + + assertThrows(RuntimeException.class, () -> thumbnailOperations.deleteThumbnail(AAS_ID)); + + assertTrue(fileRepository.exists(oldThumbnailPath)); + assertEquals(1, fileRepository.size()); + assertEquals(oldThumbnailPath, aasOperations.getAssetInformation(AAS_ID).getDefaultThumbnail().getPath()); + } + + private static AssetInformation createAssetInformationWithThumbnail(String path) { + Resource thumbnail = new DefaultResource.Builder().path(path).contentType("image/png").build(); + AssetInformation assetInformation = new DefaultAssetInformation.Builder().build(); + assetInformation.setDefaultThumbnail(thumbnail); + return assetInformation; + } + + private static class InMemoryAasOperations implements AasOperations { + protected AssetInformation assetInformation = new DefaultAssetInformation.Builder().build(); + + @Override + public void setAssetInformation(String aasId, AssetInformation assetInformation) { + this.assetInformation = assetInformation; + } + + @Override + public AssetInformation getAssetInformation(String aasId) { + return assetInformation; + } + + @Override + public CursorResult> getShells(List assetIds, String idShort, PaginationInfo pInfo) { + throw new UnsupportedOperationException(); + } + + @Override + public CursorResult> getSubmodelReferences(String aasId, PaginationInfo pInfo) { + throw new UnsupportedOperationException(); + } + + @Override + public void addSubmodelReference(String aasId, Reference submodelReference) { + throw new UnsupportedOperationException(); + } + + @Override + public void removeSubmodelReference(String aasId, String submodelId) { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable getAllAas(List assetIds, String idShort) { + throw new UnsupportedOperationException(); + } + } + + private static class FailingAasOperations extends InMemoryAasOperations { + @Override + public void setAssetInformation(String aasId, AssetInformation assetInformation) { + throw new RuntimeException("Could not update asset information."); + } + } + + private static class GridFsLikeFileRepository implements FileRepository { + private final Map store = new HashMap<>(); + + @Override + public String save(FileMetadata metadata) throws FileHandlingException { + if (exists(metadata.getFileName())) + throw new FileHandlingException("File '%s' already exists.".formatted(metadata.getFileName())); + + try { + store.put(metadata.getFileName(), metadata.getFileContent().readAllBytes()); + } catch (IOException e) { + throw new FileHandlingException(metadata.getFileName(), e); + } + + return metadata.getFileName(); + } + + @Override + public InputStream find(String fileId) throws FileDoesNotExistException { + if (!exists(fileId)) + throw new FileDoesNotExistException(); + + return new ByteArrayInputStream(store.get(fileId)); + } + + @Override + public void delete(String fileId) throws FileDoesNotExistException { + if (!exists(fileId)) + throw new FileDoesNotExistException(); + + store.remove(fileId); + } + + @Override + public boolean exists(String fileId) { + return fileId != null && store.containsKey(fileId); + } + + void put(String fileId, byte[] content) { + store.put(fileId, content); + } + + int size() { + return store.size(); + } + } +} diff --git a/basyx.aasservice/basyx.aasservice-client/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/client/ConnectedAasService.java b/basyx.aasservice/basyx.aasservice-client/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/client/ConnectedAasService.java index 299d54acc..b8c494fbd 100644 --- a/basyx.aasservice/basyx.aasservice-client/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/client/ConnectedAasService.java +++ b/basyx.aasservice/basyx.aasservice-client/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/client/ConnectedAasService.java @@ -27,7 +27,10 @@ package org.eclipse.digitaltwin.basyx.aasservice.client; import java.io.File; +import java.io.FilterInputStream; +import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; import java.util.List; import org.eclipse.digitaltwin.aas4j.v3.model.AssetAdministrationShell; @@ -41,6 +44,7 @@ import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException; import org.eclipse.digitaltwin.basyx.core.exceptions.ElementNotAFileException; import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; import org.eclipse.digitaltwin.basyx.http.Base64UrlEncoder; @@ -128,6 +132,11 @@ public File getThumbnail() { } } + @Override + public InputStream getThumbnailInputStream() { + return openFileAndDeleteOnClose(getThumbnail()); + } + @Override public void setThumbnail(String fileName, String contentType, InputStream inputStream) { try { @@ -177,4 +186,21 @@ private RuntimeException mapAasAccess(ApiException e) { return e; } + private static InputStream openFileAndDeleteOnClose(File file) { + try { + return new FilterInputStream(Files.newInputStream(file.toPath())) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + Files.deleteIfExists(file.toPath()); + } + } + }; + } catch (IOException e) { + throw new FileHandlingException("Could not open thumbnail stream.", e); + } + } + } diff --git a/basyx.aasservice/basyx.aasservice-core/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/AasService.java b/basyx.aasservice/basyx.aasservice-core/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/AasService.java index c0b6783cd..b67301479 100644 --- a/basyx.aasservice/basyx.aasservice-core/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/AasService.java +++ b/basyx.aasservice/basyx.aasservice-core/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/AasService.java @@ -1,6 +1,6 @@ /******************************************************************************* * Copyright (C) 2023 the Eclipse BaSyx Authors - * + * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including @@ -8,10 +8,10 @@ * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: - * + * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. - * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND @@ -19,24 +19,27 @@ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - * + * * SPDX-License-Identifier: MIT ******************************************************************************/ package org.eclipse.digitaltwin.basyx.aasservice; import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.InputStream; import java.util.List; import org.eclipse.digitaltwin.aas4j.v3.model.AssetAdministrationShell; import org.eclipse.digitaltwin.aas4j.v3.model.AssetInformation; import org.eclipse.digitaltwin.aas4j.v3.model.Reference; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; /** * Specifies the overall AasService API - * + * * @author schnicke, mateusmolina * */ @@ -44,14 +47,14 @@ public interface AasService { /** * Retrieves the AAS contained in the server - * + * * @return */ public AssetAdministrationShell getAAS(); /** * Retrieves all Submodel References - * + * * @return A List containing all Submodel References */ public CursorResult> getSubmodelReferences(PaginationInfo pInfo); @@ -65,29 +68,46 @@ public interface AasService { * Removes a Submodel Reference */ public void removeSubmodelReference(String submodelId); - + /** * Sets the asset-information of the AAS contained in the server */ public void setAssetInformation(AssetInformation aasInfo); - + /** * Retrieves the asset-information of the AAS contained in the server - * + * * @return the Asset-Information of the AAS */ - public AssetInformation getAssetInformation(); + public AssetInformation getAssetInformation(); /** * Get Thumbnail of the specific aas - * + * * @return the file of the thumbnail */ public File getThumbnail(); + /** + * Get Thumbnail content as a stream. + *

+ * The caller is responsible for closing the returned stream. + * + * @return the stream of the thumbnail + * @throws org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException + * if no thumbnail is stored + */ + public default InputStream getThumbnailInputStream() { + try { + return new FileInputStream(getThumbnail()); + } catch (FileNotFoundException e) { + throw new FileHandlingException("Could not open thumbnail stream.", e); + } + } + /** * Set Thumbnail of the AAS - * + * * @param fileName * name of the thumbnail file with extension * @param contentType @@ -99,7 +119,7 @@ public interface AasService { /** * Delete the thumbnail file of the AAS - * + * */ public void deleteThumbnail(); } diff --git a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasService.java b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasService.java index cc8909ff3..96e53e264 100644 --- a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasService.java +++ b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasService.java @@ -190,6 +190,11 @@ public File getThumbnail() { return decorated.getThumbnail(); } + @Override + public InputStream getThumbnailInputStream() { + return decorated.getThumbnailInputStream(); + } + @Override public void setThumbnail(String fileName, String contentType, InputStream inputStream) { decorated.setThumbnail(fileName, contentType, inputStream); diff --git a/basyx.aasxfileserver/basyx.aasxfileserver-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasxfileserver/backend/CrudAASXFileServer.java b/basyx.aasxfileserver/basyx.aasxfileserver-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasxfileserver/backend/CrudAASXFileServer.java index 8646db52f..f1b0c9cab 100644 --- a/basyx.aasxfileserver/basyx.aasxfileserver-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasxfileserver/backend/CrudAASXFileServer.java +++ b/basyx.aasxfileserver/basyx.aasxfileserver-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasxfileserver/backend/CrudAASXFileServer.java @@ -40,8 +40,8 @@ import org.eclipse.digitaltwin.basyx.aasxfileserver.model.Package; import org.eclipse.digitaltwin.basyx.aasxfileserver.model.PackagesBody; import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException; -import org.eclipse.digitaltwin.basyx.core.filerepository.FileMetadata; import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepository; +import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepositoryHelper; import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationSupport; @@ -96,17 +96,12 @@ public InputStream getAASXByPackageId(String packageId) throws ElementDoesNotExi @Override public void updateAASXByPackageId(String packageId, List shellIds, InputStream file, String filename) throws ElementDoesNotExistException { - deleteAASXByPackageId(packageId); + Package existingPackage = packageBackend.findById(packageId).orElseThrow(ElementDoesNotExistException::new); PackageDescription packageDescription = createPackageDescription(shellIds, packageId); + String oldFilePath = existingPackage.getPackagesBody().getFilePath(); - String filepath = fileRepository.save(new FileMetadata(filename, AASX_CONTENT_TYPE, file)); - - PackagesBody packagesBody = createPackagesBody(shellIds, filename, filepath); - - Package pkg = createPackage(packageDescription, packagesBody); - - packageBackend.save(pkg); + saveAASXFileAndUpdatePackageReference(shellIds, file, filename, packageDescription, oldFilePath); } @Override @@ -116,13 +111,7 @@ public PackageDescription createAASXPackage(List shellIds, InputStream f PackageDescription packageDescription = createPackageDescription(shellIds, newpackageId); - String filepath = fileRepository.save(new FileMetadata(fileName, AASX_CONTENT_TYPE, file)); - - PackagesBody packagesBody = createPackagesBody(shellIds, fileName, filepath); - - Package pkg = createPackage(packageDescription, packagesBody); - - packageBackend.save(pkg); + saveAASXFileAndUpdatePackageReference(shellIds, file, fileName, packageDescription, null); return packageDescription; } @@ -141,6 +130,13 @@ private InputStream getISFromPackage(Package pkg) { return fileRepository.find(pkg.getPackagesBody().getFilePath()); } + private void saveAASXFileAndUpdatePackageReference(List shellIds, InputStream file, String fileName, PackageDescription packageDescription, String oldFilePath) { + FileRepositoryHelper.saveAndUpdateReference(fileRepository, oldFilePath, fileName, AASX_CONTENT_TYPE, file, filePath -> { + PackagesBody packagesBody = createPackagesBody(shellIds, fileName, filePath); + packageBackend.save(createPackage(packageDescription, packagesBody)); + }); + } + /** * * @param pkg diff --git a/basyx.aasxfileserver/basyx.aasxfileserver-core/src/test/java/org/eclipse/digitaltwin/basyx/aasxfileserver/core/AASXFileServerSuite.java b/basyx.aasxfileserver/basyx.aasxfileserver-core/src/test/java/org/eclipse/digitaltwin/basyx/aasxfileserver/core/AASXFileServerSuite.java index cd854af75..c18ac29a3 100644 --- a/basyx.aasxfileserver/basyx.aasxfileserver-core/src/test/java/org/eclipse/digitaltwin/basyx/aasxfileserver/core/AASXFileServerSuite.java +++ b/basyx.aasxfileserver/basyx.aasxfileserver-core/src/test/java/org/eclipse/digitaltwin/basyx/aasxfileserver/core/AASXFileServerSuite.java @@ -27,6 +27,7 @@ import static org.junit.Assert.*; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.util.Arrays; @@ -95,6 +96,21 @@ public void createAASXPackage() { assertEquals(expectedPackageDescription, actualPackageDescription); } + @Test(expected = SecurityException.class) + public void createAASXPackageRejectsPathTraversalFileName() { + AASXFileServer server = getAASXFileServer(); + + server.createAASXPackage(DummyAASXFileServerFactory.FIRST_SHELL_IDS, createAASXFileContent(), "../test.aasx"); + } + + @Test(expected = SecurityException.class) + public void updateAASXPackageRejectsPathTraversalFileName() { + AASXFileServer server = getAASXFileServer(); + PackageDescription packageDescription = DummyAASXFileServerFactory.createFirstDummyAASXPackageOnServer(server); + + server.updateAASXByPackageId(packageDescription.getPackageId(), DummyAASXFileServerFactory.FIRST_SHELL_IDS, createAASXFileContent(), "../test.aasx"); + } + @Test public void getAllAASXPackageIdsEmpty() { String shellId = "testShellId"; @@ -181,6 +197,10 @@ private void updateAASXPackage(AASXFileServer server, String packageId, List expectedPackageDescriptions, List actualPackageDescriptions) { assertTrue(expectedPackageDescriptions.containsAll(actualPackageDescriptions)); assertTrue(actualPackageDescriptions.containsAll(expectedPackageDescriptions)); diff --git a/basyx.common/basyx.filerepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelper.java b/basyx.common/basyx.filerepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelper.java index 6ba52154d..2760a9362 100644 --- a/basyx.common/basyx.filerepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelper.java +++ b/basyx.common/basyx.filerepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelper.java @@ -26,10 +26,13 @@ package org.eclipse.digitaltwin.basyx.core.filerepository; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.UUID; +import java.util.function.Consumer; import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; @@ -40,69 +43,218 @@ * @author mateusmolina */ public class FileRepositoryHelper { + private static final int MAX_EXTENSION_LENGTH = 16; + private FileRepositoryHelper() { } /** - * Retrieves the content of a file from the repository and creates a local file. + * Retrieves repository content and materializes it as a temporary local file. + *

+ * The repository id is never used as a filesystem path. The caller owns the + * returned temporary file and is responsible for deleting it when it is no + * longer needed. * * @param fileRepository * the file repository instance * @param filePath + * the repository file id * @return the retrieved file * @throws FileHandlingException * if an IO error occurs */ public static File fetchAndStoreFileLocally(FileRepository fileRepository, String filePath) { + Path temporaryFile = null; + try (InputStream fileIs = fileRepository.find(filePath)) { - byte[] content = fileIs.readAllBytes(); - writeToFile(filePath, content); - return new File(filePath); + temporaryFile = Files.createTempFile("basyx-file-", ".tmp"); + Files.copy(fileIs, temporaryFile, StandardCopyOption.REPLACE_EXISTING); + return temporaryFile.toFile(); } catch (IOException e) { + deleteIfExists(temporaryFile); throw new FileHandlingException("Error while retrieving file content:" + filePath, e); } } /** - * Writes byte content to a file on disk. + * Saves a file to the repository under a server-generated id. + *

+ * The supplied {@code fileName} is treated as a logical display name only. It + * must not be blank, contain control characters, contain path separators, + * contain {@code ..}, or start with a Windows drive prefix. The stored file id + * is a UUID plus a conservative ASCII alphanumeric extension, if one is + * present. * - * @param filePath - * the path where the file should be created - * @param content - * the byte content to be written to the file - * @throws FileHandlingException - * if an IO error occurs + * @param fileRepository + * the file repository instance + * @param fileName + * the logical name of the file + * @param contentType + * the MIME type of the file + * @param inputStream + * the input stream containing file data + * @return the generated repository file id + * @throws IllegalArgumentException + * if the logical filename is null, blank, or contains control + * characters + * @throws SecurityException + * if the logical filename looks like a path traversal attempt */ - public static void writeToFile(String filePath, byte[] content) { - try (OutputStream outputStream = new FileOutputStream(filePath)) { - outputStream.write(content); - } catch (IOException e) { - throw new FileHandlingException("Error while writing to file: " + filePath, e); + public static String saveOrOverwriteFile(FileRepository fileRepository, String fileName, String contentType, InputStream inputStream) { + String logicalFileName = validateLogicalFileName(fileName); + String storedFileName = createUniqueFileName(logicalFileName); + FileMetadata fileMetadata = new FileMetadata(storedFileName, logicalFileName, contentType, inputStream); + + if (fileRepository.exists(storedFileName)) { + fileRepository.delete(storedFileName); } + return fileRepository.save(fileMetadata); } /** - * Saves a file to the repository, deleting any existing file with the same - * name. + * Saves a new file, updates its owning domain reference, and then removes the + * old repository file on success. + *

+ * If {@code updateReference} fails, the newly saved repository file is deleted + * before the exception is rethrown. * * @param fileRepository * the file repository instance + * @param oldFilePath + * the previous repository file id, or null if none exists * @param fileName - * the name of the file + * the logical display name of the uploaded file * @param contentType * the MIME type of the file * @param inputStream * the input stream containing file data - * @return the path where the file is saved + * @param updateReference + * callback that stores the new repository file id in the owning + * domain object + * @return the generated repository file id */ - public static String saveOrOverwriteFile(FileRepository fileRepository, String fileName, String contentType, InputStream inputStream) { - FileMetadata fileMetadata = new FileMetadata(fileName, contentType, inputStream); + public static String saveAndUpdateReference(FileRepository fileRepository, String oldFilePath, String fileName, String contentType, InputStream inputStream, Consumer updateReference) { + String filePath = saveOrOverwriteFile(fileRepository, fileName, contentType, inputStream); - if (fileRepository.exists(fileName)) { - fileRepository.delete(fileName); + try { + updateReference.accept(filePath); + } catch (RuntimeException e) { + deleteFileIfExists(fileRepository, filePath); + throw e; + } + + deleteFileIfExists(fileRepository, oldFilePath); + + return filePath; + } + + /** + * Updates the owning domain reference to no longer point at a repository file + * and then removes that repository file on success. + * + * @param fileRepository + * the file repository instance + * @param filePath + * the repository file id to delete + * @param updateReference + * callback that clears the owning domain reference + * @throws FileDoesNotExistException + * if {@code filePath} does not exist in the repository + */ + public static void updateReferenceAndDeleteFile(FileRepository fileRepository, String filePath, Runnable updateReference) { + if (!fileRepository.exists(filePath)) + throw new FileDoesNotExistException(); + + updateReference.run(); + + deleteFileIfExists(fileRepository, filePath); + } + + /** + * Opens repository content as a stream. The caller must close the returned + * stream. + * + * @param fileRepository + * the file repository instance + * @param filePath + * the repository file id + * @return the repository content stream + * @throws FileDoesNotExistException + * if {@code filePath} does not exist in the repository + */ + public static InputStream getFileInputStream(FileRepository fileRepository, String filePath) { + return fileRepository.find(filePath); + } + + /** + * Best-effort deletion of a repository file. + * + * @param fileRepository + * the file repository instance + * @param filePath + * the repository file id + */ + public static void deleteFileIfExists(FileRepository fileRepository, String filePath) { + if (filePath == null) + return; + + try { + if (fileRepository.exists(filePath)) + fileRepository.delete(filePath); + } catch (FileDoesNotExistException e) { + } + } + + private static String createUniqueFileName(String logicalFileName) { + return UUID.randomUUID() + getFileExtension(logicalFileName); + } + + private static String getFileExtension(String fileName) { + int extensionStart = fileName.lastIndexOf('.'); + + if (extensionStart <= 0 || extensionStart >= fileName.length() - 1) + return ""; + + String extension = fileName.substring(extensionStart + 1); + + if (extension.length() > MAX_EXTENSION_LENGTH || !extension.chars().allMatch(FileRepositoryHelper::isAsciiLetterOrDigit)) + return ""; + + return "." + extension; + } + + private static boolean isAsciiLetterOrDigit(int character) { + return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9'; + } + + private static String validateLogicalFileName(String fileName) { + if (fileName == null || fileName.isBlank()) + throw new IllegalArgumentException("File name must not be null or blank."); + + String logicalFileName = fileName.replace("\r", "_").replace("\n", "_").trim(); + + if (logicalFileName.chars().anyMatch(Character::isISOControl)) + throw new IllegalArgumentException("File name must not contain control characters."); + + if (logicalFileName.contains("..") || logicalFileName.contains("/") || logicalFileName.contains("\\") || startsWithWindowsDrivePrefix(logicalFileName)) + throw new SecurityException("Path traversal attempt detected."); + + return logicalFileName; + } + + private static boolean startsWithWindowsDrivePrefix(String fileName) { + return fileName.length() >= 2 && Character.isLetter(fileName.charAt(0)) && fileName.charAt(1) == ':'; + } + + private static void deleteIfExists(Path path) { + if (path == null) + return; + + try { + Files.deleteIfExists(path); + } catch (IOException e) { } - return fileRepository.save(fileMetadata); } /** @@ -111,7 +263,7 @@ public static String saveOrOverwriteFile(FileRepository fileRepository, String f * @param fileRepository * the file repository instance * @param filePath - * the path of the file to be deleted + * the repository file id to delete * @throws FileDoesNotExistException * if the file does not exist */ diff --git a/basyx.common/basyx.filerepository-backend/src/test/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelperTest.java b/basyx.common/basyx.filerepository-backend/src/test/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelperTest.java new file mode 100644 index 000000000..a7cf225fc --- /dev/null +++ b/basyx.common/basyx.filerepository-backend/src/test/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelperTest.java @@ -0,0 +1,197 @@ +/******************************************************************************* + * Copyright (C) 2026 the Eclipse BaSyx Authors + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * SPDX-License-Identifier: MIT + ******************************************************************************/ + +package org.eclipse.digitaltwin.basyx.core.filerepository; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; +import org.junit.Test; + +public class FileRepositoryHelperTest { + + @Test + public void saveOrOverwriteFileRejectsPathTraversalFileName() throws IOException { + Path victimFile = Files.createTempDirectory("basyx-victim").resolve("pwned.txt"); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + + try { + assertThrows(SecurityException.class, () -> FileRepositoryHelper.saveOrOverwriteFile(fileRepository, victimFile.toAbsolutePath().toString(), "text/plain", content("payload"))); + assertFalse(Files.exists(victimFile)); + } finally { + Files.deleteIfExists(victimFile.getParent()); + } + } + + @Test + public void saveOrOverwriteFileUsesGeneratedStorageIdAndPreservesOriginalName() { + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + + String storedId = FileRepositoryHelper.saveOrOverwriteFile(fileRepository, "thumbnail.png", "image/png", content("payload")); + + assertNotEquals("thumbnail.png", storedId); + assertTrue(storedId.matches("[0-9a-fA-F\\-]{36}\\.png")); + assertTrue(fileRepository.exists(storedId)); + assertEquals("thumbnail.png", fileRepository.getOriginalFileName(storedId)); + assertArrayEquals("payload".getBytes(UTF_8), fileRepository.content(storedId)); + } + + @Test + public void saveOrOverwriteFileDropsUnsafeExtensionFromStorageId() { + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + + String storedId = FileRepositoryHelper.saveOrOverwriteFile(fileRepository, "thumbnail.pn:g", "image/png", content("payload")); + + assertTrue(storedId.matches("[0-9a-fA-F\\-]{36}")); + assertTrue(fileRepository.exists(storedId)); + assertEquals("thumbnail.pn:g", fileRepository.getOriginalFileName(storedId)); + } + + @Test + public void saveOrOverwriteFileDropsLongExtensionFromStorageId() { + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + + String storedId = FileRepositoryHelper.saveOrOverwriteFile(fileRepository, "thumbnail.abcdefghijklmnopq", "image/png", content("payload")); + + assertTrue(storedId.matches("[0-9a-fA-F\\-]{36}")); + assertTrue(fileRepository.exists(storedId)); + assertEquals("thumbnail.abcdefghijklmnopq", fileRepository.getOriginalFileName(storedId)); + } + + @Test + public void saveOrOverwriteFileRejectsControlCharacterFileName() { + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + + assertThrows(IllegalArgumentException.class, () -> FileRepositoryHelper.saveOrOverwriteFile(fileRepository, "thumbnail.pn\0g", "image/png", content("payload"))); + } + + @Test + public void fetchAndStoreFileLocallyDoesNotUseRepositoryKeyAsFilesystemPath() throws IOException { + Path victimDirectory = Files.createTempDirectory("basyx-victim"); + Path victimFile = victimDirectory.resolve("pwned.txt"); + String maliciousRepositoryKey = victimFile.toAbsolutePath().toString(); + byte[] attackerPayload = "attacker bytes".getBytes(UTF_8); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(maliciousRepositoryKey, attackerPayload); + + try { + java.io.File localFile = FileRepositoryHelper.fetchAndStoreFileLocally(fileRepository, maliciousRepositoryKey); + + assertTrue(localFile.exists()); + assertNotEquals(victimFile.toAbsolutePath().toString(), localFile.toPath().toAbsolutePath().toString()); + assertFalse(Files.exists(victimFile)); + assertArrayEquals(attackerPayload, Files.readAllBytes(localFile.toPath())); + Files.deleteIfExists(localFile.toPath()); + } finally { + Files.deleteIfExists(victimFile); + Files.deleteIfExists(victimDirectory); + } + } + + private static ByteArrayInputStream content(String content) { + return new ByteArrayInputStream(content.getBytes(UTF_8)); + } + + private static class GridFsLikeFileRepository implements FileRepository { + private final Map store = new HashMap<>(); + + @Override + public String save(FileMetadata metadata) throws FileHandlingException { + if (exists(metadata.getFileName())) + throw new FileHandlingException("File '%s' already exists.".formatted(metadata.getFileName())); + + try { + put(metadata.getFileName(), metadata.getOriginalFileName(), metadata.getFileContent().readAllBytes()); + } catch (IOException e) { + throw new FileHandlingException(metadata.getFileName(), e); + } + + return metadata.getFileName(); + } + + @Override + public InputStream find(String fileId) throws FileDoesNotExistException { + if (!exists(fileId)) + throw new FileDoesNotExistException(); + + return new ByteArrayInputStream(content(fileId)); + } + + @Override + public void delete(String fileId) throws FileDoesNotExistException { + if (!exists(fileId)) + throw new FileDoesNotExistException(); + + store.remove(fileId); + } + + @Override + public boolean exists(String fileId) { + return fileId != null && store.containsKey(fileId); + } + + @Override + public String getOriginalFileName(String fileId) { + return store.get(fileId).originalFileName; + } + + void put(String fileId, byte[] content) { + put(fileId, fileId, content); + } + + void put(String fileId, String originalFileName, byte[] content) { + store.put(fileId, new StoredFile(originalFileName, content)); + } + + byte[] content(String fileId) { + return store.get(fileId).content; + } + } + + private static class StoredFile { + private final String originalFileName; + private final byte[] content; + + StoredFile(String originalFileName, byte[] content) { + this.originalFileName = originalFileName; + this.content = content; + } + } +} diff --git a/basyx.submodelrepository/basyx.submodelrepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/backend/CrudSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/backend/CrudSubmodelRepository.java index 5f3d51df9..38e12bcd1 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/backend/CrudSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/backend/CrudSubmodelRepository.java @@ -187,6 +187,11 @@ public java.io.File getFileByPathSubmodel(String submodelId, String idShortPath) return getService(submodelId).getFileByPath(idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return getService(submodelId).getFileByPathAsStream(idShortPath); + } + @Override public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { getService(submodelId).setFileValue(idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelrepository/basyx.submodelrepository-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/client/ConnectedSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/client/ConnectedSubmodelRepository.java index 814c52e25..2f1f527ce 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/client/ConnectedSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/client/ConnectedSubmodelRepository.java @@ -235,6 +235,11 @@ public File getFileByPathSubmodel(String submodelId, String idShortPath) throws return getConnectedSubmodelService(submodelId).getFileByPath(idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return getConnectedSubmodelService(submodelId).getFileByPathAsStream(idShortPath); + } + @Override public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { getConnectedSubmodelService(submodelId).setFileValue(idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelrepository/basyx.submodelrepository-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/SubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/SubmodelRepository.java index f238dd7b3..53694c74f 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/SubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/SubmodelRepository.java @@ -25,6 +25,8 @@ package org.eclipse.digitaltwin.basyx.submodelrepository; +import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.InputStream; import java.util.List; @@ -35,6 +37,7 @@ import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException; import org.eclipse.digitaltwin.basyx.core.exceptions.ElementNotAFileException; import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; import org.eclipse.digitaltwin.basyx.core.exceptions.MissingIdentifierException; import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; @@ -231,7 +234,10 @@ public default String getName() { public Submodel getSubmodelByIdMetadata(String submodelId) throws ElementDoesNotExistException; /** - * Retrieves the file of a file submodelelement + * Retrieves the file of a file submodelelement. + *

+ * Implementations that materialize repository content as a temporary file must + * document whether the caller is responsible for deleting the returned file. * * @param submodelId * the Submodel id @@ -245,6 +251,33 @@ public default String getName() { */ public java.io.File getFileByPathSubmodel(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException; + /** + * Retrieves the content of a file submodel element as a stream. + *

+ * The file is resolved by submodel id and submodel element idShort path, not by + * the repository storage id. The caller is responsible for closing the returned + * stream. + * + * @param submodelId + * the Submodel id + * @param idShortPath + * the IdShort path of the file element + * @return File InputStream + * @throws ElementDoesNotExistException + * if the SubmodelElement does not exist + * @throws ElementNotAFileException + * if the SubmodelElement is not a File + * @throws FileDoesNotExistException + * if the referenced file content does not exist + */ + public default InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + try { + return new FileInputStream(getFileByPathSubmodel(submodelId, idShortPath)); + } catch (FileNotFoundException e) { + throw new FileHandlingException("Could not open file stream.", e); + } + } + /** * Uploads a file to a file submodelelement * @@ -288,13 +321,15 @@ public default String getName() { public void patchSubmodelElements(String submodelId, List submodelElementList); /** - * Retrieves the file of a file submodelelement via its absolute path + * Retrieves file content by repository storage id. * * @param submodelId * the Submodel id * @param filePath - * the path of the file + * the repository file id * @return File InputStream + * @throws FileDoesNotExistException + * if the repository file id does not exist */ public InputStream getFileByFilePath(String submodelId, String filePath); diff --git a/basyx.submodelrepository/basyx.submodelrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/core/SubmodelRepositorySubmodelServiceWrapper.java b/basyx.submodelrepository/basyx.submodelrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/core/SubmodelRepositorySubmodelServiceWrapper.java index ef667a6f2..bcb4acfe8 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/core/SubmodelRepositorySubmodelServiceWrapper.java +++ b/basyx.submodelrepository/basyx.submodelrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/core/SubmodelRepositorySubmodelServiceWrapper.java @@ -124,6 +124,11 @@ public File getFileByPath(String idShortPath) throws ElementDoesNotExistExceptio return repoApi.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return repoApi.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { repoApi.setFileValue(submodelId, idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java index 74497804d..9ae372996 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java @@ -277,6 +277,15 @@ public File getFileByPathSubmodel(String submodelId, String idShortPath) throws return decorated.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new SubmodelTargetInformation(getIdAsList(submodelId), getIdAsList(idShortPath))); + + throwExceptionIfInsufficientPermission(isAuthorized); + + return decorated.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { boolean isAuthorized = permissionResolver.hasPermission(Action.UPDATE, new SubmodelTargetInformation(getIdAsList(submodelId), getIdAsList(idShortPath))); @@ -306,6 +315,10 @@ public void patchSubmodelElements(String submodelId, List submo @Override public InputStream getFileByFilePath(String submodelId, String filePath) { + boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new SubmodelTargetInformation(getIdAsList(submodelId), getIdAsList(filePath))); + + throwExceptionIfInsufficientPermission(isAuthorized); + return decorated.getFileByFilePath(submodelId, filePath); } diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/kafka/KafkaSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/kafka/KafkaSubmodelRepository.java index 8aa5f7800..94606d152 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/kafka/KafkaSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/kafka/KafkaSubmodelRepository.java @@ -174,6 +174,11 @@ public java.io.File getFileByPathSubmodel(String submodelId, String idShortPath) return decorated.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) { + return decorated.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void deleteFileValue(String identifier, String idShortPath) { decorated.deleteFileValue(identifier, idShortPath); diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java index 5ca4f42ea..63d5020dc 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java @@ -183,6 +183,11 @@ public java.io.File getFileByPathSubmodel(String submodelId, String idShortPath) return decorated.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) { + return decorated.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void deleteFileValue(String identifier, String idShortPath) { SubmodelElement submodelElement = decorated.getSubmodelElement(identifier, idShortPath); diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-operation-delegation/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/operation/delegation/OperationDelegationSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-operation-delegation/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/operation/delegation/OperationDelegationSubmodelRepository.java index fd7e09601..d91b11ca8 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-feature-operation-delegation/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/operation/delegation/OperationDelegationSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-feature-operation-delegation/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/operation/delegation/OperationDelegationSubmodelRepository.java @@ -158,6 +158,11 @@ public File getFileByPathSubmodel(String submodelId, String idShortPath) throws return decorated.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return decorated.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { decorated.setFileValue(submodelId, idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/registry/integration/RegistryIntegrationSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/registry/integration/RegistryIntegrationSubmodelRepository.java index c1cf57654..db6ea53a6 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/registry/integration/RegistryIntegrationSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/registry/integration/RegistryIntegrationSubmodelRepository.java @@ -173,6 +173,11 @@ public File getFileByPathSubmodel(String submodelId, String idShortPath) throws return decorated.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return decorated.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { decorated.setFileValue(submodelId, idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/search/SearchSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/search/SearchSubmodelRepository.java index 4524d8fee..a5afb007a 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/search/SearchSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/search/SearchSubmodelRepository.java @@ -156,6 +156,11 @@ public File getFileByPathSubmodel(String submodelId, String idShortPath) throws return decorated.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return decorated.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { decorated.setFileValue(submodelId, idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelrepository/basyx.submodelrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/http/SubmodelRepositoryApiHTTPController.java b/basyx.submodelrepository/basyx.submodelrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/http/SubmodelRepositoryApiHTTPController.java index 7e422586d..410dbe254 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/http/SubmodelRepositoryApiHTTPController.java +++ b/basyx.submodelrepository/basyx.submodelrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/http/SubmodelRepositoryApiHTTPController.java @@ -60,8 +60,9 @@ import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelElementValue; import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelValueOnly; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -232,15 +233,17 @@ public ResponseEntity getSubmodelByIdMetadata(Base64UrlEncodedIdentifi @Override public ResponseEntity getFileByPath(Base64UrlEncodedIdentifier submodelIdentifier, String idShortPath) { String submodelId = submodelIdentifier.getIdentifier(); - java.io.File file = repository.getFileByPathSubmodel(submodelId, idShortPath); - Resource resource = new FileSystemResource(file); - org.eclipse.digitaltwin.aas4j.v3.model.File fileSubmodelElement = (org.eclipse.digitaltwin.aas4j.v3.model.File) repository.getSubmodelElement(submodelId, idShortPath); + SubmodelElement submodelElement = repository.getSubmodelElement(submodelId, idShortPath); + + if (!(submodelElement instanceof org.eclipse.digitaltwin.aas4j.v3.model.File fileSubmodelElement)) + throw new ElementNotAFileException(submodelElement.getIdShort()); String fileName = repository.getOriginalFileNameByPath(submodelId, idShortPath); + Resource resource = new InputStreamResource(repository.getFileByPathSubmodelAsStream(submodelId, idShortPath)); return ResponseEntity.ok() .contentType(BaSyxMediaType.parseOrOctetStream(fileSubmodelElement.getContentType())) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"") + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(fileName).build().toString()) .body(resource); } diff --git a/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelService.java b/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelService.java index 36ea383bd..850002cfc 100644 --- a/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelService.java +++ b/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelService.java @@ -44,9 +44,8 @@ import java.io.File; import java.io.InputStream; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.List; +import java.util.Objects; /** * Default {@link SubmodelService} based on {@link SubmodelBackend} @@ -113,7 +112,7 @@ public void updateSubmodelElement(String idShortPath, SubmodelElement newElement boolean newSubmodelElementIsFile = newElement instanceof org.eclipse.digitaltwin.aas4j.v3.model.File; if (existingSubmodelElementIsFile && newSubmodelElementIsFile) { - handleFileAttachmentUpdate(submodelId, idShortPath, newElement); + handleFileAttachmentUpdate(idShortPath, existingSubmodelElement, newElement); } else if (existingSubmodelElementIsFile) { deleteAssociatedFileIfAny(idShortPath); } @@ -121,9 +120,9 @@ public void updateSubmodelElement(String idShortPath, SubmodelElement newElement backend.updateSubmodelElement(submodelId, idShortPath, newElement); } - private void handleFileAttachmentUpdate(String submodelId, String idShortPath, SubmodelElement updatedElement) { + private void handleFileAttachmentUpdate(String idShortPath, SubmodelElement existingElement, SubmodelElement updatedElement) { try { - if (hasFilePathChanged(submodelId, idShortPath, updatedElement)) { + if (hasFilePathChanged(existingElement, updatedElement)) { deleteAssociatedFileIfAny(idShortPath); } } catch (Exception e) { @@ -132,27 +131,18 @@ private void handleFileAttachmentUpdate(String submodelId, String idShortPath, S } } - private boolean hasFilePathChanged(String submodelId, String idShortPath, SubmodelElement updatedElement) { - if (!(updatedElement instanceof org.eclipse.digitaltwin.aas4j.v3.model.File file)) { - logger.warn("Expected File element but got: {}", updatedElement.getClass().getSimpleName()); - return true; - } - - String newPathString = extractFilePath(file); - if (newPathString == null) { + private boolean hasFilePathChanged(SubmodelElement existingElement, SubmodelElement updatedElement) { + if (!(existingElement instanceof org.eclipse.digitaltwin.aas4j.v3.model.File existingFile)) { + logger.warn("Expected File element but got: {}", existingElement.getClass().getSimpleName()); return true; } - String oldPathString = getExistingFilePath(submodelId, idShortPath); - if (oldPathString == null) { + if (!(updatedElement instanceof org.eclipse.digitaltwin.aas4j.v3.model.File file)) { + logger.warn("Expected File element but got: {}", updatedElement.getClass().getSimpleName()); return true; } - return pathsDiffer(oldPathString, newPathString); - } - - private boolean isFileElement(SubmodelElement element) { - return element instanceof org.eclipse.digitaltwin.aas4j.v3.model.File; + return !Objects.equals(extractFilePath(existingFile), extractFilePath(file)); } // Extracts the new file path (value) from the updated File element @@ -160,26 +150,6 @@ private String extractFilePath(org.eclipse.digitaltwin.aas4j.v3.model.File fileE return fileElement.getValue(); } - private String getExistingFilePath(String submodelId, String idShortPath) { - try { - return submodelFileOperations.getFile(submodelId, idShortPath).getPath(); - } catch (Exception e) { - logger.warn("Failed to retrieve existing file path for '{}': {}", idShortPath, e.getMessage(), e); - return null; - } - } - - private boolean pathsDiffer(String path1, String path2) { - try { - Path normalized1 = Paths.get(path1).normalize().toAbsolutePath(); - Path normalized2 = Paths.get(path2).normalize().toAbsolutePath(); - return !normalized1.equals(normalized2); - } catch (Exception e) { - logger.error("Error comparing file paths: {}", e.getMessage(), e); - return true; // Consider paths different in case of error - } - } - @Override public void deleteSubmodelElement(String idShortPath) throws ElementDoesNotExistException { deleteAssociatedFileIfAny(idShortPath); @@ -207,6 +177,11 @@ public File getFileByPath(String idShortPath) throws ElementDoesNotExistExceptio return submodelFileOperations.getFile(submodelId, idShortPath); } + @Override + public InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return submodelFileOperations.getFileAsStream(submodelId, idShortPath); + } + @Override public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { submodelFileOperations.setFileValue(submodelId, idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/SubmodelFileOperations.java b/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/SubmodelFileOperations.java index 99c4f4e26..ebd5ff5cf 100644 --- a/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/SubmodelFileOperations.java +++ b/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/SubmodelFileOperations.java @@ -25,20 +25,15 @@ package org.eclipse.digitaltwin.basyx.submodelservice.backend; -import java.io.FileOutputStream; -import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; -import java.util.UUID; import org.eclipse.digitaltwin.aas4j.v3.model.File; import org.eclipse.digitaltwin.aas4j.v3.model.SubmodelElement; import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException; import org.eclipse.digitaltwin.basyx.core.exceptions.ElementNotAFileException; import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; -import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; -import org.eclipse.digitaltwin.basyx.core.filerepository.FileMetadata; import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepository; +import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepositoryHelper; import org.eclipse.digitaltwin.basyx.submodelservice.value.FileBlobValue; /** @@ -55,6 +50,24 @@ public SubmodelFileOperations(FileRepository fileRepository, SubmodelOperations this.submodelOperations = operations; } + /** + * Retrieves a file submodel element's content as a temporary local file. + *

+ * The caller owns the returned temporary file and is responsible for deleting + * it when it is no longer needed. + * + * @param submodelId + * the id of the Submodel + * @param idShortPath + * the idShort path of the file element + * @return the file content materialized as a temporary file + * @throws ElementDoesNotExistException + * if the SubmodelElement does not exist + * @throws ElementNotAFileException + * if the SubmodelElement is not a File + * @throws FileDoesNotExistException + * if the referenced file content does not exist + */ public java.io.File getFile(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { SubmodelElement submodelElement = submodelOperations.getSubmodelElement(submodelId, idShortPath); @@ -63,36 +76,84 @@ public java.io.File getFile(String submodelId, String idShortPath) throws Elemen File fileSmElement = (File) submodelElement; String filePath = getFilePath(fileSmElement); - InputStream fileContent = getFileInputStream(filePath); + return FileRepositoryHelper.fetchAndStoreFileLocally(fileRepository, filePath); + } + + /** + * Retrieves a file submodel element's content as a stream. + *

+ * The file is resolved by submodel id and element idShort path, not by the + * repository storage id. The caller is responsible for closing the returned + * stream. + * + * @param submodelId + * the id of the Submodel + * @param idShortPath + * the idShort path of the file element + * @return the file content stream + * @throws ElementDoesNotExistException + * if the SubmodelElement does not exist + * @throws ElementNotAFileException + * if the SubmodelElement is not a File + * @throws FileDoesNotExistException + * if the referenced file content does not exist + */ + public InputStream getFileAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + SubmodelElement submodelElement = submodelOperations.getSubmodelElement(submodelId, idShortPath); + + throwIfSmElementIsNotAFile(submodelElement); - return createFile(filePath, fileContent); + File fileSmElement = (File) submodelElement; + String filePath = getFilePath(fileSmElement); + + return FileRepositoryHelper.getFileInputStream(fileRepository, filePath); } + /** + * Stores new content for a file submodel element and updates the element value + * with the generated repository id. + * + * @param submodelId + * the id of the Submodel + * @param idShortPath + * the idShort path of the file element + * @param fileName + * the logical display name of the uploaded file + * @param contentType + * the file MIME type + * @param inputStream + * the file content stream + * @throws ElementDoesNotExistException + * if the SubmodelElement does not exist + * @throws ElementNotAFileException + * if the SubmodelElement is not a File + */ public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { SubmodelElement submodelElement = submodelOperations.getSubmodelElement(submodelId, idShortPath); throwIfSmElementIsNotAFile(submodelElement); File fileSmElement = (File) submodelElement; + String oldFilePath = fileSmElement.getValue(); - if (fileRepository.exists(fileSmElement.getValue())) - fileRepository.delete(fileSmElement.getValue()); - - String logicalFileName = validateLogicalFileName(fileName); - String uniqueFileName = createUniqueFileName(logicalFileName); - - FileMetadata fileMetadata = new FileMetadata(uniqueFileName, logicalFileName, contentType, inputStream); - - if (fileRepository.exists(fileMetadata.getFileName())) - fileRepository.delete(fileMetadata.getFileName()); - - String filePath = fileRepository.save(fileMetadata); - - FileBlobValue fileValue = new FileBlobValue(contentType, filePath); - - submodelOperations.setSubmodelElementValue(submodelId, idShortPath, fileValue); + FileRepositoryHelper.saveAndUpdateReference(fileRepository, oldFilePath, fileName, contentType, inputStream, filePath -> submodelOperations.setSubmodelElementValue(submodelId, idShortPath, new FileBlobValue(contentType, filePath))); } + /** + * Clears a file submodel element value and deletes the referenced repository + * content. + * + * @param submodelId + * the id of the Submodel + * @param idShortPath + * the idShort path of the file element + * @throws ElementDoesNotExistException + * if the SubmodelElement does not exist + * @throws ElementNotAFileException + * if the SubmodelElement is not a File + * @throws FileDoesNotExistException + * if the referenced file content does not exist + */ public void deleteFileValue(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { SubmodelElement submodelElement = submodelOperations.getSubmodelElement(submodelId, idShortPath); @@ -101,17 +162,32 @@ public void deleteFileValue(String submodelId, String idShortPath) throws Elemen File fileSubmodelElement = (File) submodelElement; String filePath = fileSubmodelElement.getValue(); - fileRepository.delete(filePath); - - FileBlobValue fileValue = new FileBlobValue(" ", " "); - - submodelOperations.setSubmodelElementValue(submodelId, idShortPath, fileValue); + FileRepositoryHelper.updateReferenceAndDeleteFile(fileRepository, filePath, () -> submodelOperations.setSubmodelElementValue(submodelId, idShortPath, new FileBlobValue(" ", " "))); } - public InputStream getInputStream(String filePath) throws FileDoesNotExistException{ - return fileRepository.find(filePath); + /** + * Retrieves repository content by repository storage id. + *

+ * Prefer {@link #getFileAsStream(String, String)} for domain-level file + * downloads. The caller is responsible for closing the returned stream. + * + * @param filePath + * the repository file id + * @return the repository content stream + * @throws FileDoesNotExistException + * if the repository file id does not exist + */ + public InputStream getInputStream(String filePath) throws FileDoesNotExistException { + return FileRepositoryHelper.getFileInputStream(fileRepository, filePath); } + /** + * Resolves the original logical filename for a repository file id. + * + * @param filePath + * the repository file id + * @return the original logical filename + */ public String getOriginalFileName(String filePath) { return fileRepository.getOriginalFileName(filePath); } @@ -120,74 +196,14 @@ private static boolean isFileSubmodelElement(SubmodelElement submodelElement) { return submodelElement instanceof File; } - private InputStream getFileInputStream(String filePath) { - InputStream fileContent; - - try { - fileContent = fileRepository.find(filePath); - } catch (FileDoesNotExistException e) { - throw new FileDoesNotExistException(String.format("File at path '%s' could not be found.", filePath)); - } - - return fileContent; - } - - private static java.io.File createFile(String filePath, InputStream fileIs) { - - try { - byte[] content = fileIs.readAllBytes(); - fileIs.close(); - - createOutputStream(filePath, content); - - return new java.io.File(filePath); - } catch (IOException e) { - throw new FileHandlingException("Exception occurred while creating file from the InputStream." + e.getMessage()); - } - - } - private static String getFilePath(File fileSubmodelElement) { return fileSubmodelElement.getValue(); } - private static String createUniqueFileName(String logicalFileName) { - return UUID.randomUUID() + getFileExtension(logicalFileName); - } - - private static String getFileExtension(String fileName) { - int extensionStart = fileName.lastIndexOf('.'); - - if (extensionStart <= 0 || extensionStart >= fileName.length() - 1) - return ""; - - return fileName.substring(extensionStart); - } - - private static String validateLogicalFileName(String fileName) { - if (fileName == null || fileName.isBlank()) - throw new IllegalArgumentException("File name must not be null or blank."); - - if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) - throw new SecurityException("Path traversal attempt detected."); - - return fileName.replace("\r", "_").replace("\n", "_").trim(); - } - private static void throwIfSmElementIsNotAFile(SubmodelElement submodelElement) { if (!isFileSubmodelElement(submodelElement)) throw new ElementNotAFileException(submodelElement.getIdShort()); } - private static void createOutputStream(String filePath, byte[] content) throws IOException { - - try (OutputStream outputStream = new FileOutputStream(filePath)) { - outputStream.write(content); - } catch (IOException e) { - throw new FileHandlingException("Exception occurred while creating OutputStream from byte[]." + e.getMessage()); - } - - } - } diff --git a/basyx.submodelservice/basyx.submodelservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelServiceFileHandlingTest.java b/basyx.submodelservice/basyx.submodelservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelServiceFileHandlingTest.java new file mode 100644 index 000000000..97edc5e9b --- /dev/null +++ b/basyx.submodelservice/basyx.submodelservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelServiceFileHandlingTest.java @@ -0,0 +1,265 @@ +/******************************************************************************* + * Copyright (C) 2026 the Eclipse BaSyx Authors + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * SPDX-License-Identifier: MIT + ******************************************************************************/ + +package org.eclipse.digitaltwin.basyx.submodelservice.backend; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.digitaltwin.aas4j.v3.model.SubmodelElement; +import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultFile; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; +import org.eclipse.digitaltwin.basyx.core.filerepository.FileMetadata; +import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepository; +import org.junit.Test; + +public class CrudSubmodelServiceFileHandlingTest { + + private static final String SUBMODEL_ID = "submodel-1"; + private static final String FILE_ID_SHORT = "document"; + + @Test + public void getFileByPathWithAbsoluteStoredPathWritesOnlyTempFile() throws IOException { + Path victimDirectory = Files.createTempDirectory("basyx-victim"); + Path victimFile = victimDirectory.resolve("pwned.txt"); + String maliciousRepositoryKey = victimFile.toAbsolutePath().toString(); + byte[] attackerPayload = "attacker bytes".getBytes(UTF_8); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(maliciousRepositoryKey, attackerPayload); + TestSubmodelBackend backend = new TestSubmodelBackend(fileElement(maliciousRepositoryKey)); + CrudSubmodelService service = new CrudSubmodelService(backend.proxy(), fileRepository, SUBMODEL_ID); + + try { + java.io.File localFile = service.getFileByPath(FILE_ID_SHORT); + + assertTrue(localFile.exists()); + assertNotEquals(victimFile.toAbsolutePath().toString(), localFile.toPath().toAbsolutePath().toString()); + assertFalse(Files.exists(victimFile)); + assertArrayEquals(attackerPayload, Files.readAllBytes(localFile.toPath())); + Files.deleteIfExists(localFile.toPath()); + } finally { + Files.deleteIfExists(victimFile); + Files.deleteIfExists(victimDirectory); + } + } + + @Test + public void getFileByPathAsStreamWithAbsoluteStoredPathDoesNotWriteToDisk() throws IOException { + Path victimDirectory = Files.createTempDirectory("basyx-victim"); + Path victimFile = victimDirectory.resolve("pwned.txt"); + String maliciousRepositoryKey = victimFile.toAbsolutePath().toString(); + byte[] attackerPayload = "attacker bytes".getBytes(UTF_8); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(maliciousRepositoryKey, attackerPayload); + TestSubmodelBackend backend = new TestSubmodelBackend(fileElement(maliciousRepositoryKey)); + CrudSubmodelService service = new CrudSubmodelService(backend.proxy(), fileRepository, SUBMODEL_ID); + + try (InputStream inputStream = service.getFileByPathAsStream(FILE_ID_SHORT)) { + assertArrayEquals(attackerPayload, inputStream.readAllBytes()); + assertFalse(Files.exists(victimFile)); + } finally { + Files.deleteIfExists(victimFile); + Files.deleteIfExists(victimDirectory); + } + } + + @Test + public void updateFileElementWithUnchangedPathDoesNotMaterializeExistingFile() throws IOException { + Path victimDirectory = Files.createTempDirectory("basyx-victim"); + Path victimFile = victimDirectory.resolve("pwned.txt"); + String maliciousRepositoryKey = victimFile.toAbsolutePath().toString(); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(maliciousRepositoryKey, "attacker bytes".getBytes(UTF_8)); + TestSubmodelBackend backend = new TestSubmodelBackend(fileElement(maliciousRepositoryKey)); + CrudSubmodelService service = new CrudSubmodelService(backend.proxy(), fileRepository, SUBMODEL_ID); + + try { + service.updateSubmodelElement(FILE_ID_SHORT, fileElement(maliciousRepositoryKey)); + + assertEquals(0, fileRepository.findCalls); + assertEquals(1, backend.updateCalls); + assertFalse(Files.exists(victimFile)); + } finally { + Files.deleteIfExists(victimFile); + Files.deleteIfExists(victimDirectory); + } + } + + @Test + public void setFileValueCleansNewFileAndKeepsOldFileWhenValueUpdateFails() { + String oldFilePath = "old-file"; + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(oldFilePath, "old".getBytes(UTF_8)); + TestSubmodelBackend backend = new TestSubmodelBackend(fileElement(oldFilePath)); + backend.failSetSubmodelElementValue = true; + CrudSubmodelService service = new CrudSubmodelService(backend.proxy(), fileRepository, SUBMODEL_ID); + + try { + service.setFileValue(FILE_ID_SHORT, "new.txt", "text/plain", new ByteArrayInputStream("new".getBytes(UTF_8))); + } catch (RuntimeException e) { + assertTrue(fileRepository.exists(oldFilePath)); + assertEquals(1, fileRepository.size()); + return; + } + + throw new AssertionError("Expected setFileValue to fail."); + } + + @Test + public void deleteFileValueKeepsFileWhenValueUpdateFails() { + String oldFilePath = "old-file"; + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(oldFilePath, "old".getBytes(UTF_8)); + TestSubmodelBackend backend = new TestSubmodelBackend(fileElement(oldFilePath)); + backend.failSetSubmodelElementValue = true; + CrudSubmodelService service = new CrudSubmodelService(backend.proxy(), fileRepository, SUBMODEL_ID); + + assertThrows(RuntimeException.class, () -> service.deleteFileValue(FILE_ID_SHORT)); + + assertTrue(fileRepository.exists(oldFilePath)); + assertEquals(1, fileRepository.size()); + } + + private static org.eclipse.digitaltwin.aas4j.v3.model.File fileElement(String value) { + return new DefaultFile.Builder().idShort(FILE_ID_SHORT).contentType("text/plain").value(value).build(); + } + + private static class TestSubmodelBackend implements InvocationHandler { + private final AtomicReference submodelElement; + private int updateCalls; + private boolean failSetSubmodelElementValue; + + TestSubmodelBackend(SubmodelElement submodelElement) { + this.submodelElement = new AtomicReference<>(submodelElement); + } + + SubmodelBackend proxy() { + return (SubmodelBackend) Proxy.newProxyInstance(SubmodelBackend.class.getClassLoader(), new Class[] { SubmodelBackend.class }, this); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + if (method.getDeclaringClass() == Object.class) + return invokeObjectMethod(proxy, method, args); + + switch (method.getName()) { + case "getSubmodelElement": + return submodelElement.get(); + case "updateSubmodelElement": + submodelElement.set((SubmodelElement) args[2]); + updateCalls++; + return null; + case "setSubmodelElementValue": + if (failSetSubmodelElementValue) + throw new RuntimeException("Could not update submodel element value."); + + return null; + default: + throw new UnsupportedOperationException(method.getName()); + } + } + + private Object invokeObjectMethod(Object proxy, Method method, Object[] args) { + switch (method.getName()) { + case "equals": + return proxy == args[0]; + case "hashCode": + return System.identityHashCode(proxy); + case "toString": + return "TestSubmodelBackend"; + default: + throw new UnsupportedOperationException(method.getName()); + } + } + } + + private static class GridFsLikeFileRepository implements FileRepository { + private final Map store = new HashMap<>(); + private int findCalls; + + @Override + public String save(FileMetadata metadata) throws FileHandlingException { + if (exists(metadata.getFileName())) + throw new FileHandlingException("File '%s' already exists.".formatted(metadata.getFileName())); + + try { + store.put(metadata.getFileName(), metadata.getFileContent().readAllBytes()); + } catch (IOException e) { + throw new FileHandlingException(metadata.getFileName(), e); + } + + return metadata.getFileName(); + } + + @Override + public InputStream find(String fileId) throws FileDoesNotExistException { + findCalls++; + + if (!exists(fileId)) + throw new FileDoesNotExistException(); + + return new ByteArrayInputStream(store.get(fileId)); + } + + @Override + public void delete(String fileId) throws FileDoesNotExistException { + if (!exists(fileId)) + throw new FileDoesNotExistException(); + + store.remove(fileId); + } + + @Override + public boolean exists(String fileId) { + return fileId != null && store.containsKey(fileId); + } + + void put(String fileId, byte[] content) { + store.put(fileId, content); + } + + int size() { + return store.size(); + } + } +} diff --git a/basyx.submodelservice/basyx.submodelservice-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/client/ConnectedSubmodelService.java b/basyx.submodelservice/basyx.submodelservice-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/client/ConnectedSubmodelService.java index a31ad8c0c..48da15edf 100644 --- a/basyx.submodelservice/basyx.submodelservice-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/client/ConnectedSubmodelService.java +++ b/basyx.submodelservice/basyx.submodelservice-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/client/ConnectedSubmodelService.java @@ -27,7 +27,10 @@ package org.eclipse.digitaltwin.basyx.submodelservice.client; import java.io.File; +import java.io.FilterInputStream; +import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; import java.util.List; import org.apache.commons.lang3.NotImplementedException; @@ -176,6 +179,11 @@ public File getFileByPath(String idShortPath) throws ElementDoesNotExistExceptio } } + @Override + public InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return openFileAndDeleteOnClose(getFileByPath(idShortPath)); + } + @Override public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { try { @@ -208,6 +216,23 @@ public InputStream getFileByFilePath(String filePath) { throw new NotImplementedException("This Method is not implemented in the Client"); } + private static InputStream openFileAndDeleteOnClose(File file) { + try { + return new FilterInputStream(Files.newInputStream(file.toPath())) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + Files.deleteIfExists(file.toPath()); + } + } + }; + } catch (IOException e) { + throw new FileHandlingException("Could not open file stream.", e); + } + } + private RuntimeException mapExceptionFileAccess(String idShortPath, ApiException e) { if (e.getCode() == HttpStatus.NOT_FOUND.value()) return new FileDoesNotExistException(idShortPath); diff --git a/basyx.submodelservice/basyx.submodelservice-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/SubmodelService.java b/basyx.submodelservice/basyx.submodelservice-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/SubmodelService.java index 50eb3c08c..76f175bd3 100644 --- a/basyx.submodelservice/basyx.submodelservice-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/SubmodelService.java +++ b/basyx.submodelservice/basyx.submodelservice-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/SubmodelService.java @@ -25,6 +25,8 @@ package org.eclipse.digitaltwin.basyx.submodelservice; +import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.InputStream; import java.util.List; @@ -34,6 +36,7 @@ import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException; import org.eclipse.digitaltwin.basyx.core.exceptions.ElementNotAFileException; import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelElementValue; @@ -147,7 +150,10 @@ public interface SubmodelService { public OperationVariable[] invokeOperation(String idShortPath, OperationVariable[] input) throws ElementDoesNotExistException; /** - * Retrieves the file of a file submodelelement + * Retrieves the file of a file submodelelement. + *

+ * Implementations that materialize repository content as a temporary file must + * document whether the caller is responsible for deleting the returned file. * * @param idShortPath * the IdShort path of the file element @@ -158,6 +164,31 @@ public interface SubmodelService { */ public java.io.File getFileByPath(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException; + /** + * Retrieves the content of a file submodel element as a stream. + *

+ * The file is resolved by the submodel element idShort path, not by the + * repository storage id. The caller is responsible for closing the returned + * stream. + * + * @param idShortPath + * the IdShort path of the file element + * @return File InputStream + * @throws ElementDoesNotExistException + * if the SubmodelElement does not exist + * @throws ElementNotAFileException + * if the SubmodelElement is not a File + * @throws FileDoesNotExistException + * if the referenced file content does not exist + */ + public default InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + try { + return new FileInputStream(getFileByPath(idShortPath)); + } catch (FileNotFoundException e) { + throw new FileHandlingException("Could not open file stream.", e); + } + } + /** * Uploads a file to a file submodelelement * @@ -189,11 +220,13 @@ public interface SubmodelService { /** - * Retrieves the file of a file submodelelement via its absolute path + * Retrieves file content by repository storage id. * * @param filePath - * the path of the file + * the repository file id * @return File InputStream + * @throws FileDoesNotExistException + * if the repository file id does not exist */ public InputStream getFileByFilePath(String filePath); diff --git a/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelService.java b/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelService.java index 4b2e3a58a..812f458a5 100644 --- a/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelService.java +++ b/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelService.java @@ -86,6 +86,13 @@ public File getFileByPath(String idShortPath) throws ElementDoesNotExistExceptio return decorated.getFileByPath(idShortPath); } + @Override + public InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new SubmodelTargetInformation(List.of(smId), List.of(idShortPath))); + throwExceptionIfInsufficientPermission(isAuthorized); + return decorated.getFileByPathAsStream(idShortPath); + } + @Override public SubmodelElement getSubmodelElement(String idShortPath) throws ElementDoesNotExistException { boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new SubmodelTargetInformation(List.of(smId), List.of(idShortPath))); diff --git a/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelServiceFileStreamTest.java b/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelServiceFileStreamTest.java new file mode 100644 index 000000000..07faa2a1a --- /dev/null +++ b/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelServiceFileStreamTest.java @@ -0,0 +1,99 @@ +/******************************************************************************* + * Copyright (C) 2026 the Eclipse BaSyx Authors + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * SPDX-License-Identifier: MIT + ******************************************************************************/ + +package org.eclipse.digitaltwin.basyx.submodelservice.feature.authorization; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.List; + +import org.eclipse.digitaltwin.basyx.authorization.rbac.Action; +import org.eclipse.digitaltwin.basyx.authorization.rbac.RbacPermissionResolver; +import org.eclipse.digitaltwin.basyx.core.exceptions.InsufficientPermissionException; +import org.eclipse.digitaltwin.basyx.submodelservice.SubmodelService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class AuthorizedSubmodelServiceFileStreamTest { + + private static final String SUBMODEL_ID = "smId"; + private static final String FILE_ID_SHORT_PATH = "document"; + private static final String REPOSITORY_FILE_ID = "file-repository-id"; + private static final byte[] FILE_CONTENT = "content".getBytes(); + + @Test + void getFileByPathAsStreamAuthorizesByFileIdShortPath() throws Exception { + SubmodelService decorated = mock(SubmodelService.class); + when(decorated.getFileByPathAsStream(FILE_ID_SHORT_PATH)).thenReturn(new ByteArrayInputStream(FILE_CONTENT)); + RbacPermissionResolver permissionResolver = permissionResolverAllowing(FILE_ID_SHORT_PATH); + + AuthorizedSubmodelService service = new AuthorizedSubmodelService(decorated, permissionResolver, SUBMODEL_ID); + + try (InputStream result = service.getFileByPathAsStream(FILE_ID_SHORT_PATH)) { + assertArrayEquals(FILE_CONTENT, result.readAllBytes()); + } + + ArgumentCaptor targetInformation = ArgumentCaptor.forClass(SubmodelTargetInformation.class); + verify(permissionResolver).hasPermission(eq(Action.READ), targetInformation.capture()); + assertEquals(List.of(SUBMODEL_ID), targetInformation.getValue().getSubmodelIds()); + assertEquals(List.of(FILE_ID_SHORT_PATH), targetInformation.getValue().getSubmodelElementIdShortPaths()); + verify(decorated).getFileByPathAsStream(FILE_ID_SHORT_PATH); + verify(decorated, never()).getFileByFilePath(anyString()); + } + + @Test + void getFileByPathAsStreamDoesNotAuthorizeByRepositoryFileId() throws Exception { + SubmodelService decorated = mock(SubmodelService.class); + RbacPermissionResolver permissionResolver = permissionResolverAllowing(REPOSITORY_FILE_ID); + + AuthorizedSubmodelService service = new AuthorizedSubmodelService(decorated, permissionResolver, SUBMODEL_ID); + + assertThrows(InsufficientPermissionException.class, () -> service.getFileByPathAsStream(FILE_ID_SHORT_PATH)); + verify(decorated, never()).getFileByPathAsStream(anyString()); + verify(decorated, never()).getFileByFilePath(anyString()); + } + + private static RbacPermissionResolver permissionResolverAllowing(String allowedIdShortPath) { + @SuppressWarnings("unchecked") + RbacPermissionResolver permissionResolver = mock(RbacPermissionResolver.class); + when(permissionResolver.hasPermission(eq(Action.READ), any(SubmodelTargetInformation.class))).thenAnswer(invocation -> { + SubmodelTargetInformation targetInformation = invocation.getArgument(1); + return List.of(SUBMODEL_ID).equals(targetInformation.getSubmodelIds()) && List.of(allowedIdShortPath).equals(targetInformation.getSubmodelElementIdShortPaths()); + }); + return permissionResolver; + } + +} diff --git a/basyx.submodelservice/basyx.submodelservice-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/kafka/KafkaSubmodelService.java b/basyx.submodelservice/basyx.submodelservice-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/kafka/KafkaSubmodelService.java index 7686b9eb4..0190f0421 100644 --- a/basyx.submodelservice/basyx.submodelservice-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/kafka/KafkaSubmodelService.java +++ b/basyx.submodelservice/basyx.submodelservice-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/kafka/KafkaSubmodelService.java @@ -129,6 +129,12 @@ public File getFileByPath(String idShortPath) return decorated.getFileByPath(idShortPath); } + @Override + public InputStream getFileByPathAsStream(String idShortPath) + throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return decorated.getFileByPathAsStream(idShortPath); + } + @Override public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelService.java b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelService.java index 190a43f2f..2a8773c68 100644 --- a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelService.java +++ b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelService.java @@ -139,6 +139,11 @@ public File getFileByPath(String idShortPath) throws ElementDoesNotExistExceptio return decorated.getFileByPath(idShortPath); } + @Override + public InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return decorated.getFileByPathAsStream(idShortPath); + } + @Override public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { decorated.setFileValue(idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelservice/basyx.submodelservice-feature-operation-dispatching/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/operationdispatching/AbstractSubmodelServiceDecorator.java b/basyx.submodelservice/basyx.submodelservice-feature-operation-dispatching/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/operationdispatching/AbstractSubmodelServiceDecorator.java index bf50af5de..8d69a3264 100644 --- a/basyx.submodelservice/basyx.submodelservice-feature-operation-dispatching/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/operationdispatching/AbstractSubmodelServiceDecorator.java +++ b/basyx.submodelservice/basyx.submodelservice-feature-operation-dispatching/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/operationdispatching/AbstractSubmodelServiceDecorator.java @@ -115,6 +115,12 @@ public File getFileByPath(String idShortPath) return decorated.getFileByPath(idShortPath); } + @Override + public InputStream getFileByPathAsStream(String idShortPath) + throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return decorated.getFileByPathAsStream(idShortPath); + } + @Override public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { @@ -131,4 +137,4 @@ public void deleteFileValue(String idShortPath) public InputStream getFileByFilePath(String filePath) { return decorated.getFileByFilePath(filePath); } -} \ No newline at end of file +} diff --git a/basyx.submodelservice/basyx.submodelservice-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/http/SubmodelServiceHTTPApiController.java b/basyx.submodelservice/basyx.submodelservice-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/http/SubmodelServiceHTTPApiController.java index 6b39f31a1..b59bdd332 100644 --- a/basyx.submodelservice/basyx.submodelservice-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/http/SubmodelServiceHTTPApiController.java +++ b/basyx.submodelservice/basyx.submodelservice-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/http/SubmodelServiceHTTPApiController.java @@ -52,7 +52,7 @@ import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelElementValue; import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelValueOnly; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -259,8 +259,12 @@ private String getEncodedCursorFromCursorResult(CursorResult cursorResult) { @Override public ResponseEntity getFileByPath(String idShortPath) { - Resource resource = new FileSystemResource(service.getFileByPath(idShortPath)); - org.eclipse.digitaltwin.aas4j.v3.model.File fileSubmodelElement = (org.eclipse.digitaltwin.aas4j.v3.model.File) service.getSubmodelElement(idShortPath); + SubmodelElement submodelElement = service.getSubmodelElement(idShortPath); + + if (!(submodelElement instanceof org.eclipse.digitaltwin.aas4j.v3.model.File fileSubmodelElement)) + throw new ElementNotAFileException(submodelElement.getIdShort()); + + Resource resource = new InputStreamResource(service.getFileByPathAsStream(idShortPath)); return ResponseEntity.ok() .contentType(BaSyxMediaType.parseOrOctetStream(fileSubmodelElement.getContentType())) From 310d986834af8618d3b3461a1b3cf737666a09ef Mon Sep 17 00:00:00 2001 From: Aaron Zielstorff Date: Fri, 3 Jul 2026 10:41:15 +0200 Subject: [PATCH 2/2] Fixes failing tests --- .../filerepository/FileRepositoryHelper.java | 21 +++++++++++++++---- .../FileRepositoryHelperTest.java | 1 + 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/basyx.common/basyx.filerepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelper.java b/basyx.common/basyx.filerepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelper.java index 2760a9362..897966859 100644 --- a/basyx.common/basyx.filerepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelper.java +++ b/basyx.common/basyx.filerepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelper.java @@ -52,9 +52,10 @@ private FileRepositoryHelper() { /** * Retrieves repository content and materializes it as a temporary local file. *

- * The repository id is never used as a filesystem path. The caller owns the - * returned temporary file and is responsible for deleting it when it is no - * longer needed. + * The repository id is never used as a filesystem path. A conservative file + * extension is preserved for compatibility with callers that inspect the + * returned file name. The caller owns the returned temporary file and is + * responsible for deleting it when it is no longer needed. * * @param fileRepository * the file repository instance @@ -68,7 +69,7 @@ public static File fetchAndStoreFileLocally(FileRepository fileRepository, Strin Path temporaryFile = null; try (InputStream fileIs = fileRepository.find(filePath)) { - temporaryFile = Files.createTempFile("basyx-file-", ".tmp"); + temporaryFile = Files.createTempFile("basyx-file-", getSafeTempFileSuffix(filePath)); Files.copy(fileIs, temporaryFile, StandardCopyOption.REPLACE_EXISTING); return temporaryFile.toFile(); } catch (IOException e) { @@ -211,6 +212,9 @@ private static String createUniqueFileName(String logicalFileName) { } private static String getFileExtension(String fileName) { + if (fileName == null) + return ""; + int extensionStart = fileName.lastIndexOf('.'); if (extensionStart <= 0 || extensionStart >= fileName.length() - 1) @@ -224,6 +228,15 @@ private static String getFileExtension(String fileName) { return "." + extension; } + private static String getSafeTempFileSuffix(String filePath) { + String extension = getFileExtension(filePath); + + if (extension.isEmpty()) + return ".tmp"; + + return extension; + } + private static boolean isAsciiLetterOrDigit(int character) { return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9'; } diff --git a/basyx.common/basyx.filerepository-backend/src/test/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelperTest.java b/basyx.common/basyx.filerepository-backend/src/test/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelperTest.java index a7cf225fc..a89e832e5 100644 --- a/basyx.common/basyx.filerepository-backend/src/test/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelperTest.java +++ b/basyx.common/basyx.filerepository-backend/src/test/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelperTest.java @@ -115,6 +115,7 @@ public void fetchAndStoreFileLocallyDoesNotUseRepositoryKeyAsFilesystemPath() th java.io.File localFile = FileRepositoryHelper.fetchAndStoreFileLocally(fileRepository, maliciousRepositoryKey); assertTrue(localFile.exists()); + assertTrue(localFile.getName().endsWith(".txt")); assertNotEquals(victimFile.toAbsolutePath().toString(), localFile.toPath().toAbsolutePath().toString()); assertFalse(Files.exists(victimFile)); assertArrayEquals(attackerPayload, Files.readAllBytes(localFile.toPath()));