diff --git a/composer/lib/dependabot/composer/file_parser.rb b/composer/lib/dependabot/composer/file_parser.rb index 9d63b2061c..bcae1b85d1 100644 --- a/composer/lib/dependabot/composer/file_parser.rb +++ b/composer/lib/dependabot/composer/file_parser.rb @@ -11,6 +11,12 @@ module Dependabot module Composer + REQUIREMENT_SEPARATOR = / + (?<=\S|^) # Positive lookbehind for a non-whitespace character or start of string + (?:[ \t,]*\|\|?[ \t]*) # Match optional whitespace, a pipe (|| or |), and optional whitespace + (?=\S|$) # Positive lookahead for a non-whitespace character or end of string + /x + class FileParser < Dependabot::FileParsers::Base require "dependabot/file_parsers/base/dependency_set" @@ -40,7 +46,8 @@ def ecosystem @ecosystem ||= T.let( Ecosystem.new( name: ECOSYSTEM, - package_manager: package_manager + package_manager: package_manager, + language: language ), T.nilable(Ecosystem) ) @@ -50,7 +57,48 @@ def ecosystem sig { returns(Ecosystem::VersionManager) } def package_manager - PackageManager.new(composer_version) + raw_composer_version = env_versions[:composer] || composer_version + PackageManager.new( + raw_composer_version + ) + end + + sig { returns(T.nilable(Ecosystem::VersionManager)) } + def language + php_version = env_versions[:php] + + return unless php_version + + Language.new( + php_version, + requirement: php_requirement + ) + end + + sig { returns(T::Hash[Symbol, T.nilable(String)]) } + def env_versions + @env_versions ||= T.let( + Helpers.fetch_composer_and_php_versions, + T.nilable(T::Hash[Symbol, T.nilable(String)]) + ) + end + + # Capture PHP requirement from the composer.json + sig { returns(T.nilable(Requirement)) } + def php_requirement + requirement_string = Helpers.php_constraint(parsed_composer_json) + + return nil unless requirement_string + + requirements = requirement_string + .strip + .split(REQUIREMENT_SEPARATOR) + .map(&:strip) + .reject(&:empty?) + + return nil unless requirements.any? + + Requirement.new(requirements) end sig { returns(DependencySet) } @@ -162,7 +210,8 @@ def dependency_version(name:, type:) end sig do - params(name: String, type: String, requirement: String).returns(T.nilable(T::Hash[Symbol, T.nilable(String)])) + params(name: String, type: String, + requirement: String).returns(T.nilable(T::Hash[Symbol, T.nilable(String)])) end def dependency_source(name:, type:, requirement:) return unless lockfile @@ -243,7 +292,10 @@ def parsed_lockfile # rubocop:disable Metrics/PerceivedComplexity def parsed_composer_json content = composer_json&.content - raise Dependabot::DependencyFileNotParseable, composer_json&.path || "" if content.nil? || content.strip.empty? + if content.nil? || content.strip.empty? + raise Dependabot::DependencyFileNotParseable, + composer_json&.path || "" + end @parsed_composer_json ||= T.let(JSON.parse(content), T.nilable(T::Hash[String, T.untyped])) rescue JSON::ParserError @@ -268,7 +320,8 @@ def lockfile sig { returns(String) } def composer_version - @composer_version ||= T.let(Helpers.composer_version(parsed_composer_json, parsed_lockfile), T.nilable(String)) + @composer_version ||= T.let(Helpers.composer_version(parsed_composer_json, parsed_lockfile), + T.nilable(String)) end end end diff --git a/composer/lib/dependabot/composer/helpers.rb b/composer/lib/dependabot/composer/helpers.rb index d628634373..60391d2708 100644 --- a/composer/lib/dependabot/composer/helpers.rb +++ b/composer/lib/dependabot/composer/helpers.rb @@ -89,6 +89,55 @@ def self.extract_and_clean_dependency_url(message, regex) nil end + # Run single composer command returning stdout/stderr + sig { params(command: String, fingerprint: T.nilable(String)).returns(String) } + def self.package_manager_run_command(command, fingerprint: nil) + full_command = "composer #{command}" + + Dependabot.logger.info("Running composer command: #{full_command}") + + result = Dependabot::SharedHelpers.run_shell_command( + full_command, + fingerprint: "composer #{fingerprint || command}" + ).strip + + Dependabot.logger.info("Command executed successfully: #{full_command}") + result + rescue StandardError => e + Dependabot.logger.error("Error running composer command: #{full_command}, Error: #{e.message}") + raise + end + + # Example output: + # [dependabot] ~ $ composer --version + # Composer version 2.7.7 2024-06-10 22:11:12 + # PHP version 7.4.33 (/usr/bin/php7.4) + # Run the "diagnose" command to get more detailed diagnostics output. + # Get the version of the composer and php form the command output + # @return [Hash] with the composer and php version + # => { composer: "2.7.7", php: "7.4.33" } + sig { returns(T::Hash[Symbol, T.nilable(String)]) } + def self.fetch_composer_and_php_versions + output = package_manager_run_command("--version").strip + + composer_version = capture_version(output, /Composer version (?\d+\.\d+\.\d+)/) + php_version = capture_version(output, /PHP version (?\d+\.\d+\.\d+)/) + + Dependabot.logger.info("Dependabot running with Composer version: #{composer_version}") + Dependabot.logger.info("Dependabot running with PHP version: #{php_version}") + + { composer: composer_version, php: php_version } + rescue StandardError => e + Dependabot.logger.error("Error fetching versions for package manager and language #{name}: #{e.message}") + {} + end + + sig { params(output: String, regex: Regexp).returns(T.nilable(String)) } + def self.capture_version(output, regex) + match = output.match(regex) + match&.named_captures&.fetch("version", nil) + end + # Capture the platform PHP version from composer.json sig { params(parsed_composer_json: T::Hash[String, T.untyped]).returns(T.nilable(String)) } def self.capture_platform_php(parsed_composer_json) diff --git a/composer/lib/dependabot/composer/language.rb b/composer/lib/dependabot/composer/language.rb index 54f848ee9d..17696fe07a 100644 --- a/composer/lib/dependabot/composer/language.rb +++ b/composer/lib/dependabot/composer/language.rb @@ -3,6 +3,7 @@ require "sorbet-runtime" require "dependabot/ecosystem" +require "dependabot/composer/requirement" require "dependabot/composer/version" module Dependabot @@ -12,11 +13,14 @@ class Language < Dependabot::Ecosystem::VersionManager NAME = "php" - sig { params(raw_version: String).void } - def initialize(raw_version) + sig { params(raw_version: String, requirement: T.nilable(Requirement)).void } + def initialize(raw_version, requirement: nil) super( NAME, Version.new(raw_version), + [], + [], + requirement ) end diff --git a/composer/spec/dependabot/composer/file_parser_spec.rb b/composer/spec/dependabot/composer/file_parser_spec.rb index c05162e1af..d6c9ea34a1 100644 --- a/composer/spec/dependabot/composer/file_parser_spec.rb +++ b/composer/spec/dependabot/composer/file_parser_spec.rb @@ -410,9 +410,38 @@ end end - describe "#package_manager" do - it "returns the correct package manager" do + describe "#ecosystem" do + it "returns the correct ecosystem" do + expect(parser.ecosystem).to be_a(Dependabot::Ecosystem) + end + + it "returns package manager with version" do expect(parser.ecosystem.package_manager).to be_a(Dependabot::Composer::PackageManager) + expect(parser.ecosystem.package_manager.version).to eq("2.7.7") + end + + it "returns language with version" do + expect(parser.ecosystem.language).to be_a(Dependabot::Composer::Language) + expect(parser.ecosystem.language.version).to eq("7.4.33") + end + end + + describe "REQUIREMENT_SEPARATOR" do + let(:requirements) do + [ + "php >=7.4 || php >=8.0", + "php >=7.4, || php >=8.0", + "php >=7.4 |||| php >=8.0" + ] + end + + it "splits requirements correctly" do + results = requirements.map { |req| req.split(Dependabot::Composer::REQUIREMENT_SEPARATOR) } + expect(results).to eq([ + ["php >=7.4", "php >=8.0"], + ["php >=7.4", "php >=8.0"], + ["php >=7.4", "", "php >=8.0"] + ]) end end end diff --git a/composer/spec/dependabot/composer/helpers_spec.rb b/composer/spec/dependabot/composer/helpers_spec.rb index 5a9166268e..9f46f0c94c 100644 --- a/composer/spec/dependabot/composer/helpers_spec.rb +++ b/composer/spec/dependabot/composer/helpers_spec.rb @@ -2,6 +2,8 @@ # frozen_string_literal: true require "spec_helper" +require "dependabot/composer/package_manager" +require "dependabot/composer/language" require "dependabot/composer/helpers" RSpec.describe Dependabot::Composer::Helpers do @@ -57,4 +59,149 @@ expect(described_class.composer_version(composer_json)).to eq("2") end end + + describe ".package_manager_run_command" do + let(:command) { "--version" } + let(:fingerprint) { nil } + let(:output) { "Composer version 2.7.7\nPHP version 7.4.33" } + + before do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command).and_return(output) + end + + it "runs the given composer command and returns the output" do + expect(described_class.package_manager_run_command(command)).to eq(output.strip) + end + + it "logs the command execution and success" do + expect(Dependabot.logger).to receive(:info).with("Running composer command: composer --version") + expect(Dependabot.logger).to receive(:info).with("Command executed successfully: composer --version") + described_class.package_manager_run_command(command) + end + + it "logs and raises an error if the command fails" do + allow(Dependabot::SharedHelpers).to receive(:run_shell_command).and_raise(StandardError, "Command failed") + expect(Dependabot.logger).to receive(:error).with(/Error running composer command/) + expect { described_class.package_manager_run_command(command) }.to raise_error(StandardError, "Command failed") + end + end + + describe ".fetch_composer_and_php_versions" do + let(:output) do + "Composer version 2.7.7 2024-06-10 22:11:12\nPHP version 7.4.33 (/usr/bin/php7.4)" + end + + before do + allow(described_class).to receive(:package_manager_run_command).and_return(output) + end + + it "fetches and parses composer and PHP versions correctly" do + expect(described_class.fetch_composer_and_php_versions).to eq({ + composer: "2.7.7", + php: "7.4.33" + }) + end + + it "logs the composer and PHP versions" do + expect(Dependabot.logger).to receive(:info).with(/Dependabot running with Composer version/) + expect(Dependabot.logger).to receive(:info).with(/Dependabot running with PHP version/) + described_class.fetch_composer_and_php_versions + end + + it "logs and returns an empty hash on failure" do + allow(described_class).to receive(:package_manager_run_command).and_raise(StandardError, "Command failed") + expect(Dependabot.logger).to receive(:error).with(/Error fetching versions/) + expect(described_class.fetch_composer_and_php_versions).to eq({}) + end + end + + describe ".capture_version" do + let(:output) { "Composer version 2.7.7\nPHP version 7.4.33" } + + it "captures the version from the output using the provided regex" do + expect(described_class.capture_version(output, /Composer version (?\d+\.\d+\.\d+)/)).to eq("2.7.7") + expect(described_class.capture_version(output, /PHP version (?\d+\.\d+\.\d+)/)).to eq("7.4.33") + end + + it "returns nil if the regex does not match" do + expect(described_class.capture_version(output, /Unknown version (?\d+\.\d+\.\d+)/)).to be_nil + end + end + + describe ".capture_platform_php" do + let(:parsed_composer_json) do + { + "config" => { + "platform" => { + "php" => "7.4.33" + } + } + } + end + + it "captures the PHP version from the composer.json config" do + expect(described_class.capture_platform_php(parsed_composer_json)).to eq("7.4.33") + end + + it "returns nil if the platform key is not present" do + expect(described_class.capture_platform_php({})).to be_nil + end + end + + describe ".capture_platform" do + let(:parsed_composer_json) do + { + "config" => { + "platform" => { + "ext-json" => "1.5.0" + } + } + } + end + + it "captures the platform extension version from composer.json" do + expect(described_class.capture_platform(parsed_composer_json, "ext-json")).to eq("1.5.0") + end + + it "returns nil if the platform or extension name is not present" do + expect(described_class.capture_platform({}, "ext-json")).to be_nil + end + end + + describe ".php_constraint" do + let(:parsed_composer_json) do + { + "require" => { + "php" => ">=7.4 <8.0" + } + } + end + + it "captures the PHP version constraint from composer.json" do + expect(described_class.php_constraint(parsed_composer_json)).to eq(">=7.4 <8.0") + end + + it "returns nil if the PHP constraint is not specified" do + expect(described_class.php_constraint({})).to be_nil + end + end + + describe ".dependency_constraint" do + let(:parsed_composer_json) do + { + "require" => { + "ext-json" => ">=1.5.0", + "php" => ">=7.4 <8.0" + } + } + end + + it "captures the version constraint for the given dependency" do + expect(described_class.dependency_constraint(parsed_composer_json, "ext-json")).to eq(">=1.5.0") + end + + it "returns nil if the dependency is not specified" do + expect(described_class.dependency_constraint(parsed_composer_json, "ext-mbstring")).to be_nil + end + end end diff --git a/composer/spec/dependabot/composer/language_spec.rb b/composer/spec/dependabot/composer/language_spec.rb new file mode 100644 index 0000000000..c205c8b58b --- /dev/null +++ b/composer/spec/dependabot/composer/language_spec.rb @@ -0,0 +1,102 @@ +# typed: false +# frozen_string_literal: true + +require "dependabot/composer/language" +require "dependabot/ecosystem" +require "spec_helper" + +ComposerLanguage = Dependabot::Composer::Language + +RSpec.describe Dependabot::Composer::Language do + let(:language) { described_class.new(version, requirement: requirement) } + let(:version) { "7.4.33" } + let(:requirement) { nil } + + describe "#initialize" do + context "when version is a String" do + it "sets the version correctly" do + expect(language.version).to eq(Dependabot::Version.new(version)) + end + + it "sets the name correctly" do + expect(language.name).to eq(ComposerLanguage::NAME) + end + + it "sets the deprecated_versions correctly" do + expect(language.deprecated_versions).to eq([]) + end + + it "sets the supported_versions correctly" do + expect(language.supported_versions).to eq([]) + end + + it "sets the requirement correctly" do + expect(language.requirement).to be_nil + end + end + + context "when a requirement is provided" do + let(:requirement) { Dependabot::Composer::Requirement.new([">=7.4", "<8.0"]) } + + it "sets the requirement correctly" do + expect(language.requirement.requirements).to eq([ + [">=", Gem::Version.new("7.4")], + ["<", Gem::Version.new("8.0")] + ]) + end + end + end + + describe "#deprecated?" do + it "returns false for all versions" do + expect(language.deprecated?).to be false + end + end + + describe "#unsupported?" do + it "returns false for all versions" do + expect(language.unsupported?).to be false + end + end + + describe "#name" do + it "returns the correct name for the language" do + expect(language.name).to eq("php") + end + end + + describe "#version" do + context "when version is valid" do + it "returns the correct version" do + expect(language.version).to eq(Dependabot::Version.new("7.4.33")) + end + end + + context "when version is invalid" do + let(:version) { "invalid" } + + it "raises an error when parsed as a Dependabot::Version" do + expect { language.version }.to raise_error(ArgumentError) + end + end + end + + describe "#requirement" do + context "when no requirement is provided" do + it "returns nil" do + expect(language.requirement).to be_nil + end + end + + context "when a requirement is provided" do + let(:requirement) { Dependabot::Composer::Requirement.new([">=7.4", "<8.0"]) } + + it "returns the requirement object" do + expect(language.requirement.requirements).to eq([ + [">=", Gem::Version.new("7.4")], + ["<", Gem::Version.new("8.0")] + ]) + end + end + end +end