Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: use purl as bomref #84

Merged
merged 17 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ OPTIONS
-s, --shortened-strings length Trim author, publisher, and purl to <length> characters; this may cause data loss but can improve compatibility with other systems

Component Metadata
If a podspec file is present the name, version, and type do not need to be specified as they will be set automatically.

-n, --name name (If specified version and type are also required) Name of the component for which the BOM is generated
-v, --version version Version of the component for which the BOM is generated
-t, --type type Type of the component for which the BOM is generated (one of application|framework|library|container|operating-system|device|firmware|file)
Expand Down
1 change: 1 addition & 0 deletions lib/cyclonedx/cocoapods/bom_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ def add_to_bom(xml)
end
end
end
xml.purl bomref
end
end
end
Expand Down
71 changes: 63 additions & 8 deletions lib/cyclonedx/cocoapods/cli_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
require_relative 'component'
require_relative 'manufacturer'
require_relative 'podfile_analyzer'
require_relative 'podspec_analyzer'

module CycloneDX
module CocoaPods
Expand Down Expand Up @@ -95,6 +96,8 @@ def parse_options
end

options.separator("\n Component Metadata\n")
options.separator(' If a podspec file is present the name, version, and type do not ' \
"need to be specified as they will be set automatically.\n")
options.on('-n', '--name name',
'(If specified version and type are also required) Name of the ' \
'component for which the BOM is generated') do |name|
Expand Down Expand Up @@ -161,12 +164,9 @@ def parse_options
end

def analyze(options)
analyzer = PodfileAnalyzer.new(logger: @logger, exclude_test_targets: options[:exclude_test_targets])
podfile, lockfile = analyzer.ensure_podfile_and_lock_are_present(options)
pods, dependencies = analyzer.parse_pods(podfile, lockfile)
analyzer.populate_pods_with_additional_info(pods)

component = component_from_options(options)
analyzer, dependencies, lockfile, podfile, pods = analyze_podfile(options)
podspec = analyze_podspec(options)
component = component_from_options(options, podspec)
manufacturer = manufacturer_from_options(options)

unless component.nil?
Expand All @@ -184,6 +184,21 @@ def analyze(options)
[component, manufacturer, pods, manifest_path, dependencies]
end

def analyze_podspec(options)
spec_analyzer = PodspecAnalyzer.new(logger: @logger)
podspec = spec_analyzer.ensure_podspec_is_present(options)
spec = spec_analyzer.parse_podspec(podspec) unless podspec.nil?
spec
end

def analyze_podfile(options)
analyzer = PodfileAnalyzer.new(logger: @logger, exclude_test_targets: options[:exclude_test_targets])
podfile, lockfile = analyzer.ensure_podfile_and_lock_are_present(options)
pods, dependencies = analyzer.parse_pods(podfile, lockfile)
analyzer.populate_pods_with_additional_info(pods)
[analyzer, dependencies, lockfile, podfile, pods]
end

def build_and_write_bom(options, component, manufacturer, pods, manifest_path, dependencies)
builder = BOMBuilder.new(pods: pods, manifest_path: manifest_path,
component: component, manufacturer: manufacturer, dependencies: dependencies)
Expand All @@ -192,8 +207,10 @@ def build_and_write_bom(options, component, manufacturer, pods, manifest_path, d
write_bom_to_file(bom: bom, options: options)
end

def component_from_options(options)
return unless options[:name]
def component_from_options(options, podspec)
return unless options[:name] || !podspec.nil?

ensure_options_match(options, podspec)

Component.new(group: options[:group], name: options[:name], version: options[:version],
type: options[:type], build_system: options[:build], vcs: options[:vcs])
Expand All @@ -209,6 +226,44 @@ def manufacturer_from_options(options)
)
end

def ensure_options_match(options, podspec)
validate_name_option(options, podspec)
validate_version_option(options, podspec)
validate_group_option(options, podspec)
validate_type_option(options, podspec)
end

def validate_name_option(options, podspec)
unless !podspec.nil? && options[:name] && options[:name] == podspec.name
raise OptionParser::InvalidArgument,
"Component name '#{options[:name]}' does not match podspec name '#{podspec.name}'"
end
options[:name] ||= podspec&.name
end

def validate_version_option(options, podspec)
unless !podspec.nil? && options[:version] && options[:version] == podspec.version.to_s
raise OptionParser::InvalidArgument,
"Component version '#{options[:version]}' does not match podspec version '#{podspec.version}'"
end
options[:version] ||= podspec&.version&.to_s
end

def validate_type_option(options, podspec)
raise OptionParser::InvalidArgument, "Component type must be 'library' when using a podspec" unless
!podspec.nil? && options[:type] && options[:type] == 'library'

return if podspec.nil?

options[:type] = 'cocoapods'
end

def validate_group_option(options, podspec)
return if podspec.nil?
raise OptionParser::InvalidArgument, 'Component group must not be specified when using a podspec' unless
options[:group].nil?
end

def setup_logger(verbose: true)
@logger ||= Logger.new($stdout)
@logger.level = verbose ? Logger::DEBUG : Logger::INFO
Expand Down
47 changes: 42 additions & 5 deletions lib/cyclonedx/cocoapods/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,30 @@ class Component
attr_reader :group, :name, :version, :type, :bomref, :build_system, :vcs

def initialize(name:, version:, type:, group: nil, build_system: nil, vcs: nil)
validate_attributes(name, version, type, group)
# cocoapods is a special case to correctly build a purl
package_type = type == 'cocoapods' ? 'cocoapods' : 'generic'
@type = type == 'cocoapods' ? 'library' : type

validate_attributes(name, version, @type, group)

@group = group
@name = name
@version = version
@type = type
@build_system = build_system
@vcs = vcs
@bomref = "#{name}@#{version}"
@bomref = build_purl(package_type, name, group, version)
end

return if group.nil?
private

@bomref = "#{group}/#{@bomref}"
def build_purl(package_type, name, group, version)
if group.nil?
purl_name, subpath = parse_name(name)
else
purl_name = "#{CGI.escape(group)}/#{CGI.escape(name)}"
subpath = ''
end
"pkg:#{package_type}/#{purl_name}@#{CGI.escape(version.to_s)}#{subpath}"
end

private
Expand All @@ -74,13 +85,39 @@ def validate_attributes(name, version, type, group)
raise ArgumentError, "#{type} is not valid component type (#{VALID_COMPONENT_TYPES.join('|')})"
end

def parse_name(name)
purls = name.split('/')
purl_name = CGI.escape(purls[0])
subpath = if purls.length > 1
"##{name.split('/').drop(1).map do |component|
CGI.escape(component)
end.join('/')}"
else
''
end
[purl_name, subpath]
end

def missing(str)
str.nil? || str.to_s.strip.empty?
end

def exists_and_blank(str)
!str.nil? && str.to_s.strip.empty?
end

def create_purl(name)
jeremylong marked this conversation as resolved.
Show resolved Hide resolved
purls = name.split('/')
purl_name = CGI.escape(purls[0])
subpath = if purls.length > 1
"##{name.split('/').drop(1).map do |component|
CGI.escape(component)
end.join('/')}"
else
''
end
[purl_name, subpath]
end
end
end
end
109 changes: 109 additions & 0 deletions lib/cyclonedx/cocoapods/podspec_analyzer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

#
# This file is part of CycloneDX CocoaPods
#
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.
#

require 'cocoapods'
require 'cocoapods-core'
require 'logger'

require_relative 'pod'
require_relative 'pod_attributes'
require_relative 'source'

module CycloneDX
module CocoaPods
class PodspecParsingError < StandardError; end

# Analyzes CocoaPods podspec files to extract component information for CycloneDX BOM generation
#
# The PodspecAnalyzer is responsible for:
# - Validating and loading podspec files from a given path
# - Parsing podspec contents to extract pod metadata
# - Converting podspec source information into standardized Source objects
#
# @example
# analyzer = PodspecAnalyzer.new(logger: Logger.new(STDOUT))
# podspec = analyzer.ensure_podspec_is_present(path: '/path/to/project')
# pod = analyzer.parse_podspec(podspec)
#
class PodspecAnalyzer
def initialize(logger:)
@logger = logger
end

def ensure_podspec_is_present(options)
project_dir = Pathname.new(options[:path] || Dir.pwd)
validate_options(project_dir, options)
initialize_cocoapods_config(project_dir)

options[:podspec_path].nil? ? nil : ::Pod::Specification.from_file(options[:podspec_path])
end

def parse_podspec(podspec)
return nil if podspec.nil?

@logger.debug "Parsing podspec from #{podspec.defined_in_file}"

Pod.new(
name: podspec.name,
version: podspec.version.to_s,
source: source_from_podspec(podspec),
checksum: nil
)
end

private

def validate_options(project_dir, options)
raise PodspecParsingError, "#{options[:path]} is not a valid directory." unless File.directory?(project_dir)

podspec_files = Dir.glob("#{project_dir}/*.podspec{.json,}")
options[:podspec_path] = podspec_files.first unless podspec_files.empty?
end

def initialize_cocoapods_config(project_dir)
::Pod::Config.instance = nil
::Pod::Config.instance.installation_root = project_dir
end

def source_from_podspec(podspec)
return unless podspec.source[:git]

Source::GitRepository.new(
url: podspec.source[:git],
type: determine_git_ref_type(podspec.source),
label: determine_git_ref_label(podspec.source)
)
end

def determine_git_ref_type(source)
return :tag if source[:tag]
return :commit if source[:commit]
return :branch if source[:branch]

nil
end

def determine_git_ref_label(source)
source[:tag] || source[:commit] || source[:branch]
end
end
end
end
Loading
Loading