1#!/usr/bin/env python 2# -*- coding: utf-8 -*- 3 4# 5# Copyright (c) 2025 Northeastern University 6# Licensed under the Apache License, Version 2.0 (the "License"); 7# you may not use this file except in compliance with the License. 8# You may obtain a copy of the License at 9# 10# http://www.apache.org/licenses/LICENSE-2.0 11# 12# Unless required by applicable law or agreed to in writing, software 13# distributed under the License is distributed on an "AS IS" BASIS, 14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15# See the License for the specific language governing permissions and 16# limitations under the License. 17# 18 19from collections import OrderedDict 20from typing import Dict, Any 21 22from ohos.sbom.common.utils import remove_empty 23from ohos.sbom.converters.api import SBOMConverter 24from ohos.sbom.converters.base import SBOMFormat, ISBOMConverter 25from ohos.sbom.sbom.metadata.sbom_meta_data import Document, SBOMMetaData, Package, File, Hash, RelationshipType 26 27 28class SPDX23Converter(ISBOMConverter): 29 """ 30 Converter for SPDX 2.3 format (strictly follows input model and field mapping). 31 32 Implements conversion from OpenHarmony SBOM metadata to SPDX 2.3 JSON format. 33 """ 34 35 def convert(self, sbom_meta: SBOMMetaData) -> Dict[str, Any]: 36 """ 37 Convert intermediate SBOM data to SPDX 2.3 format. 38 39 Args: 40 sbom_meta: Intermediate SBOM data containing document, packages, 41 files and relationships 42 43 Returns: 44 OrderedDict: SPDX 2.3 compliant dictionary with empty values removed 45 """ 46 spdx = OrderedDict() 47 48 # Document information 49 spdx.update(self._convert_document(sbom_meta.document)) 50 51 # Package information 52 if sbom_meta.packages: 53 spdx["packages"] = [self._convert_package(pkg) for pkg in sbom_meta.packages] 54 55 # File information 56 if sbom_meta.files: 57 spdx["files"] = [self._convert_file(file) for file in sbom_meta.files] 58 59 # Relationship information 60 if sbom_meta.relationships: 61 spdx["relationships"] = [ 62 self._convert_single_relationship(rel.bom_ref, target, rel.relationship_type) 63 for rel in sbom_meta.relationships 64 for target in rel.depends_on 65 ] 66 67 return remove_empty(spdx) 68 69 def _convert_document(self, doc: Document) -> Dict[str, Any]: 70 """ 71 Convert document metadata to SPDX format. 72 73 Args: 74 doc: Document metadata object 75 76 Returns: 77 OrderedDict: SPDX document section 78 """ 79 return OrderedDict([ 80 ("SPDXID", doc.doc_id or "SPDXRef-DOCUMENT"), 81 ("spdxVersion", "2.3"), 82 ("creationInfo", self._build_creation_info(doc)), 83 ("name", doc.name or "Unnamed SBOM"), 84 ("dataLicense", doc.data_license), 85 ("documentNamespace", doc.document_namespace or self._generate_namespace(doc)), 86 ("comment", doc.doc_comments) 87 ]) 88 89 def _build_creation_info(self, doc: Document) -> Dict[str, Any]: 90 """ 91 Build SPDX creationInfo section. 92 93 Args: 94 doc: Document metadata object 95 96 Returns: 97 OrderedDict: creationInfo section with creators and timestamps 98 """ 99 creators = [] 100 if doc.tools: 101 creators.extend([ 102 f"Tool: {tool.get('name', '')}-{tool.get('version', '')}" 103 for tool in doc.tools 104 ]) 105 creators.append("Organization: OpenHarmony") 106 107 return OrderedDict([ 108 ("created", doc.timestamp), 109 ("creators", creators), 110 ("licenseListVersion", doc.license_list_version), 111 ("comment", getattr(doc, 'doc_comments', None)) 112 ]) 113 114 def _generate_namespace(self, doc: Document) -> str: 115 """ 116 Generate default document namespace URI. 117 118 Args: 119 doc: Document metadata object 120 121 Returns: 122 str: Generated namespace URI 123 """ 124 return f"http://spdx.org/spdxdocs/{doc.name}-{doc.serial_number}" 125 126 def _convert_package(self, pkg: Package) -> Dict[str, Any]: 127 """ 128 Convert package metadata to SPDX format. 129 130 Args: 131 pkg: Package metadata object 132 133 Returns: 134 OrderedDict: SPDX package entry with all required fields 135 136 Note: 137 Automatically handles package purpose validation and fallback 138 """ 139 # SPDX 2.3 valid package purposes 140 valid_purposes = { 141 "SOURCE", "BINARY", "ARCHIVE", "APPLICATION", "FRAMEWORK", 142 "LIBRARY", "MODULE", "OPERATING-SYSTEM", "DEVICE", "FIRMWARE", 143 "CONTAINER", "FILE", "INSTALL", "OTHER" 144 } 145 146 purpose = (pkg.type or "OTHER").upper() 147 if purpose not in valid_purposes: 148 purpose = "OTHER" 149 150 pkg_data = OrderedDict([ 151 ("SPDXID", pkg.purl or f"SPDXRef-Package-{pkg.name}"), 152 ("name", pkg.name), 153 ("versionInfo", pkg.version), 154 ("supplier", self._format_supplier(pkg.supplier)), 155 ("originator", self._format_supplier(pkg.group) if pkg.group else None), 156 ("downloadLocation", pkg.download_location or "NOASSERTION"), 157 ("filesAnalyzed", False), # SPDX recommendation for SBOMs 158 ("licenseConcluded", pkg.license_concluded), 159 ("licenseDeclared", pkg.license_declared), 160 ("copyrightText", pkg.com_copyright or "NOASSERTION"), 161 ("externalRefs", [self._build_purl_ref(pkg.purl)] if pkg.purl else []), 162 ("checksums", [self._convert_hash(h) for h in pkg.hashes]), 163 ("primaryPackagePurpose", purpose) 164 ]) 165 return remove_empty(pkg_data) 166 167 def _convert_file(self, file: File) -> Dict[str, Any]: 168 """ 169 Convert file metadata to SPDX format. 170 171 Args: 172 file: File metadata object 173 174 Returns: 175 OrderedDict: SPDX file entry with all required fields 176 """ 177 file_data = OrderedDict([ 178 ("SPDXID", file.file_id), 179 ("fileName", file.file_name), 180 ("fileTypes", [t.upper() for t in file.file_types]), 181 ("checksums", [self._convert_hash(h) for h in file.checksums]), 182 ("licenseConcluded", file.license_concluded), 183 ("copyrightText", file.copyright_text or "NOASSERTION"), 184 ]) 185 return remove_empty(file_data) 186 187 def _convert_single_relationship(self, subject_id: str, target_id: str, rel_type: RelationshipType) -> Dict[ 188 str, Any]: 189 """ 190 Convert relationship to SPDX format. 191 192 Args: 193 subject_id: Source component ID 194 target_id: Target component ID 195 rel_type: Relationship type enum 196 197 Returns: 198 OrderedDict: SPDX relationship entry 199 """ 200 return OrderedDict([ 201 ("spdxElementId", subject_id), 202 ("relatedSpdxElement", target_id), 203 ("relationshipType", rel_type.value) 204 ]) 205 206 def _format_supplier(self, supplier: str) -> str: 207 """ 208 Format supplier information according to SPDX spec. 209 210 Args: 211 supplier: Raw supplier string 212 213 Returns: 214 str: Formatted supplier string (with Person: prefix if contains @) 215 """ 216 if not supplier: 217 return "NOASSERTION" 218 return f"Person: {supplier}" if "@" in supplier else supplier 219 220 def _build_purl_ref(self, purl: str) -> Dict[str, str]: 221 """ 222 Build SPDX external reference for Package URL. 223 224 Args: 225 purl: Package URL string 226 227 Returns: 228 dict: SPDX external reference structure 229 """ 230 return { 231 "referenceType": "purl", 232 "referenceLocator": purl 233 } 234 235 def _convert_hash(self, hash_obj: Hash) -> Dict[str, str]: 236 """ 237 Convert hash object to SPDX format. 238 239 Args: 240 hash_obj: Hash object with algorithm and value 241 242 Returns: 243 dict: SPDX checksum structure 244 """ 245 return { 246 "algorithm": hash_obj.alg.upper(), 247 "checksumValue": hash_obj.content 248 } 249 250 251# Register the converter with the factory 252SBOMConverter.register_format(SBOMFormat.SPDX, SPDX23Converter) 253