1#!/usr/bin/env python3
2#
3# Copyright (C) 2023 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#      http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""
18Serialize objects defined in package sbom_data to SPDX format: tagvalue, JSON.
19"""
20
21import json
22import sbom_data
23
24SPDX_VER = 'SPDX-2.3'
25DATA_LIC = 'CC0-1.0'
26
27
28class Tags:
29  # Common
30  SPDXID = 'SPDXID'
31  SPDX_VERSION = 'SPDXVersion'
32  DATA_LICENSE = 'DataLicense'
33  DOCUMENT_NAME = 'DocumentName'
34  DOCUMENT_NAMESPACE = 'DocumentNamespace'
35  CREATED = 'Created'
36  CREATOR = 'Creator'
37  EXTERNAL_DOCUMENT_REF = 'ExternalDocumentRef'
38
39  # Package
40  PACKAGE_NAME = 'PackageName'
41  PACKAGE_DOWNLOAD_LOCATION = 'PackageDownloadLocation'
42  PACKAGE_VERSION = 'PackageVersion'
43  PACKAGE_SUPPLIER = 'PackageSupplier'
44  FILES_ANALYZED = 'FilesAnalyzed'
45  PACKAGE_VERIFICATION_CODE = 'PackageVerificationCode'
46  PACKAGE_EXTERNAL_REF = 'ExternalRef'
47  # Package license
48  PACKAGE_LICENSE_CONCLUDED = 'PackageLicenseConcluded'
49  PACKAGE_LICENSE_INFO_FROM_FILES = 'PackageLicenseInfoFromFiles'
50  PACKAGE_LICENSE_DECLARED = 'PackageLicenseDeclared'
51  PACKAGE_LICENSE_COMMENTS = 'PackageLicenseComments'
52
53  # File
54  FILE_NAME = 'FileName'
55  FILE_CHECKSUM = 'FileChecksum'
56  # File license
57  FILE_LICENSE_CONCLUDED = 'LicenseConcluded'
58  FILE_LICENSE_INFO_IN_FILE = 'LicenseInfoInFile'
59  FILE_LICENSE_COMMENTS = 'LicenseComments'
60  FILE_COPYRIGHT_TEXT = 'FileCopyrightText'
61  FILE_NOTICE = 'FileNotice'
62  FILE_ATTRIBUTION_TEXT = 'FileAttributionText'
63
64  # Relationship
65  RELATIONSHIP = 'Relationship'
66
67
68class TagValueWriter:
69  @staticmethod
70  def marshal_doc_headers(sbom_doc):
71    headers = [
72      f'{Tags.SPDX_VERSION}: {SPDX_VER}',
73      f'{Tags.DATA_LICENSE}: {DATA_LIC}',
74      f'{Tags.SPDXID}: {sbom_doc.id}',
75      f'{Tags.DOCUMENT_NAME}: {sbom_doc.name}',
76      f'{Tags.DOCUMENT_NAMESPACE}: {sbom_doc.namespace}',
77    ]
78    for creator in sbom_doc.creators:
79      headers.append(f'{Tags.CREATOR}: {creator}')
80    headers.append(f'{Tags.CREATED}: {sbom_doc.created}')
81    for doc_ref in sbom_doc.external_refs:
82      headers.append(
83        f'{Tags.EXTERNAL_DOCUMENT_REF}: {doc_ref.id} {doc_ref.uri} {doc_ref.checksum}')
84    headers.append('')
85    return headers
86
87  @staticmethod
88  def marshal_package(sbom_doc, package, fragment):
89    download_location = sbom_data.VALUE_NOASSERTION
90    if package.download_location:
91      download_location = package.download_location
92    tagvalues = [
93      f'{Tags.PACKAGE_NAME}: {package.name}',
94      f'{Tags.SPDXID}: {package.id}',
95      f'{Tags.PACKAGE_DOWNLOAD_LOCATION}: {download_location}',
96      f'{Tags.FILES_ANALYZED}: {str(package.files_analyzed).lower()}',
97    ]
98    if package.version:
99      tagvalues.append(f'{Tags.PACKAGE_VERSION}: {package.version}')
100    if package.supplier:
101      tagvalues.append(f'{Tags.PACKAGE_SUPPLIER}: {package.supplier}')
102    if package.verification_code:
103      tagvalues.append(f'{Tags.PACKAGE_VERIFICATION_CODE}: {package.verification_code}')
104    if package.external_refs:
105      for external_ref in package.external_refs:
106        tagvalues.append(
107          f'{Tags.PACKAGE_EXTERNAL_REF}: {external_ref.category} {external_ref.type} {external_ref.locator}')
108
109    tagvalues.append('')
110
111    if package.id == sbom_doc.describes and not fragment:
112      tagvalues.append(
113          f'{Tags.RELATIONSHIP}: {sbom_doc.id} {sbom_data.RelationshipType.DESCRIBES} {sbom_doc.describes}')
114      tagvalues.append('')
115
116    for file in sbom_doc.files:
117      if file.id in package.file_ids:
118        tagvalues += TagValueWriter.marshal_file(file)
119
120    return tagvalues
121
122  @staticmethod
123  def marshal_packages(sbom_doc, fragment):
124    tagvalues = []
125    marshaled_relationships = []
126    i = 0
127    packages = sbom_doc.packages
128    while i < len(packages):
129      if (i + 1 < len(packages)
130          and packages[i].id.startswith('SPDXRef-SOURCE-')
131          and packages[i + 1].id.startswith('SPDXRef-UPSTREAM-')):
132        # Output SOURCE, UPSTREAM packages and their VARIANT_OF relationship together, so they are close to each other
133        # in SBOMs in tagvalue format.
134        tagvalues += TagValueWriter.marshal_package(sbom_doc, packages[i], fragment)
135        tagvalues += TagValueWriter.marshal_package(sbom_doc, packages[i + 1], fragment)
136        rel = next((r for r in sbom_doc.relationships if
137                    r.id1 == packages[i].id and
138                    r.id2 == packages[i + 1].id and
139                    r.relationship == sbom_data.RelationshipType.VARIANT_OF), None)
140        if rel:
141          marshaled_relationships.append(rel)
142          tagvalues.append(TagValueWriter.marshal_relationship(rel))
143          tagvalues.append('')
144
145        i += 2
146      else:
147        tagvalues += TagValueWriter.marshal_package(sbom_doc, packages[i], fragment)
148        i += 1
149
150    return tagvalues, marshaled_relationships
151
152  @staticmethod
153  def marshal_file(file):
154    tagvalues = [
155      f'{Tags.FILE_NAME}: {file.name}',
156      f'{Tags.SPDXID}: {file.id}',
157      f'{Tags.FILE_CHECKSUM}: {file.checksum}',
158      '',
159    ]
160
161    return tagvalues
162
163  @staticmethod
164  def marshal_files(sbom_doc, fragment):
165    tagvalues = []
166    files_in_packages = []
167    for package in sbom_doc.packages:
168      files_in_packages += package.file_ids
169    for file in sbom_doc.files:
170      if file.id in files_in_packages:
171        continue
172      tagvalues += TagValueWriter.marshal_file(file)
173      if file.id == sbom_doc.describes and not fragment:
174        # Fragment is not a full SBOM document so the relationship DESCRIBES is not applicable.
175        tagvalues.append(
176            f'{Tags.RELATIONSHIP}: {sbom_doc.id} {sbom_data.RelationshipType.DESCRIBES} {sbom_doc.describes}')
177        tagvalues.append('')
178    return tagvalues
179
180  @staticmethod
181  def marshal_relationship(rel):
182    return f'{Tags.RELATIONSHIP}: {rel.id1} {rel.relationship} {rel.id2}'
183
184  @staticmethod
185  def marshal_relationships(sbom_doc, marshaled_rels):
186    tagvalues = []
187    sorted_rels = sorted(sbom_doc.relationships, key=lambda r: r.id2 + r.id1)
188    for rel in sorted_rels:
189      if any(r.id1 == rel.id1 and r.id2 == rel.id2 and r.relationship == rel.relationship
190             for r in marshaled_rels):
191        continue
192      tagvalues.append(TagValueWriter.marshal_relationship(rel))
193    tagvalues.append('')
194    return tagvalues
195
196  @staticmethod
197  def write(sbom_doc, file, fragment=False):
198    content = []
199    if not fragment:
200      content += TagValueWriter.marshal_doc_headers(sbom_doc)
201    content += TagValueWriter.marshal_files(sbom_doc, fragment)
202    tagvalues, marshaled_relationships = TagValueWriter.marshal_packages(sbom_doc, fragment)
203    content += tagvalues
204    content += TagValueWriter.marshal_relationships(sbom_doc, marshaled_relationships)
205    file.write('\n'.join(content))
206
207
208class PropNames:
209  # Common
210  SPDXID = 'SPDXID'
211  SPDX_VERSION = 'spdxVersion'
212  DATA_LICENSE = 'dataLicense'
213  NAME = 'name'
214  DOCUMENT_NAMESPACE = 'documentNamespace'
215  CREATION_INFO = 'creationInfo'
216  CREATORS = 'creators'
217  CREATED = 'created'
218  EXTERNAL_DOCUMENT_REF = 'externalDocumentRefs'
219  DOCUMENT_DESCRIBES = 'documentDescribes'
220  EXTERNAL_DOCUMENT_ID = 'externalDocumentId'
221  EXTERNAL_DOCUMENT_URI = 'spdxDocument'
222  EXTERNAL_DOCUMENT_CHECKSUM = 'checksum'
223  ALGORITHM = 'algorithm'
224  CHECKSUM_VALUE = 'checksumValue'
225
226  # Package
227  PACKAGES = 'packages'
228  PACKAGE_DOWNLOAD_LOCATION = 'downloadLocation'
229  PACKAGE_VERSION = 'versionInfo'
230  PACKAGE_SUPPLIER = 'supplier'
231  FILES_ANALYZED = 'filesAnalyzed'
232  PACKAGE_VERIFICATION_CODE = 'packageVerificationCode'
233  PACKAGE_VERIFICATION_CODE_VALUE = 'packageVerificationCodeValue'
234  PACKAGE_EXTERNAL_REFS = 'externalRefs'
235  PACKAGE_EXTERNAL_REF_CATEGORY = 'referenceCategory'
236  PACKAGE_EXTERNAL_REF_TYPE = 'referenceType'
237  PACKAGE_EXTERNAL_REF_LOCATOR = 'referenceLocator'
238  PACKAGE_HAS_FILES = 'hasFiles'
239
240  # File
241  FILES = 'files'
242  FILE_NAME = 'fileName'
243  FILE_CHECKSUMS = 'checksums'
244
245  # Relationship
246  RELATIONSHIPS = 'relationships'
247  REL_ELEMENT_ID = 'spdxElementId'
248  REL_RELATED_ELEMENT_ID = 'relatedSpdxElement'
249  REL_TYPE = 'relationshipType'
250
251
252class JSONWriter:
253  @staticmethod
254  def marshal_doc_headers(sbom_doc):
255    headers = {
256      PropNames.SPDX_VERSION: SPDX_VER,
257      PropNames.DATA_LICENSE: DATA_LIC,
258      PropNames.SPDXID: sbom_doc.id,
259      PropNames.NAME: sbom_doc.name,
260      PropNames.DOCUMENT_NAMESPACE: sbom_doc.namespace,
261      PropNames.CREATION_INFO: {}
262    }
263    creators = [creator for creator in sbom_doc.creators]
264    headers[PropNames.CREATION_INFO][PropNames.CREATORS] = creators
265    headers[PropNames.CREATION_INFO][PropNames.CREATED] = sbom_doc.created
266    external_refs = []
267    for doc_ref in sbom_doc.external_refs:
268      checksum = doc_ref.checksum.split(': ')
269      external_refs.append({
270        PropNames.EXTERNAL_DOCUMENT_ID: f'{doc_ref.id}',
271        PropNames.EXTERNAL_DOCUMENT_URI: doc_ref.uri,
272        PropNames.EXTERNAL_DOCUMENT_CHECKSUM: {
273          PropNames.ALGORITHM: checksum[0],
274          PropNames.CHECKSUM_VALUE: checksum[1]
275        }
276      })
277    if external_refs:
278      headers[PropNames.EXTERNAL_DOCUMENT_REF] = external_refs
279    headers[PropNames.DOCUMENT_DESCRIBES] = [sbom_doc.describes]
280
281    return headers
282
283  @staticmethod
284  def marshal_packages(sbom_doc):
285    packages = []
286    for p in sbom_doc.packages:
287      package = {
288        PropNames.NAME: p.name,
289        PropNames.SPDXID: p.id,
290        PropNames.PACKAGE_DOWNLOAD_LOCATION: p.download_location if p.download_location else sbom_data.VALUE_NOASSERTION,
291        PropNames.FILES_ANALYZED: p.files_analyzed
292      }
293      if p.version:
294        package[PropNames.PACKAGE_VERSION] = p.version
295      if p.supplier:
296        package[PropNames.PACKAGE_SUPPLIER] = p.supplier
297      if p.verification_code:
298        package[PropNames.PACKAGE_VERIFICATION_CODE] = {
299          PropNames.PACKAGE_VERIFICATION_CODE_VALUE: p.verification_code
300        }
301      if p.external_refs:
302        package[PropNames.PACKAGE_EXTERNAL_REFS] = []
303        for ref in p.external_refs:
304          ext_ref = {
305            PropNames.PACKAGE_EXTERNAL_REF_CATEGORY: ref.category,
306            PropNames.PACKAGE_EXTERNAL_REF_TYPE: ref.type,
307            PropNames.PACKAGE_EXTERNAL_REF_LOCATOR: ref.locator,
308          }
309          package[PropNames.PACKAGE_EXTERNAL_REFS].append(ext_ref)
310      if p.file_ids:
311        package[PropNames.PACKAGE_HAS_FILES] = []
312        for file_id in p.file_ids:
313          package[PropNames.PACKAGE_HAS_FILES].append(file_id)
314
315      packages.append(package)
316
317    return {PropNames.PACKAGES: packages}
318
319  @staticmethod
320  def marshal_files(sbom_doc):
321    files = []
322    for f in sbom_doc.files:
323      file = {
324        PropNames.FILE_NAME: f.name,
325        PropNames.SPDXID: f.id
326      }
327      checksum = f.checksum.split(': ')
328      file[PropNames.FILE_CHECKSUMS] = [{
329        PropNames.ALGORITHM: checksum[0],
330        PropNames.CHECKSUM_VALUE: checksum[1],
331      }]
332      files.append(file)
333    return {PropNames.FILES: files}
334
335  @staticmethod
336  def marshal_relationships(sbom_doc):
337    relationships = []
338    sorted_rels = sorted(sbom_doc.relationships, key=lambda r: r.relationship + r.id2 + r.id1)
339    for r in sorted_rels:
340      rel = {
341        PropNames.REL_ELEMENT_ID: r.id1,
342        PropNames.REL_RELATED_ELEMENT_ID: r.id2,
343        PropNames.REL_TYPE: r.relationship,
344      }
345      relationships.append(rel)
346
347    return {PropNames.RELATIONSHIPS: relationships}
348
349  @staticmethod
350  def write(sbom_doc, file):
351    doc = {}
352    doc.update(JSONWriter.marshal_doc_headers(sbom_doc))
353    doc.update(JSONWriter.marshal_packages(sbom_doc))
354    doc.update(JSONWriter.marshal_files(sbom_doc))
355    doc.update(JSONWriter.marshal_relationships(sbom_doc))
356    file.write(json.dumps(doc, indent=4))
357