licensed 2.9.1 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,25 @@ module Licensed
15
15
 
16
16
  protected
17
17
 
18
+ # Run the command for all enumerated dependencies found in a dependency source,
19
+ # recording results in a report.
20
+ # Enumerating dependencies in the source is skipped if a :sources option
21
+ # is provided and the evaluated `source.class.type` is not in the :sources values
22
+ #
23
+ # app - The application configuration for the source
24
+ # source - A dependency source enumerator
25
+ #
26
+ # Returns whether the command succeeded for the dependency source enumerator
27
+ def run_source(app, source)
28
+ super do |report|
29
+ next if Array(options[:sources]).empty?
30
+ next if options[:sources].include?(source.class.type)
31
+
32
+ report.warnings << "skipped source"
33
+ :skip
34
+ end
35
+ end
36
+
18
37
  # Verifies that a cached record exists, is up to date and
19
38
  # has license data that complies with the licensed configuration.
20
39
  #
@@ -108,7 +108,12 @@ module Licensed
108
108
  def detect_cache_path(options, inherited_options)
109
109
  return options["cache_path"] unless options["cache_path"].to_s.empty?
110
110
 
111
- cache_path = inherited_options["cache_path"] || DEFAULT_CACHE_PATH
111
+ # if cache_path and shared_cache are both set in inherited_options,
112
+ # don't append the app name to the cache path
113
+ cache_path = inherited_options["cache_path"]
114
+ return cache_path if cache_path && inherited_options["shared_cache"] == true
115
+
116
+ cache_path ||= DEFAULT_CACHE_PATH
112
117
  File.join(cache_path, self["name"])
113
118
  end
114
119
 
@@ -167,7 +172,12 @@ module Licensed
167
172
  # will handle configurations that don't have these explicitly set
168
173
  dir_name = File.basename(path)
169
174
  config["name"] = "#{config["name"]}-#{dir_name}" if config["name"]
170
- config["cache_path"] = File.join(config["cache_path"], dir_name) if config["cache_path"]
175
+
176
+ # if a cache_path is set and is not marked as shared, append the app name
177
+ # to the end of the cache path to make a unique cache path for the app
178
+ if config["cache_path"] && config["shared_cache"] != true
179
+ config["cache_path"] = File.join(config["cache_path"], dir_name)
180
+ end
171
181
 
172
182
  config
173
183
  end
@@ -74,7 +74,7 @@ module Licensed
74
74
  def license_contents
75
75
  files = matched_files.reject { |f| f == package_file }
76
76
  .group_by(&:content)
77
- .map { |content, files| { "sources" => license_content_sources(files), "text" => content } }
77
+ .map { |content, sources| { "sources" => license_content_sources(sources), "text" => content } }
78
78
 
79
79
  files << generated_license_contents if files.empty?
80
80
  files.compact
@@ -7,5 +7,6 @@ module Licensed
7
7
  require "licensed/reporters/list_reporter"
8
8
  require "licensed/reporters/json_reporter"
9
9
  require "licensed/reporters/yaml_reporter"
10
+ require "licensed/reporters/notices_reporter"
10
11
  end
11
12
  end
@@ -27,32 +27,32 @@ module Licensed
27
27
  shell.info " #{source.class.type}"
28
28
  result = yield report
29
29
 
30
- warning_reports = report.all_reports.select { |report| report.warnings.any? }.to_a
30
+ warning_reports = report.all_reports.select { |r| r.warnings.any? }.to_a
31
31
  if warning_reports.any?
32
32
  shell.newline
33
33
  shell.warn " * Warnings:"
34
- warning_reports.each do |report|
35
- display_metadata = report.map { |k, v| "#{k}: #{v}" }.join(", ")
34
+ warning_reports.each do |r|
35
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
36
36
 
37
- shell.warn " * #{report.name}"
37
+ shell.warn " * #{r.name}"
38
38
  shell.warn " #{display_metadata}" unless display_metadata.empty?
39
- report.warnings.each do |warning|
39
+ r.warnings.each do |warning|
40
40
  shell.warn " - #{warning}"
41
41
  end
42
42
  shell.newline
43
43
  end
44
44
  end
45
45
 
46
- errored_reports = report.all_reports.select { |report| report.errors.any? }.to_a
46
+ errored_reports = report.all_reports.select { |r| r.errors.any? }.to_a
47
47
  if errored_reports.any?
48
48
  shell.newline
49
49
  shell.error " * Errors:"
50
- errored_reports.each do |report|
51
- display_metadata = report.map { |k, v| "#{k}: #{v}" }.join(", ")
50
+ errored_reports.each do |r|
51
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
52
52
 
53
- shell.error " * #{report.name}"
53
+ shell.error " * #{r.name}"
54
54
  shell.error " #{display_metadata}" unless display_metadata.empty?
55
- report.errors.each do |error|
55
+ r.errors.each do |error|
56
56
  shell.error " - #{error}"
57
57
  end
58
58
  shell.newline
@@ -28,16 +28,32 @@ module Licensed
28
28
  shell.info " #{source.class.type}"
29
29
  result = yield report
30
30
 
31
- errored_reports = report.all_reports.select { |report| report.errors.any? }.to_a
31
+ warning_reports = report.all_reports.select { |r| r.warnings.any? }.to_a
32
+ if warning_reports.any?
33
+ shell.newline
34
+ shell.warn " * Warnings:"
35
+ warning_reports.each do |r|
36
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
37
+
38
+ shell.warn " * #{r.name}"
39
+ shell.warn " #{display_metadata}" unless display_metadata.empty?
40
+ r.warnings.each do |warning|
41
+ shell.warn " - #{warning}"
42
+ end
43
+ shell.newline
44
+ end
45
+ end
46
+
47
+ errored_reports = report.all_reports.select { |r| r.errors.any? }.to_a
32
48
  if errored_reports.any?
33
49
  shell.newline
34
50
  shell.error " * Errors:"
35
- errored_reports.each do |report|
36
- display_metadata = report.map { |k, v| "#{k}: #{v}" }.join(", ")
51
+ errored_reports.each do |r|
52
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
37
53
 
38
- shell.error " * #{report.name}"
54
+ shell.error " * #{r.name}"
39
55
  shell.error " #{display_metadata}" unless display_metadata.empty?
40
- report.errors.each do |error|
56
+ r.errors.each do |error|
41
57
  shell.error " - #{error}"
42
58
  end
43
59
  shell.newline
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Licensed
4
+ module Reporters
5
+ class NoticesReporter < Reporter
6
+ TEXT_SEPARATOR = "\n\n#{("-" * 5)}\n\n".freeze
7
+ LICENSE_SEPARATOR = "\n#{("*" * 5)}\n".freeze
8
+
9
+ # Reports on an application configuration in a notices command run
10
+ #
11
+ # app - An application configuration
12
+ #
13
+ # Returns the result of the yielded method
14
+ # Note - must be called from inside the `report_run` scope
15
+ def report_app(app)
16
+ super do |report|
17
+ filename = app["shared_cache"] ? "NOTICE.#{app["name"]}" : "NOTICE"
18
+ path = app.cache_path.join(filename)
19
+ shell.info "Writing notices for #{app["name"]} to #{path}"
20
+
21
+ result = yield report
22
+
23
+ File.open(path, "w") do |file|
24
+ file << "THIRD PARTY NOTICES\n"
25
+ file << LICENSE_SEPARATOR
26
+ file << report.all_reports
27
+ .map { |r| notices(r) }
28
+ .compact
29
+ .join(LICENSE_SEPARATOR)
30
+ end
31
+
32
+ result
33
+ end
34
+ end
35
+
36
+
37
+ # Reports on a dependency source enumerator in a notices command run.
38
+ # Shows warnings encountered during the run.
39
+ #
40
+ # source - A dependency source enumerator
41
+ #
42
+ # Returns the result of the yielded method
43
+ # Note - must be called from inside the `report_run` scope
44
+ def report_source(source)
45
+ super do |report|
46
+ result = yield report
47
+
48
+ report.warnings.each do |warning|
49
+ shell.warn "* #{report.name}: #{warning}"
50
+ end
51
+
52
+ result
53
+ end
54
+ end
55
+
56
+ # Reports on a dependency in a notices command run.
57
+ #
58
+ # dependency - An application dependency
59
+ #
60
+ # Returns the result of the yielded method
61
+ # Note - must be called from inside the `report_run` scope
62
+ def report_dependency(dependency)
63
+ super do |report|
64
+ result = yield report
65
+ report.warnings.each do |warning|
66
+ shell.warn "* #{report.name}: #{warning}"
67
+ end
68
+ result
69
+ end
70
+ end
71
+
72
+ # Returns notices information for a dependency report
73
+ def notices(report)
74
+ return unless report.target.is_a?(Licensed::Dependency)
75
+
76
+ cached_record = report["cached_record"]
77
+ return unless cached_record
78
+
79
+ texts = cached_record.licenses.map(&:text)
80
+ cached_record.notices.each do |notice|
81
+ case notice
82
+ when Hash
83
+ texts << notice["text"]
84
+ when String
85
+ texts << notice
86
+ else
87
+ shell.warn "* unable to parse notices for #{report.target.name}"
88
+ end
89
+ end
90
+
91
+ <<~NOTICE
92
+ #{cached_record["name"]}@#{cached_record["version"]}
93
+
94
+ #{texts.map(&:strip).reject(&:empty?).compact.join(TEXT_SEPARATOR)}
95
+ NOTICE
96
+ end
97
+ end
98
+ end
99
+ end
@@ -47,6 +47,9 @@ module Licensed
47
47
 
48
48
  def initialize(shell = Licensed::UI::Shell.new)
49
49
  @shell = shell
50
+ @run_report = nil
51
+ @app_report = nil
52
+ @source_report = nil
50
53
  end
51
54
 
52
55
  # Generate a report for a licensed command execution
@@ -15,20 +15,37 @@ module Licensed
15
15
  result = yield report
16
16
 
17
17
  all_reports = report.all_reports
18
- errored_reports = all_reports.select { |report| report.errors.any? }.to_a
19
18
 
20
- dependency_count = all_reports.select { |report| report.target.is_a?(Licensed::Dependency) }.size
21
- error_count = errored_reports.sum { |report| report.errors.size }
19
+ warning_reports = all_reports.select { |r| r.warnings.any? }.to_a
20
+ if warning_reports.any?
21
+ shell.newline
22
+ shell.warn "Warnings:"
23
+ warning_reports.each do |r|
24
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
25
+
26
+ shell.warn "* #{r.name}"
27
+ shell.warn " #{display_metadata}" unless display_metadata.empty?
28
+ r.warnings.each do |warning|
29
+ shell.warn " - #{warning}"
30
+ end
31
+ shell.newline
32
+ end
33
+ end
34
+
35
+ errored_reports = all_reports.select { |r| r.errors.any? }.to_a
36
+
37
+ dependency_count = all_reports.select { |r| r.target.is_a?(Licensed::Dependency) }.size
38
+ error_count = errored_reports.sum { |r| r.errors.size }
22
39
 
23
40
  if error_count > 0
24
41
  shell.newline
25
42
  shell.error "Errors:"
26
- errored_reports.each do |report|
27
- display_metadata = report.map { |k, v| "#{k}: #{v}" }.join(", ")
43
+ errored_reports.each do |r|
44
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
28
45
 
29
- shell.error "* #{report.name}"
46
+ shell.error "* #{r.name}"
30
47
  shell.error " #{display_metadata}" unless display_metadata.empty?
31
- report.errors.each do |error|
48
+ r.errors.each do |error|
32
49
  shell.error " - #{error}"
33
50
  end
34
51
  shell.newline
@@ -11,6 +11,7 @@ module Licensed
11
11
  require "licensed/sources/go"
12
12
  require "licensed/sources/manifest"
13
13
  require "licensed/sources/npm"
14
+ require "licensed/sources/nuget"
14
15
  require "licensed/sources/pip"
15
16
  require "licensed/sources/pipenv"
16
17
  require "licensed/sources/gradle"
@@ -111,11 +111,11 @@ module Licensed
111
111
  info = package_info_command(id).strip
112
112
  return missing_package(id) if info.empty?
113
113
 
114
- info.lines.each_with_object({}) do |line, info|
114
+ info.lines.each_with_object({}) do |line, hsh|
115
115
  key, value = line.split(":", 2).map(&:strip)
116
116
  next unless key && value
117
117
 
118
- info[key] = value
118
+ hsh[key] = value
119
119
  end
120
120
  end
121
121
 
@@ -120,12 +120,12 @@ module Licensed
120
120
  return go_mod["Version"] if go_mod
121
121
 
122
122
  package_directory = package["Dir"]
123
- return unless package_directory
123
+ return unless package_directory && File.exist?(package_directory)
124
124
 
125
125
  # find most recent git SHA for a package, or nil if SHA is
126
126
  # not available
127
127
  Dir.chdir package_directory do
128
- contents_version *contents_version_arguments
128
+ contents_version(*contents_version_arguments)
129
129
  end
130
130
  end
131
131
 
@@ -48,6 +48,7 @@ module Licensed
48
48
  # package name to it's metadata
49
49
  def recursive_dependencies(dependencies, result = {})
50
50
  dependencies.each do |name, dependency|
51
+ next if dependency["peerMissing"]
51
52
  next if yarn_lock_present && dependency["missing"]
52
53
  (result[name] ||= []) << dependency
53
54
  recursive_dependencies(dependency["dependencies"] || {}, result)
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "reverse_markdown"
4
+
5
+ module Licensed
6
+ module Sources
7
+ # Only supports ProjectReference (project.assets.json) style restore used in .NET Core.
8
+ # Does not currently support packages.config style restore.
9
+ class NuGet < Source
10
+ def self.type
11
+ "nuget"
12
+ end
13
+
14
+ class NuGetDependency < Licensed::Dependency
15
+ LICENSE_FILE_REGEX = /<license\s*type\s*=\s*\"\s*file\s*\"\s*>\s*(.*)\s*<\/license>/ix.freeze
16
+ LICENSE_URL_REGEX = /<licenseUrl>\s*(.*)\s*<\/licenseUrl>/ix.freeze
17
+ PROJECT_URL_REGEX = /<projectUrl>\s*(.*)\s*<\/projectUrl>/ix.freeze
18
+ PROJECT_DESC_REGEX = /<description>\s*(.*)\s*<\/description>/ix.freeze
19
+
20
+ # Returns the metadata that represents this dependency. This metadata
21
+ # is written to YAML in the dependencys cached text file
22
+ def license_metadata
23
+ super.tap do |record_metadata|
24
+ record_metadata["homepage"] = project_url if project_url
25
+ record_metadata["summary"] = description if description
26
+ end
27
+ end
28
+
29
+ def nuspec_path
30
+ name = @metadata["name"]
31
+ File.join(self.path, "#{name.downcase}.nuspec")
32
+ end
33
+
34
+ def nuspec_contents
35
+ return @nuspec_contents if defined?(@nuspec_contents)
36
+ @nuspec_contents = begin
37
+ return unless nuspec_path && File.exist?(nuspec_path)
38
+ File.read(nuspec_path)
39
+ end
40
+ end
41
+
42
+ def project_url
43
+ return @project_url if defined?(@project_url)
44
+ @project_url = begin
45
+ return unless nuspec_contents
46
+ match = nuspec_contents.match PROJECT_URL_REGEX
47
+ match[1] if match && match[1]
48
+ end
49
+ end
50
+
51
+ def description
52
+ return @description if defined?(@description)
53
+ @description = begin
54
+ return unless nuspec_contents
55
+ match = nuspec_contents.match PROJECT_DESC_REGEX
56
+ match[1] if match && match[1]
57
+ end
58
+ end
59
+
60
+ def project_files
61
+ @nuget_project_files ||= begin
62
+ files = super().flatten.compact
63
+
64
+ # Only include the local file if it's a file licensee didn't already detect
65
+ nuspec_license_filename = File.basename(nuspec_local_license_file.filename) if nuspec_local_license_file
66
+ if nuspec_license_filename && files.none? { |file| File.basename(file.filename) == nuspec_license_filename }
67
+ files.push(nuspec_local_license_file)
68
+ end
69
+
70
+ # Only download licenseUrl if no recognized license was found locally
71
+ if files.none? { |file| file.license && file.license.key != "other" }
72
+ files.push(nuspec_remote_license_file)
73
+ end
74
+
75
+ files.compact
76
+ end
77
+ end
78
+
79
+ # Look for a <license type="file"> element in the nuspec that points to an
80
+ # on-disk license file (which licensee may not find due to a non-standard filename)
81
+ def nuspec_local_license_file
82
+ return @nuspec_local_license_file if defined?(@nuspec_local_license_file)
83
+ return unless nuspec_contents
84
+
85
+ match = nuspec_contents.match LICENSE_FILE_REGEX
86
+ return unless match && match[1]
87
+
88
+ license_path = File.join(File.dirname(nuspec_path), match[1])
89
+ return unless File.exist?(license_path)
90
+
91
+ license_data = File.read(license_path)
92
+ @nuspec_local_license_file = Licensee::ProjectFiles::LicenseFile.new(license_data, license_path)
93
+ end
94
+
95
+ # Look for a <licenseUrl> element in the nuspec that either is known to contain a license identifier
96
+ # in the URL, or points to license text on the internet that can be downloaded.
97
+ def nuspec_remote_license_file
98
+ return @nuspec_remote_license_file if defined?(@nuspec_remote_license_file)
99
+ return unless nuspec_contents
100
+
101
+ match = nuspec_contents.match LICENSE_URL_REGEX
102
+ return unless match && match[1]
103
+
104
+ # Attempt to fetch the license content
105
+ license_content = self.class.retrieve_license(match[1])
106
+ @nuspec_remote_license_file = Licensee::ProjectFiles::LicenseFile.new(license_content, { uri: match[1] }) if license_content
107
+ end
108
+
109
+ class << self
110
+ def strip_html(html)
111
+ return unless html
112
+
113
+ return html unless html.downcase.include?("<html")
114
+ ReverseMarkdown.convert(html, unknown_tags: :bypass)
115
+ end
116
+
117
+ def ignored_url?(url)
118
+ # Many Microsoft packages that now use <license> use this for <licenseUrl>
119
+ # No need to fetch this page - it just contains NuGet documentation
120
+ url == "https://aka.ms/deprecateLicenseUrl"
121
+ end
122
+
123
+ def text_content_url(url)
124
+ # Convert github file URLs to raw URLs
125
+ return url unless match = url.match(/https?:\/\/(?:www\.)?github.com\/([^\/]+)\/([^\/]+)\/blob\/(.*)/i)
126
+ "https://github.com/#{match[1]}/#{match[2]}/raw/#{match[3]}"
127
+ end
128
+
129
+ def retrieve_license(url)
130
+ return unless url
131
+ return if ignored_url?(url)
132
+
133
+ # Transform URLs that are known to return HTML but have a corresponding text-based URL
134
+ text_url = text_content_url(url)
135
+
136
+ raw_content = fetch_content(text_url)
137
+ strip_html(raw_content)
138
+ end
139
+
140
+ def fetch_content(url, redirect_limit = 5)
141
+ url = URI.parse(url) if url.instance_of? String
142
+ return @response_by_url[url] if (@response_by_url ||= {}).key?(url)
143
+ return if redirect_limit == 0
144
+
145
+ begin
146
+ response = Net::HTTP.get_response(url)
147
+ case response
148
+ when Net::HTTPSuccess then
149
+ @response_by_url[url] = response.body
150
+ when Net::HTTPRedirection then
151
+ redirect_url = URI.parse(response["location"])
152
+ if redirect_url.relative?
153
+ redirect_url = url + redirect_url
154
+ end
155
+ # The redirect might be to a URL that requires transformation, i.e. a github file
156
+ redirect_url = text_content_url(redirect_url.to_s)
157
+ @response_by_url[url] = fetch_content(redirect_url, redirect_limit - 1)
158
+ end
159
+ rescue
160
+ # Host might no longer exist or some other error, ignore
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ def project_assets_file_path
167
+ File.join(config.pwd, "project.assets.json")
168
+ end
169
+
170
+ def project_assets_file
171
+ return @project_assets_file if defined?(@project_assets_file)
172
+ @project_assets_file = File.read(project_assets_file_path)
173
+ end
174
+
175
+ def enabled?
176
+ File.exist?(project_assets_file_path)
177
+ end
178
+
179
+ # Inspect project.assets.json files for package references.
180
+ # Ideally we'd use `dotnet list package` instead, but its output isn't
181
+ # easily machine readable and doesn't contain everything we need.
182
+ def enumerate_dependencies
183
+ json = JSON.parse(project_assets_file)
184
+ nuget_packages_dir = json["project"]["restore"]["packagesPath"]
185
+ json["targets"].each_with_object({}) do |(_, target), dependencies|
186
+ target.each do |reference_key, reference|
187
+ # Ignore project references
188
+ next unless reference["type"] == "package"
189
+ package_id_parts = reference_key.partition("/")
190
+ name = package_id_parts[0]
191
+ version = package_id_parts[-1]
192
+ id = "#{name}-#{version}"
193
+
194
+ # Already know this package from another target
195
+ next if dependencies.key?(id)
196
+
197
+ path = File.join(nuget_packages_dir, json["libraries"][reference_key]["path"])
198
+ dependencies[id] = NuGetDependency.new(
199
+ name: id,
200
+ version: version,
201
+ path: path,
202
+ metadata: {
203
+ "type" => NuGet.type,
204
+ "name" => name
205
+ }
206
+ )
207
+ end
208
+ end.values
209
+ end
210
+ end
211
+ end
212
+ end