diff --git a/backend/common/src/main/java/org/eclipse/sw360/datahandler/db/ComponentDatabaseHandler.java b/backend/common/src/main/java/org/eclipse/sw360/datahandler/db/ComponentDatabaseHandler.java index 1f1c44724a..4f2f091169 100644 --- a/backend/common/src/main/java/org/eclipse/sw360/datahandler/db/ComponentDatabaseHandler.java +++ b/backend/common/src/main/java/org/eclipse/sw360/datahandler/db/ComponentDatabaseHandler.java @@ -2940,6 +2940,21 @@ public RequestSummary importBomFromAttachmentContent(User user, String attachmen } } + public SpdxImportDryRunResult dryRunImportBom(User user, String attachmentContentId) throws SW360Exception { + final AttachmentContent attachmentContent = attachmentConnector.getAttachmentContent(attachmentContentId); + final Duration timeout = Duration.durationOf(30, TimeUnit.SECONDS); + try { + final AttachmentStreamConnector attachmentStreamConnector = new AttachmentStreamConnector(timeout); + try (final InputStream inputStream = attachmentStreamConnector.unsafeGetAttachmentStream(attachmentContent)) { + final SpdxBOMImporterSink spdxBOMImporterSink = new SpdxBOMImporterSink(user, null, this); + final SpdxBOMImporter spdxBOMImporter = new SpdxBOMImporter(spdxBOMImporterSink); + return spdxBOMImporter.dryRunImportSpdxBOMAsRelease(inputStream, attachmentContent); + } + } catch (IOException e) { + throw new SW360Exception(e.getMessage()); + } + } + private String getFileType(String fileName) { if (isNullEmptyOrWhitespace(fileName) || !fileName.contains(".")) { log.error("Can not get file type from file name - no file extension"); diff --git a/backend/common/src/main/java/org/eclipse/sw360/spdx/SpdxBOMImporter.java b/backend/common/src/main/java/org/eclipse/sw360/spdx/SpdxBOMImporter.java index f436fd0400..b5c5d35b3d 100644 --- a/backend/common/src/main/java/org/eclipse/sw360/spdx/SpdxBOMImporter.java +++ b/backend/common/src/main/java/org/eclipse/sw360/spdx/SpdxBOMImporter.java @@ -123,6 +123,144 @@ public ImportBomRequestPreparation prepareImportSpdxBOMAsRelease(File targetFile return requestPreparation; } + public SpdxImportDryRunResult dryRunImportSpdxBOMAsRelease(InputStream inputStream, AttachmentContent attachmentContent) + throws SW360Exception, IOException { + final SpdxImportDryRunResult result = new SpdxImportDryRunResult(); + List newComponents = new ArrayList<>(); + List existingComponents = new ArrayList<>(); + List licenseConflicts = new ArrayList<>(); + List warnings = new ArrayList<>(); + + String fileType = getFileType(attachmentContent.getFilename()); + if (!"rdf".equals(fileType) && !"spdx".equals(fileType)) { + result.setRequestStatus(RequestStatus.FAILURE); + result.setMessage("Invalid file type. Only .rdf and .spdx files are supported."); + return result; + } + final String ext = "." + fileType; + + final File sourceFile = DatabaseHandlerUtil.saveAsTempFile(inputStream, attachmentContent.getId(), ext); + try { + SpdxDocument spdxDocument = openAsSpdx(sourceFile); + if (spdxDocument == null) { + result.setRequestStatus(RequestStatus.FAILURE); + result.setMessage("Failed to parse SPDX file."); + return result; + } + + List describedPackages = spdxDocument.getDocumentDescribes().stream().collect(Collectors.toList()); + List packages = describedPackages.stream() + .filter(SpdxPackage.class::isInstance) + .collect(Collectors.toList()); + + if (packages.isEmpty()) { + result.setRequestStatus(RequestStatus.FAILURE); + result.setMessage("The provided BOM did not contain any top level packages."); + return result; + } else if (packages.size() > 1) { + result.setRequestStatus(RequestStatus.FAILURE); + result.setMessage("The provided BOM file contained multiple described top level packages. This is not allowed here."); + return result; + } + + final SpdxPackage spdxPackage = (SpdxPackage) packages.get(0); + final String componentName = getValue(spdxPackage.getName()); + final String version = getValue(spdxPackage.getVersionInfo()); + final String spdxId = spdxPackage.getId(); + + Set licenseConcluded = new HashSet<>(); + if (spdxPackage.getLicenseConcluded() != null) { + licenseConcluded.add(spdxPackage.getLicenseConcluded().toString()); + } + + Set licenseDeclared = new HashSet<>(); + String declaredLicense = createLicenseDeclaredFromSpdxLicenseDeclared(spdxPackage); + if (declaredLicense != null) { + licenseDeclared.add(declaredLicense); + } + + SpdxComponentInfo componentInfo = new SpdxComponentInfo(); + componentInfo.setName(componentName); + componentInfo.setVersion(version); + componentInfo.setComponentType(ComponentType.OSS.toString()); + componentInfo.setSpdxId(spdxId); + componentInfo.setLicenseConcluded(licenseConcluded); + componentInfo.setLicenseDeclared(licenseDeclared); + + Component existingComponent = sink.searchComponent(componentName); + if (existingComponent != null) { + existingComponents.add(componentInfo); + + Set existingLicenses = existingComponent.getMainLicenseIds(); + if (existingLicenses != null && !existingLicenses.isEmpty()) { + for (String existingLicense : existingLicenses) { + for (String proposedLicense : licenseConcluded) { + if (!existingLicense.equals(proposedLicense) && !"NOASSERTION".equals(proposedLicense)) { + LicenseConflictInfo conflict = new LicenseConflictInfo(); + conflict.setComponentName(componentName); + conflict.setExistingLicense(existingLicense); + conflict.setProposedLicense(proposedLicense); + conflict.setConflictType("LICENSE_MISMATCH"); + licenseConflicts.add(conflict); + } + } + } + } + } else { + newComponents.add(componentInfo); + } + + List allPackages = getPackages(spdxDocument); + for (SpdxPackage pkg : allPackages) { + String pkgName = getValue(pkg.getName()); + if (pkgName == null || pkgName.equals(componentName)) { + continue; + } + + SpdxComponentInfo pkgInfo = new SpdxComponentInfo(); + pkgInfo.setName(pkgName); + pkgInfo.setVersion(getValue(pkg.getVersionInfo())); + pkgInfo.setComponentType(ComponentType.OSS.toString()); + pkgInfo.setSpdxId(pkg.getId()); + + Set pkgLicenseConcluded = new HashSet<>(); + if (pkg.getLicenseConcluded() != null) { + pkgLicenseConcluded.add(pkg.getLicenseConcluded().toString()); + } + pkgInfo.setLicenseConcluded(pkgLicenseConcluded); + + String pkgDeclared = createLicenseDeclaredFromSpdxLicenseDeclared(pkg); + if (pkgDeclared != null) { + Set pkgLicenseDeclared = new HashSet<>(); + pkgLicenseDeclared.add(pkgDeclared); + pkgInfo.setLicenseDeclared(pkgLicenseDeclared); + } + + Component existingPkgComponent = sink.searchComponent(pkgName); + if (existingPkgComponent != null) { + existingComponents.add(pkgInfo); + } else { + newComponents.add(pkgInfo); + } + } + + result.setNewComponents(newComponents); + result.setExistingComponents(existingComponents); + result.setLicenseConflicts(licenseConflicts); + result.setWarnings(warnings); + result.setRequestStatus(RequestStatus.SUCCESS); + result.setMessage("Dry-run completed successfully. " + newComponents.size() + " new components, " + + existingComponents.size() + " existing components, " + licenseConflicts.size() + " license conflicts."); + + } catch (InvalidSPDXAnalysisException | NullPointerException e) { + log.error("Error during dry-run import: " + e); + result.setRequestStatus(RequestStatus.FAILURE); + result.setMessage("Error analyzing SPDX file: " + e.getMessage()); + } + + return result; + } + public RequestSummary importSpdxBOMAsRelease(InputStream inputStream, AttachmentContent attachmentContent, User user) throws SW360Exception, IOException { return importSpdxBOM(inputStream, attachmentContent, SW360Constants.TYPE_RELEASE, user); diff --git a/backend/components/src/main/java/org/eclipse/sw360/components/ComponentHandler.java b/backend/components/src/main/java/org/eclipse/sw360/components/ComponentHandler.java index 9b374bef60..cfc02e1b5d 100644 --- a/backend/components/src/main/java/org/eclipse/sw360/components/ComponentHandler.java +++ b/backend/components/src/main/java/org/eclipse/sw360/components/ComponentHandler.java @@ -722,6 +722,13 @@ public ImportBomRequestPreparation prepareImportBom(User user, String attachment return handler.prepareImportBom(user, attachmentContentId); } + @Override + public SpdxImportDryRunResult dryRunImportBom(User user, String attachmentContentId) throws TException { + assertNotNull(attachmentContentId); + assertUser(user); + return handler.dryRunImportBom(user, attachmentContentId); + } + @Override public RequestSummary importBomFromAttachmentContent(User user, String attachmentContentId) throws TException { assertNotNull(attachmentContentId); diff --git a/libraries/datahandler/src/main/thrift/components.thrift b/libraries/datahandler/src/main/thrift/components.thrift index 5c68a384c8..9392f2b6c5 100644 --- a/libraries/datahandler/src/main/thrift/components.thrift +++ b/libraries/datahandler/src/main/thrift/components.thrift @@ -30,6 +30,9 @@ typedef sw360.SW360Exception SW360Exception typedef sw360.PaginationData PaginationData typedef sw360.ClearingReportStatus ClearingReportStatus typedef sw360.ImportBomRequestPreparation ImportBomRequestPreparation +typedef sw360.SpdxImportDryRunResult SpdxImportDryRunResult +typedef sw360.SpdxComponentInfo SpdxComponentInfo +typedef sw360.LicenseConflictInfo LicenseConflictInfo typedef attachments.Attachment Attachment typedef attachments.FilledAttachment FilledAttachment typedef users.User User @@ -1038,6 +1041,12 @@ service ComponentService { ImportBomRequestPreparation prepareImportBom(1: User user, 2:string attachmentContentId); + /** + * Dry-run SPDX import - simulates import without persisting data + * Returns detailed impact analysis including new/existing components and license conflicts + **/ + SpdxImportDryRunResult dryRunImportBom(1: User user, 2:string attachmentContentId); + /** * parse a bom file and write the information to SW360 **/ diff --git a/libraries/datahandler/src/main/thrift/sw360.thrift b/libraries/datahandler/src/main/thrift/sw360.thrift index 16b33d099b..fd03cf554e 100644 --- a/libraries/datahandler/src/main/thrift/sw360.thrift +++ b/libraries/datahandler/src/main/thrift/sw360.thrift @@ -234,6 +234,31 @@ struct ImportBomRequestPreparation { 7: optional string message; } +struct SpdxImportDryRunResult { + 1: required RequestStatus requestStatus; + 2: optional list newComponents; + 3: optional list existingComponents; + 4: optional list licenseConflicts; + 5: optional list warnings; + 6: optional string message; +} + +struct SpdxComponentInfo { + 1: optional string name; + 2: optional string version; + 3: optional string componentType; + 4: optional string spdxId; + 5: optional set licenseConcluded; + 6: optional set licenseDeclared; +} + +struct LicenseConflictInfo { + 1: optional string componentName; + 2: optional string existingLicense; + 3: optional string proposedLicense; + 4: optional string conflictType; +} + struct CustomProperties { 1: optional string id, 2: optional string revision, diff --git a/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/component/ComponentController.java b/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/component/ComponentController.java index 73e70922c8..28e62b9186 100644 --- a/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/component/ComponentController.java +++ b/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/component/ComponentController.java @@ -34,6 +34,7 @@ import org.eclipse.sw360.datahandler.thrift.RequestStatus; import org.eclipse.sw360.datahandler.thrift.RequestSummary; import org.eclipse.sw360.datahandler.thrift.ImportBomRequestPreparation; +import org.eclipse.sw360.datahandler.thrift.SpdxImportDryRunResult; import org.eclipse.sw360.datahandler.thrift.RestrictedResource; import org.eclipse.sw360.datahandler.thrift.Source; import org.eclipse.sw360.datahandler.thrift.VerificationStateInfo; @@ -1068,6 +1069,58 @@ public ResponseEntity importSBOM( return new ResponseEntity<>(halResource, status); } + @Operation( + summary = "Dry-run SPDX import to analyze impact without persisting data.", + description = "Performs a dry-run import of an SPDX/SBOM file to analyze the impact without writing to the database.", + responses = { + @ApiResponse( + responseCode = "200", description = "Dry-run analysis completed.", + content = { + @Content(mediaType = "application/json", + schema = @Schema(implementation = SpdxImportDryRunResult.class)) + } + ), + @ApiResponse( + responseCode = "400", description = "Invalid SBOM file." + ), + @ApiResponse( + responseCode = "500", description = "Internal server error." + ) + }, + tags = {"Components"} + ) + @RequestMapping(value = COMPONENTS_URL + "/import/SBOM/dryRun", method = RequestMethod.POST, consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) + public ResponseEntity dryRunImportSBOM( + @Parameter(description = "Type of SBOM being uploaded.", + schema = @Schema(type = "string", allowableValues = {"SPDX"}) + ) + @RequestParam(value = "type", required = true) String type, + @Parameter(description = "The file to be uploaded.") + @RequestBody MultipartFile file + ) throws TException { + final User sw360User = restControllerHelper.getSw360UserFromAuthentication(); + Attachment attachment; + final SpdxImportDryRunResult dryRunResult; + if(!type.equalsIgnoreCase("SPDX") || !attachmentService.isValidSbomFile(file, type)) { + throw new IllegalArgumentException("SBOM file is not valid. It currently only supports SPDX(.rdf/.spdx) files."); + } + try { + attachment = attachmentService.uploadAttachment(file, new Attachment(), sw360User); + try { + dryRunResult = componentService.dryRunImportSBOM(sw360User, attachment.getAttachmentContentId()); + } catch (Exception e) { + throw new RuntimeException(e.getMessage()); + } + } catch (IOException e) { + log.error("failed to upload attachment", e); + throw new RuntimeException("failed to upload attachment", e); + } + if (!(dryRunResult.getRequestStatus() == RequestStatus.SUCCESS)) { + throw new BadRequestClientException("Invalid SBOM file: " + dryRunResult.getMessage()); + } + return new ResponseEntity<>(dryRunResult, HttpStatus.OK); + } + @Operation( summary = "Import SBOM in SPDX format.", description = "Import SBOM in SPDX format.", diff --git a/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/component/Sw360ComponentService.java b/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/component/Sw360ComponentService.java index 7be5103cbe..49e28f1176 100644 --- a/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/component/Sw360ComponentService.java +++ b/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/component/Sw360ComponentService.java @@ -295,6 +295,11 @@ public RequestSummary importSBOM(User user, String attachmentContentId) throws T return sw360ComponentClient.importBomFromAttachmentContent(user, attachmentContentId); } + public SpdxImportDryRunResult dryRunImportSBOM(User user, String attachmentContentId) throws TException { + ComponentService.Iface sw360ComponentClient = getThriftComponentClient(); + return sw360ComponentClient.dryRunImportBom(user, attachmentContentId); + } + public ImportBomRequestPreparation prepareImportSBOM(User user, String attachmentContentId) throws TException { ComponentService.Iface sw360ComponentClient = getThriftComponentClient(); return sw360ComponentClient.prepareImportBom(user, attachmentContentId); diff --git a/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/core/JacksonCustomizations.java b/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/core/JacksonCustomizations.java index 611dfce7b6..a36d65865a 100644 --- a/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/core/JacksonCustomizations.java +++ b/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/core/JacksonCustomizations.java @@ -168,6 +168,7 @@ public Sw360Module() { setMixInAnnotation(ModerationRequest.class, Sw360Module.ModerationRequestMixin.class); setMixInAnnotation(EmbeddedModerationRequest.class, Sw360Module.EmbeddedModerationRequestMixin.class); setMixInAnnotation(ImportBomRequestPreparation.class, Sw360Module.ImportBomRequestPreparationMixin.class); + setMixInAnnotation(SpdxImportDryRunResult.class, Sw360Module.SpdxImportDryRunResultMixin.class); setMixInAnnotation(ModerationPatch.class, Sw360Module.ModerationPatchMixin.class); setMixInAnnotation(ProjectDTO.class, Sw360Module.ProjectDTOMixin.class); setMixInAnnotation(EmbeddedProjectDTO.class, Sw360Module.EmbeddedProjectDTOMixin.class); @@ -2740,6 +2741,18 @@ static abstract class EmbeddedModerationRequestMixin extends ModerationRequestMi public static abstract class ImportBomRequestPreparationMixin extends ImportBomRequestPreparation { } + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonIgnoreProperties({ + "setRequestStatus", + "setNewComponents", + "setExistingComponents", + "setLicenseConflicts", + "setWarnings", + "setMessage" + }) + public static abstract class SpdxImportDryRunResultMixin extends SpdxImportDryRunResult { + } + @JsonInclude(JsonInclude.Include.NON_NULL) public static abstract class ModerationPatchMixin extends ModerationPatch { }