licensed 2.8.0 → 2.11.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.
@@ -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
@@ -11,7 +11,9 @@ module Licensed
11
11
  # or nil if not in a git repository.
12
12
  def repository_root
13
13
  return unless available?
14
- Licensed::Shell.execute("git", "rev-parse", "--show-toplevel", allow_failure: true)
14
+ root = Licensed::Shell.execute("git", "rev-parse", "--show-toplevel", allow_failure: true)
15
+ return nil if root.empty?
16
+ root
15
17
  end
16
18
 
17
19
  # Returns true if a git repository is found, false otherwise
@@ -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,16 @@ 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
+ errored_reports = report.all_reports.select { |r| r.errors.any? }.to_a
32
32
  if errored_reports.any?
33
33
  shell.newline
34
34
  shell.error " * Errors:"
35
- errored_reports.each do |report|
36
- display_metadata = report.map { |k, v| "#{k}: #{v}" }.join(", ")
35
+ errored_reports.each do |r|
36
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
37
37
 
38
- shell.error " * #{report.name}"
38
+ shell.error " * #{r.name}"
39
39
  shell.error " #{display_metadata}" unless display_metadata.empty?
40
- report.errors.each do |error|
40
+ r.errors.each do |error|
41
41
  shell.error " - #{error}"
42
42
  end
43
43
  shell.newline
@@ -0,0 +1,68 @@
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
+ # Reports on a dependency in a notices command run.
37
+ #
38
+ # dependency - An application dependency
39
+ #
40
+ # Returns the result of the yielded method
41
+ # Note - must be called from inside the `report_run` scope
42
+ def report_dependency(dependency)
43
+ super do |report|
44
+ result = yield report
45
+ shell.warn "* #{report["warning"]}" if report["warning"]
46
+ result
47
+ end
48
+ end
49
+
50
+ # Returns notices information for a dependency report
51
+ def notices(report)
52
+ return unless report.target.is_a?(Licensed::Dependency)
53
+
54
+ cached_record = report["cached_record"]
55
+ return unless cached_record
56
+
57
+ texts = cached_record.licenses.map(&:text)
58
+ texts.concat(cached_record.notices)
59
+
60
+ <<~NOTICE
61
+ #{cached_record["name"]}@#{cached_record["version"]}
62
+
63
+ #{texts.map(&:strip).reject(&:empty?).compact.join(TEXT_SEPARATOR)}
64
+ NOTICE
65
+ end
66
+ end
67
+ end
68
+ 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,20 @@ 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
18
+ errored_reports = all_reports.select { |r| r.errors.any? }.to_a
19
19
 
20
- dependency_count = all_reports.select { |report| report.target.is_a?(Licensed::Dependency) }.size
21
- error_count = errored_reports.sum { |report| report.errors.size }
20
+ dependency_count = all_reports.select { |r| r.target.is_a?(Licensed::Dependency) }.size
21
+ error_count = errored_reports.sum { |r| r.errors.size }
22
22
 
23
23
  if error_count > 0
24
24
  shell.newline
25
25
  shell.error "Errors:"
26
- errored_reports.each do |report|
27
- display_metadata = report.map { |k, v| "#{k}: #{v}" }.join(", ")
26
+ errored_reports.each do |r|
27
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
28
28
 
29
- shell.error "* #{report.name}"
29
+ shell.error "* #{r.name}"
30
30
  shell.error " #{display_metadata}" unless display_metadata.empty?
31
- report.errors.each do |error|
31
+ r.errors.each do |error|
32
32
  shell.error " - #{error}"
33
33
  end
34
34
  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
 
@@ -15,7 +15,8 @@ module Licensed
15
15
  def enumerate_dependencies
16
16
  with_configured_gopath do
17
17
  packages.map do |package|
18
- import_path = non_vendored_import_path(package["ImportPath"])
18
+ import_path = non_vendored_path(package["ImportPath"], root_package["ImportPath"])
19
+ import_path ||= package["ImportPath"]
19
20
  error = package.dig("Error", "Err") if package["Error"]
20
21
 
21
22
  Dependency.new(
@@ -86,14 +87,17 @@ module Licensed
86
87
  # true if go standard packages includes the import path as given
87
88
  return true if go_std_packages.include?(import_path)
88
89
  return true if go_std_packages.include?("vendor/#{import_path}")
90
+ return true if go_std_packages.include?(import_path.sub("golang.org", "internal"))
89
91
 
90
92
  # additional checks are only for vendored dependencies - return false
91
93
  # if package isn't vendored
92
- return false unless vendored_path?(import_path)
94
+ non_vendored_import_path = non_vendored_path(import_path, root_package["ImportPath"])
95
+ return false unless non_vendored_import_path
93
96
 
94
97
  # return true if any of the go standard packages matches against
95
98
  # the non-vendored import path
96
- return true if go_std_packages.include?(non_vendored_import_path(import_path))
99
+ return true if go_std_packages.include?(non_vendored_import_path)
100
+ return true if go_std_packages.include?(non_vendored_import_path.sub("golang.org", "internal"))
97
101
 
98
102
  # modify the import path to look like the import path `go list` returns for vendored std packages
99
103
  vendor_path = import_path.sub("#{root_package["ImportPath"]}/", "")
@@ -104,7 +108,7 @@ module Licensed
104
108
  def local_package?(package)
105
109
  return false unless package && package["ImportPath"]
106
110
  import_path = package["ImportPath"]
107
- import_path.start_with?(root_package["ImportPath"]) && !vendored_path?(import_path)
111
+ import_path.start_with?(root_package["ImportPath"]) && !import_path.include?("vendor/")
108
112
  end
109
113
 
110
114
  # Returns the version for a given package
@@ -116,12 +120,12 @@ module Licensed
116
120
  return go_mod["Version"] if go_mod
117
121
 
118
122
  package_directory = package["Dir"]
119
- return unless package_directory
123
+ return unless package_directory && File.exist?(package_directory)
120
124
 
121
125
  # find most recent git SHA for a package, or nil if SHA is
122
126
  # not available
123
127
  Dir.chdir package_directory do
124
- contents_version *contents_version_arguments
128
+ contents_version(*contents_version_arguments)
125
129
  end
126
130
  end
127
131
 
@@ -150,34 +154,37 @@ module Licensed
150
154
  return if package.nil?
151
155
 
152
156
  # search root choices:
153
- # 1. module directory if using go modules
157
+ # 1. module directory if using go modules and directory is available
154
158
  # 2. vendor folder if package is vendored
155
159
  # 3. package root value if available
156
160
  # 4. GOPATH if the package directory is under the gopath
157
161
  # 5. nil
158
- return package.dig("Module", "Dir") if package["Module"]
159
- return package["Dir"].match("^(.*/vendor)/.*$")[1] if vendored_path?(package["Dir"])
162
+ module_dir = package.dig("Module", "Dir")
163
+ return module_dir if module_dir
164
+ return package["Dir"].match("^(.*/vendor)/.*$")[1] if vendored_path?(package["Dir"], config.root)
160
165
  return package["Root"] if package["Root"]
161
166
  return gopath if package["Dir"]&.start_with?(gopath)
162
167
  nil
163
168
  end
164
169
 
165
- # Returns whether a package is vendored or not based on the package
166
- # import_path
170
+ # Returns whether a package is vendored or not based on a base path and
171
+ # whether the path contains a vendor component
167
172
  #
168
173
  # path - Package path to test
169
- def vendored_path?(path)
170
- return false if path.nil?
171
- path.start_with?(root_package["ImportPath"]) && path.include?("vendor/")
174
+ # base - The base path that the input must start with
175
+ def vendored_path?(path, base)
176
+ return false if path.nil? || base.nil?
177
+ path.start_with?(base.to_s) && path.include?("vendor/")
172
178
  end
173
179
 
174
- # Returns the import path parameter without the vendor component
180
+ # Returns the path parameter without the vendor component if one is found
175
181
  #
176
- # import_path - Package import path with vendor component
177
- def non_vendored_import_path(import_path)
178
- return unless import_path
179
- return import_path unless vendored_path?(import_path)
180
- import_path.split("vendor/")[1]
182
+ # path - Package path with vendor component
183
+ # base - The base path that the input must start with
184
+ def non_vendored_path(path, base)
185
+ return unless path
186
+ return unless vendored_path?(path, base)
187
+ path.split("vendor/")[1]
181
188
  end
182
189
 
183
190
  # Returns a hash of information about the package with a given import path
@@ -93,8 +93,12 @@ module Licensed
93
93
  :[a-zA-Z0-9_]+ # after an Elixir atom,
94
94
  ,\s* # and skipping a comma and any number of spaces,
95
95
  "(?<version>.*?)" # capture the contents of a double-quoted string as the version,
96
- .* # and later
96
+ .*?\],\s* # and later
97
97
  "(?<repo>.*?)" # capture the contents of a double-quoted string as the repo
98
+ (?:
99
+ ,\s* # a comma
100
+ "[a-f0-9]{64}" # a digest
101
+ )?
98
102
  \},?\s*\Z # right before the final closing brace.
99
103
  /x,
100
104
 
@@ -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