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 dataclasses import dataclass, field 21from enum import Enum 22from typing import List, Dict, Optional, Any 23 24from ohos.sbom.common.utils import remove_empty 25 26# SPDX standard value indicating no license assertion 27NOASSERTION = "NOASSERTION" 28 29 30# --------------------------- 31# Core Value Objects 32# --------------------------- 33@dataclass(frozen=True) 34class Hash: 35 """ 36 Represents a cryptographic hash value for file/package verification. 37 38 Attributes: 39 alg: Hash algorithm (e.g. "SHA256", "MD5") 40 content: Hexadecimal hash value 41 """ 42 alg: str 43 content: str 44 45 def __json__(self) -> Dict[str, str]: 46 """Support JSON module serialization.""" 47 return self.to_dict() 48 49 @classmethod 50 def from_dict(cls, data: Dict[str, str]) -> 'Hash': 51 """Create Hash from dictionary.""" 52 return cls( 53 alg=data.get('alg', ''), 54 content=data.get('content', '') 55 ) 56 57 def to_dict(self) -> Dict[str, str]: 58 """Convert to dictionary representation.""" 59 return { 60 "alg": self.alg, 61 "content": self.content 62 } 63 64 65class RelationshipType(Enum): 66 """ 67 Enumeration of standard SBOM relationship types between components. 68 69 Values: 70 CONTAINS: The source contains the target 71 CONTAINED_BY: The source is contained by the target 72 DEPENDS_ON: The source depends on the target 73 DEPENDENCY_OF: The source is a dependency of the target 74 GENERATES: The source generates the target 75 GENERATED_FROM: The source was generated from the target 76 VARIANT_OF: The source is a variant of the target 77 COPY_OF: The source is a copy of the target 78 DYNAMIC_LINK: The source dynamically links to the target 79 STATIC_LINK: The source statically links to the target 80 OTHER: Other relationship type 81 """ 82 CONTAINS = "CONTAINS" 83 CONTAINED_BY = "CONTAINED_BY" 84 DEPENDS_ON = "DEPENDS_ON" 85 DEPENDENCY_OF = "DEPENDENCY_OF" 86 GENERATES = "GENERATES" 87 GENERATED_FROM = "GENERATED_FROM" 88 VARIANT_OF = "VARIANT_OF" 89 COPY_OF = "COPY_OF" 90 DYNAMIC_LINK = "DYNAMIC_LINK" 91 STATIC_LINK = "STATIC_LINK" 92 OTHER = "OTHER" 93 94 @classmethod 95 def from_str(cls, value: str) -> 'RelationshipType': 96 """Create from string value.""" 97 try: 98 return cls(value) 99 except ValueError: 100 return cls.OTHER 101 102 103# --------------------------- 104# Document Creation Information 105# --------------------------- 106@dataclass(frozen=True) 107class Document: 108 """ 109 Represents SBOM document metadata and creation information. 110 111 Required fields: 112 serial_number: Unique document identifier (typically UUID) 113 version: Document version 114 bom_format: BOM format specification 115 spec_version: Specification version 116 data_license: License for the document metadata 117 timestamp: Creation timestamp (ISO 8601 format) 118 authors: List of document authors 119 120 Optional fields: 121 doc_id: SPDX document identifier 122 name: Document name 123 document_namespace: Unique document namespace URI 124 license_list_version: SPDX license list version 125 lifecycles: List of lifecycle phases 126 properties: Custom key-value properties 127 tools: List of tools used to generate the SBOM 128 doc_comments: Free-form comments 129 """ 130 # Required fields 131 serial_number: str 132 version: str 133 bom_format: str 134 spec_version: str 135 data_license: str 136 timestamp: str 137 authors: List[str] 138 139 # SPDX-specific fields 140 doc_id: Optional[str] 141 name: Optional[str] 142 document_namespace: Optional[str] 143 license_list_version: Optional[str] 144 145 # Optional fields 146 lifecycles: List[str] = field(default_factory=list) 147 properties: Dict[str, str] = field(default_factory=dict) 148 tools: List[Dict[str, str]] = field(default_factory=list) 149 doc_comments: Optional[str] = None 150 151 @classmethod 152 def from_dict(cls, data: Dict[str, Any]) -> 'Document': 153 """Create Document from dictionary.""" 154 return cls( 155 serial_number=data.get('serialNumber', ''), 156 version=data.get('version', ''), 157 bom_format=data.get('bomFormat', ''), 158 spec_version=data.get('specVersion', ''), 159 data_license=data.get('dataLicense', ''), 160 timestamp=data.get('timestamp', ''), 161 authors=data.get('authors', []), 162 doc_id=data.get('docId'), 163 name=data.get('name'), 164 document_namespace=data.get('documentNamespace'), 165 license_list_version=data.get('licenseListVersion'), 166 lifecycles=data.get('lifecycles', []), 167 properties=data.get('properties', {}), 168 tools=data.get('tools', []), 169 doc_comments=data.get('docComments') 170 ) 171 172 def to_dict(self) -> Dict[str, Any]: 173 """ 174 Convert to ordered dictionary following standard field order. 175 176 Returns: 177 OrderedDict: Document data with empty values removed 178 """ 179 data = OrderedDict([ 180 ("serialNumber", self.serial_number), 181 ("docId", self.doc_id), 182 ("name", self.name), 183 ("documentNamespace", self.document_namespace), 184 ("version", self.version), 185 ("bomFormat", self.bom_format), 186 ("specVersion", self.spec_version), 187 ("dataLicense", self.data_license), 188 ("licenseListVersion", self.license_list_version), 189 ("timestamp", self.timestamp), 190 ("authors", self.authors), 191 ("lifecycles", self.lifecycles), 192 ("properties", self.properties), 193 ("tools", self.tools), 194 ("docComments", self.doc_comments) 195 ]) 196 return remove_empty(data) 197 198 199# --------------------------- 200# Package Information 201# --------------------------- 202@dataclass(frozen=True) 203class Package: 204 """ 205 Represents a software package in the SBOM. 206 207 Required fields: 208 type: Package type (e.g. "library", "application") 209 supplier: Package supplier/originator 210 group: Package group/namespace 211 name: Package name 212 version: Package version 213 purl: Package URL identifier 214 license_concluded: Concluded license 215 license_declared: Declared license 216 bom_ref: Unique package reference 217 comp_platform: Target platform 218 219 Optional fields: 220 com_copyright: Copyright notice 221 download_location: Package download URL 222 hashes: List of cryptographic hashes 223 """ 224 type: str 225 supplier: str 226 group: str 227 name: str 228 version: str 229 purl: str 230 license_concluded: str 231 license_declared: str 232 bom_ref: str 233 comp_platform: str 234 235 # Optional fields 236 com_copyright: Optional[str] = None 237 download_location: Optional[str] = None 238 hashes: List[Hash] = field(default_factory=list) 239 240 @classmethod 241 def from_dict(cls, data: Dict[str, Any]) -> 'Package': 242 """Create Package from dictionary.""" 243 return cls( 244 type=data.get('type', ''), 245 supplier=data.get('supplier', ''), 246 group=data.get('group', ''), 247 name=data.get('name', ''), 248 version=data.get('version', ''), 249 purl=data.get('purl', ''), 250 license_concluded=data.get('licenseConcluded', ''), 251 license_declared=data.get('licenseDeclared', ''), 252 bom_ref=data.get('bom-ref', ''), 253 comp_platform=data.get('compPlatform', ''), 254 com_copyright=data.get('comCopyright'), 255 download_location=data.get('downloadLocation'), 256 hashes=[Hash.from_dict(h) for h in data.get('hashes', [])] 257 ) 258 259 def to_dict(self) -> Dict[str, Any]: 260 """ 261 Convert to ordered dictionary following standard field order. 262 263 Returns: 264 OrderedDict: Package data with empty values removed 265 """ 266 data = OrderedDict([ 267 ("type", self.type), 268 ("supplier", self.supplier), 269 ("group", self.group), 270 ("name", self.name), 271 ("version", self.version), 272 ("purl", self.purl), 273 ("licenseConcluded", self.license_concluded), 274 ("licenseDeclared", self.license_declared), 275 ("comCopyright", self.com_copyright), 276 ("bom-ref", self.bom_ref), 277 ("downloadLocation", self.download_location), 278 ("hashes", [h.to_dict() for h in self.hashes]), 279 ("compPlatform", self.comp_platform) 280 ]) 281 return remove_empty(data) 282 283 284# --------------------------- 285# File Information 286# --------------------------- 287@dataclass(frozen=True) 288class File: 289 """ 290 Represents a file in the SBOM. 291 292 Required fields: 293 file_name: File name with path 294 file_id: Unique file identifier 295 checksums: List of cryptographic hashes 296 license_concluded: Concluded license 297 298 Optional fields: 299 file_types: List of file types 300 file_author: File author 301 license_info_in_files: Licenses found in file 302 copyright_text: Copyright notice 303 """ 304 file_name: str 305 file_id: str 306 checksums: List['Hash'] 307 license_concluded: str 308 file_types: List[str] = field(default_factory=list) 309 file_author: Optional[str] = None 310 license_info_in_files: List[str] = field(default_factory=list) 311 copyright_text: Optional[str] = None 312 313 @classmethod 314 def from_dict(cls, data: Dict[str, Any]) -> 'File': 315 """Create File from dictionary.""" 316 return cls( 317 file_name=data.get('fileName', ''), 318 file_id=data.get('fileId', ''), 319 checksums=[Hash.from_dict(h) for h in data.get('checksums', [])], 320 license_concluded=data.get('licenseConcluded', ''), 321 file_types=data.get('fileTypes', []), 322 file_author=data.get('fileAuthor'), 323 license_info_in_files=data.get('licenseInfoInFiles', []), 324 copyright_text=data.get('copyrightText') 325 ) 326 327 def to_dict(self) -> Dict[str, Any]: 328 """ 329 Convert to ordered dictionary following standard field order. 330 331 Returns: 332 OrderedDict: File data with empty values removed 333 """ 334 data = OrderedDict([ 335 ("fileName", self.file_name), 336 ("fileAuthor", self.file_author), 337 ("fileId", self.file_id), 338 ("fileTypes", self.file_types), 339 ("checksums", [h.to_dict() for h in self.checksums]), 340 ("licenseConcluded", self.license_concluded), 341 ("licenseInfoInFiles", self.license_info_in_files), 342 ("copyrightText", self.copyright_text) 343 ]) 344 return remove_empty(data) 345 346 347# --------------------------- 348# Relationship Information 349# --------------------------- 350@dataclass(frozen=True) 351class Relationship: 352 """ 353 Represents a relationship between SBOM components. 354 355 Attributes: 356 bom_ref: Source component reference 357 depends_on: List of target component references 358 relationship_type: Type of relationship 359 """ 360 bom_ref: str 361 depends_on: List[str] 362 relationship_type: RelationshipType 363 364 @classmethod 365 def from_dict(cls, data: Dict[str, Any]) -> 'Relationship': 366 """Create Relationship from dictionary.""" 367 return cls( 368 bom_ref=data.get('bom-ref', ''), 369 depends_on=data.get('dependsOn', []), 370 relationship_type=RelationshipType.from_str(data.get('relationshipType', 'OTHER')) 371 ) 372 373 def to_dict(self) -> Dict[str, Any]: 374 """ 375 Convert to ordered dictionary following standard field order. 376 377 Returns: 378 OrderedDict: Relationship data with empty values removed 379 """ 380 data = OrderedDict([ 381 ("bom-ref", self.bom_ref), 382 ("dependsOn", self.depends_on), 383 ("relationshipType", self.relationship_type.value) 384 ]) 385 return remove_empty(data) 386 387 388# --------------------------- 389# SBOM Metadata 390# --------------------------- 391@dataclass(frozen=True) 392class SBOMMetaData: 393 """ 394 Complete SBOM document containing all metadata components. 395 396 Attributes: 397 document: Document creation information 398 packages: List of software packages 399 files: List of files 400 relationships: List of component relationships 401 """ 402 document: Document 403 packages: List[Package] = field(default_factory=list) 404 files: List[File] = field(default_factory=list) 405 relationships: List[Relationship] = field(default_factory=list) 406 407 @classmethod 408 def from_dict(cls, data: Dict[str, Any]) -> 'SBOMMetaData': 409 """Create SBOMMetaData from dictionary.""" 410 return cls( 411 document=Document.from_dict(data.get('document', {})), 412 packages=[Package.from_dict(p) for p in data.get('packages', [])], 413 files=[File.from_dict(f) for f in data.get('files', [])], 414 relationships=[Relationship.from_dict(r) for r in data.get('relationships', [])] 415 ) 416 417 def to_dict(self) -> Dict[str, Any]: 418 """ 419 Convert complete SBOM to ordered dictionary. 420 421 Returns: 422 OrderedDict: SBOM data with empty values removed 423 """ 424 data = OrderedDict([ 425 ("document", self.document.to_dict()), 426 ("packages", [pkg.to_dict() for pkg in self.packages]), 427 ("files", [file.to_dict() for file in self.files]), 428 ("relationships", [rel.to_dict() for rel in self.relationships]) 429 ]) 430 return remove_empty(data) 431