diff --git a/docs/configuration.md b/docs/configuration.md index 64423172..9204d64f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,6 +67,13 @@ reviewed: - classlist # public domain - octicons +# Specify additional license terms that have been obtained from a dependency's owner +# which apply to the dependency's license +additional_terms: + bundler: + bcrypt-ruby: + - .licenses/amendments/bundler/bcrypt-ruby/amendment.txt + # A single configuration file can be used to enumerate dependencies for multiple # projects. Each configuration is referred to as an "application" and must include # a source path, at a minimum diff --git a/docs/configuration/README.md b/docs/configuration/README.md index 9e5640be..2bc1cef8 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -9,3 +9,4 @@ 1. [Allowed licenses](./allowed_licenses.md) 1. [Ignoring dependencies](./ignoring_dependencies.md) 1. [Reviewing dependencies](./reviewing_dependencies.md) +1. [Additional license terms](./additional_terms.md) diff --git a/docs/configuration/additional_terms.md b/docs/configuration/additional_terms.md new file mode 100644 index 00000000..f531ad93 --- /dev/null +++ b/docs/configuration/additional_terms.md @@ -0,0 +1,41 @@ +# Additional terms + +The `additional_terms` configuration option is used to specify paths to files containing extra licensing terms that do not ship with the dependency package. All files specified are expected to be plain text. + +Files containing additional content can be located anywhere on disk that is accessible to licensed. File paths can be specified as a string or array and can contain glob values to simplify configuration inputs. All file paths are evaluated from the [configuration root](./configuration_root.md). + +## Examples + +**Note** The examples below specify paths to additional files under the `.licenses` folder. This is a logical place to store files containing license terms, but be careful not to store files under paths managed by licensed like `.licenses//...`. Running `licensed cache` in the future will delete any files under licensed managed paths that licensed did not create. This is why the below examples use paths like `.licenses/amendments/bundler/...` instead of not `.licenses/bundler/amendments/...`. + +### With a string + +```yaml +additional_terms: + # specify the type of dependency + bundler: + # specify the dependency name and path to an additional file + : .licenses/amendments/bundler//terms.txt +``` + +### With a glob string + +```yaml +additional_terms: + # specify the type of dependency + bundler: + # specify the dependency name and one or more additional files with a glob pattern + : .licenses/amendments/bundler//*.txt +``` + +### With an array of strings + +```yaml +additional_terms: + # specify the type of dependency + bundler: + # specify the dependency name and array of paths to additional files + : + - .licenses/amendments/bundler//terms-1.txt + - .licenses/amendments/bundler//terms-2.txt +``` diff --git a/lib/licensed/configuration.rb b/lib/licensed/configuration.rb index d10dbcfe..25b380ae 100644 --- a/lib/licensed/configuration.rb +++ b/lib/licensed/configuration.rb @@ -109,6 +109,12 @@ def allow(license) self["allowed"] << license end + # Returns an array of paths to files containing additional license terms. + def additional_terms_for_dependency(dependency) + amendment_paths = Array(self.dig("additional_terms", dependency["type"], dependency["name"])) + amendment_paths.flat_map { |path| Dir.glob(self.root.join(path)) } + end + private def any_list_pattern_matched?(list, dependency, match_version: false) diff --git a/lib/licensed/dependency.rb b/lib/licensed/dependency.rb index c3de247f..3854c549 100644 --- a/lib/licensed/dependency.rb +++ b/lib/licensed/dependency.rb @@ -9,6 +9,7 @@ class Dependency < Licensee::Projects::FSProject attr_reader :version attr_reader :errors attr_reader :path + attr_reader :additional_terms # Create a new project dependency # @@ -28,6 +29,7 @@ def initialize(name:, version:, path:, search_root: nil, metadata: {}, errors: [ @errors = errors path = path.to_s @path = path + @additional_terms = [] # enforcing absolute paths makes life much easier when determining # an absolute file path in #notices @@ -80,6 +82,13 @@ def license_contents files.compact end + + # Override the behavior of Licensee::Projects::FSProject#project_files to include + # additional license terms + def project_files + super + additional_license_terms_files + end + # Returns legal notices found at the dependency path def notice_contents Dir.glob(dir_path.join("*")) @@ -102,6 +111,7 @@ def read_file_with_encoding_check(file_path) def license_content_sources(files) paths = Array(files).map do |file| next file[:uri] if file[:uri] + next file[:source] if file[:source] path = dir_path.join(file[:dir], file[:name]) normalize_source_path(path) @@ -157,5 +167,22 @@ def generated_license_contents "text" => text } end + + # Returns an array of Licensee::ProjectFiles::LicenseFile created from + # this dependency's additional license terms + def additional_license_terms_files + @additional_license_terms_files ||= begin + files = additional_terms.map do |path| + next unless File.file?(path) + + metadata = { dir: File.dirname(path), name: File.basename(path) } + Licensee::ProjectFiles::LicenseFile.new( + load_file(metadata), + { source: "License terms loaded from #{metadata[:name]}" } + ) + end + files.compact + end + end end end diff --git a/lib/licensed/sources/source.rb b/lib/licensed/sources/source.rb index b51fa252..ed9c8e5e 100644 --- a/lib/licensed/sources/source.rb +++ b/lib/licensed/sources/source.rb @@ -69,7 +69,9 @@ def enabled? # Returns all dependencies that should be evaluated. # Excludes ignored dependencies. def dependencies - cached_dependencies.reject { |d| ignored?(d) } + cached_dependencies + .reject { |d| ignored?(d) } + .each { |d| add_additional_terms_from_configuration(d) } end # Enumerate all source dependencies. Must be implemented by each source class. @@ -88,6 +90,11 @@ def ignored?(dependency) def cached_dependencies @dependencies ||= enumerate_dependencies.compact end + + # Add any additional_terms for this dependency that have been added to the configuration + def add_additional_terms_from_configuration(dependency) + dependency.additional_terms.concat config.additional_terms_for_dependency("type" => self.class.type, "name" => dependency.name) + end end end end diff --git a/test/configuration_test.rb b/test/configuration_test.rb index ba976bb9..3c1cd930 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -604,4 +604,56 @@ config.cache_path end end + + describe "additional_terms_for_dependency" do + it "returns an empty array if additional terms aren't configured for a dependency" do + assert_empty config.additional_terms_for_dependency("type" => "test", "name" => "test") + end + + it "returns an array of absolute paths to amendment files for a string configuration" do + Dir.mktmpdir do |dir| + Dir.chdir dir do + options["additional_terms"] = { "test" => { "test" => "amendment.txt" } } + File.write "amendment.txt", "amendment" + + path = File.join(Dir.pwd, "amendment.txt") + assert_equal [path], config.additional_terms_for_dependency("type" => "test", "name" => "test") + end + end + end + + it "returns an array of absolute paths to amendment files for an array configuration" do + Dir.mktmpdir do |dir| + Dir.chdir dir do + options["additional_terms"] = { "test" => { "test" => ["amendment.txt"] } } + File.write "amendment.txt", "amendment" + + path = File.join(Dir.pwd, "amendment.txt") + assert_equal [path], config.additional_terms_for_dependency("type" => "test", "name" => "test") + end + end + end + + it "strips any amendment paths for files that don't exist" do + options["additional_terms"] = { "test" => { "test" => "amendment.txt" } } + assert_empty config.additional_terms_for_dependency("type" => "test", "name" => "test") + end + + it "expands glob patterns" do + Dir.mktmpdir do |dir| + Dir.chdir dir do + Dir.mkdir "amendments" + + options["additional_terms"] = { "test" => { "test" => "amendments/*" } } + paths = 2.times.map do |index| + path = "amendments/amendment-#{index}.txt" + File.write path, "amendment-#{index}" + File.join(Dir.pwd, path) + end + + assert_equal paths, config.additional_terms_for_dependency("type" => "test", "name" => "test") + end + end + end + end end diff --git a/test/dependency_test.rb b/test/dependency_test.rb index eb159318..a3beadd5 100644 --- a/test/dependency_test.rb +++ b/test/dependency_test.rb @@ -319,4 +319,23 @@ def mkproject(&block) refute dep.exist? end end + + describe "project_files" do + it "returns found only project files when the dependency does not contain additional terms" do + mkproject do |dependency| + File.write "LICENSE", Licensee::License.find("mit").text + assert_includes dependency.license_contents, + { "sources" => "LICENSE", "text" => Licensee::License.find("mit").text } + end + end + + it "returns custom license amendment files when the dependency contains additional terms" do + mkproject do |dependency| + File.write "amendment.txt", "license amendment" + dependency.additional_terms << "amendment.txt" + assert_includes dependency.license_contents, + { "sources" => "License terms loaded from amendment.txt", "text" => "license amendment" } + end + end + end end diff --git a/test/sources/source_test.rb b/test/sources/source_test.rb index b75dd485..e8c105e6 100644 --- a/test/sources/source_test.rb +++ b/test/sources/source_test.rb @@ -18,5 +18,20 @@ config.ignore("type" => "test", "name" => "dependency") assert_empty source.dependencies end + + it "adds the dependency's configured additional terms to dependencies" do + Dir.mktmpdir do |dir| + Dir.chdir dir do + config["additional_terms"] = { + TestSource.type => { + TestSource::DEFAULT_DEPENDENCY_NAME => "amendment.txt" + } + } + File.write "amendment.txt", "amendment" + dep = source.dependencies.first + assert_equal [File.join(Dir.pwd, "amendment.txt")], dep.additional_terms + end + end + end end end