Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
83 changes: 83 additions & 0 deletions lib/chef-cli/command/gem.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,28 @@

require_relative "base"
require_relative "../dist"
require_relative "../licensing/base"
require "rubygems" unless defined?(Gem)
require "rubygems/gem_runner"
require "rubygems/exceptions"
require "fileutils" unless defined?(FileUtils)
require "uri" unless defined?(URI)

module ChefCLI
module Command
# Forwards all commands to rubygems.
class GemForwarder < ChefCLI::Command::Base
banner "Usage: #{ChefCLI::Dist::EXEC} gem GEM_COMMANDS_AND_OPTIONS"

CHEF_GEM_SOURCE_HOST = "rubygems.chef.io".freeze
RUBYGEMS_ORG_HOST = "rubygems.org".freeze

# gem subcommands that fetch from remote sources and need the Chef source configured.
PREMIUM_SOURCE_COMMANDS = %w{install i search s fetch update download}.freeze

def run(params)
setup_gem_environment if habitat_gem_home_enabled?
ensure_chef_gem_source(params)
retval = Gem::GemRunner.new.run(params.clone)
retval.nil? || retval
rescue Gem::SystemExitException => e
Expand All @@ -45,6 +54,80 @@ def needs_version?(_params)

private

# Checks gem sources before any remote-fetching command and ensures the
# Chef Premium RubyGem server is configured:
# - Chef source present with valid v1 credentials → proceed.
# - Non-standard source present (incl. file://) → assume airgap, warn and skip.
# - Only rubygems.org (or unauthenticated chef source) → obtain license key and run `gem sources --add`.
def ensure_chef_gem_source(params)
return unless premium_source_command?(params)
return if chef_gem_source_configured?

custom = non_standard_sources
unless custom.empty?
err("WARN: A custom gem source (#{custom.join(", ")}) is already configured; assuming an air-gapped environment.")
err("WARN: The Chef Premium RubyGem source was not added. Premium extensions may be unavailable.")
return
end
Comment on lines +66 to +71

license_key = chef_license_key
if license_key.nil? || license_key.empty?
err("WARN: No valid Chef license key was found, so the Chef Premium RubyGem source was not configured.")
err("WARN: You will not be able to access premium extensions. Run `#{ChefCLI::Dist::EXEC} license add` to configure a license.")
return
end

add_chef_gem_source(license_key)
end

# Returns true when the first non-flag token is a remote-fetching subcommand.
def premium_source_command?(params)
command = params.find { |p| !p.to_s.start_with?("-") }
PREMIUM_SOURCE_COMMANDS.include?(command)
end

# Returns true only when rubygems.chef.io is configured with valid v1 credentials.
# A bare https://rubygems.chef.io entry (no user/password) is not considered configured.
def chef_gem_source_configured?
Gem.sources.any? do |source|
uri = URI.parse(source.to_s)
uri.host == CHEF_GEM_SOURCE_HOST && uri.user == "v1" && !uri.password.to_s.empty?
rescue URI::InvalidURIError
false
end
end

# Returns sources that are neither rubygems.org nor rubygems.chef.io.
# Sources whose URI has no host (e.g. file://) or cannot be parsed are
# treated as non-standard so the code errs on the side of not modifying sources.
def non_standard_sources
Gem.sources.reject do |source|
uri = URI.parse(source.to_s)
[RUBYGEMS_ORG_HOST, CHEF_GEM_SOURCE_HOST].include?(uri.host)
rescue URI::InvalidURIError
false
end
end

# Persists the Chef Premium RubyGem server via `gem sources --add` so the
# configuration survives the current process.
def add_chef_gem_source(license_key)
source_url = "https://v1:#{license_key}@#{CHEF_GEM_SOURCE_HOST}"
Gem::GemRunner.new.run(["sources", "--add", source_url])
rescue Gem::SystemExitException => e
err("WARN: Failed to add the Chef Premium RubyGem source (exit #{e.exit_code}).") unless e.exit_code == 0
end

# Fetches the first Chef license key from env, CLI args, or persisted storage.
# Does not prompt the terminal — if no key is found nil is returned and the
# caller warns the user to run `chef license add`.
def chef_license_key
keys = ChefLicensing.license_keys
keys.is_a?(Array) ? keys.first : nil
rescue StandardError
nil
end

# Detects whether the user gem home feature is enabled.
# This is set via CHEF_GEM_HOME_ENABLED in the Habitat plan's
# do_setup_environment/Invoke-SetupEnvironment, or falls back to
Expand Down
212 changes: 212 additions & 0 deletions spec/unit/command/gem_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@

before do
allow(Gem::GemRunner).to receive(:new).and_return(gem_runner)
# Avoid touching the licensing service / network in unrelated examples.
allow(command_instance).to receive(:ensure_chef_gem_source)
end

it "has a usage banner" do
Expand All @@ -49,6 +51,13 @@
allow(ENV).to receive(:[]).with("CHEF_GEM_HOME_ENABLED").and_return(nil)
end

# TC-11: source auto-configuration applies regardless of Habitat mode
it "calls ensure_chef_gem_source before forwarding to GemRunner" do
expect(command_instance).to receive(:ensure_chef_gem_source).with(%w{install knife}).ordered
expect(gem_runner).to receive(:run).with(%w{install knife}).and_return(true).ordered
command_instance.run(%w{install knife})
end

it "forwards params to Gem::GemRunner" do
expect(gem_runner).to receive(:run).with(%w(install knife)).and_return(true)
expect(command_instance.run(%w(install knife))).to eq(true)
Expand Down Expand Up @@ -172,4 +181,207 @@
)
end
end

describe "#ensure_chef_gem_source" do
let(:license_key) { "tmns-00000000-0000-0000-0000-000000000000-0000" }

before do
allow(command_instance).to receive(:ensure_chef_gem_source).and_call_original
end

def stub_sources(*urls)
allow(Gem).to receive(:sources).and_return(urls)
end

context "when the command does not download from remote sources" do
before { stub_sources("https://rubygems.org/") }

it "does nothing for non-install/search commands" do
expect(ChefLicensing).not_to receive(:license_keys)
expect(command_instance).not_to receive(:add_chef_gem_source)
command_instance.send(:ensure_chef_gem_source, %w{list})
end
end

context "when the Chef Premium RubyGem source is already configured with credentials" do
before do

Check warning on line 207 in spec/unit/command/gem_spec.rb

View workflow job for this annotation

GitHub Actions / call-ci-main-pr-check-pipeline / Trufflehog scan / Trufflehog

Found unverified URI result 🐷🔑
# Stub the method directly — URL construction with credentials is tested in #chef_gem_source_configured?
allow(command_instance).to receive(:chef_gem_source_configured?).and_return(true)
end

it "does not attempt to add it again" do
expect(ChefLicensing).not_to receive(:license_keys)
expect(command_instance).not_to receive(:add_chef_gem_source)
command_instance.send(:ensure_chef_gem_source, %w{install knife})
end
end

# Bug fix: unauthenticated rubygems.chef.io must not be treated as configured
context "when rubygems.chef.io is present but without credentials" do
before do
stub_sources("https://rubygems.org/", "https://rubygems.chef.io")
allow(ChefLicensing).to receive(:license_keys).and_return([license_key])
end

it "treats it as not configured and adds the authenticated source" do
expect(command_instance).to receive(:add_chef_gem_source).with(license_key)
command_instance.send(:ensure_chef_gem_source, %w{install knife})
end
end

context "when only the default RubyGems.org source is configured" do
before do
stub_sources("https://rubygems.org/")
allow(ChefLicensing).to receive(:license_keys).and_return([license_key])
end

it "adds the Chef source built from the license key" do
expect(command_instance).to receive(:add_chef_gem_source).with(license_key)
command_instance.send(:ensure_chef_gem_source, %w{install knife})
end

# TC-13: license_keys (not fetch_and_persist) is called so no interactive prompt blocks gem commands
it "uses license_keys to retrieve the license key without prompting" do
expect(ChefLicensing).to receive(:license_keys).and_return([license_key])
allow(command_instance).to receive(:add_chef_gem_source)
command_instance.send(:ensure_chef_gem_source, %w{install knife})
end

it "also triggers for the search command" do
expect(command_instance).to receive(:add_chef_gem_source).with(license_key)
command_instance.send(:ensure_chef_gem_source, %w{search knife})
end
end

# TC-06: only a custom source present with NO rubygems.org
context "when only a custom source is configured (no rubygems.org)" do
before { stub_sources("https://airgap.internal/gems") }

it "assumes air-gapped and does not add the Chef source" do
expect(ChefLicensing).not_to receive(:license_keys)
expect(command_instance).not_to receive(:add_chef_gem_source)
allow(command_instance).to receive(:err)
command_instance.send(:ensure_chef_gem_source, %w{install plugin})
end

it "warns about air-gapped environment" do
allow(command_instance).to receive(:err)
expect(command_instance).to receive(:err).with(/air-gapped/)
command_instance.send(:ensure_chef_gem_source, %w{install plugin})
end
end

# Bug fix: file:// sources have host=="" and must not be silently dropped from airgap detection
context "when a file:// local source is configured" do
before { stub_sources("file:///var/cache/gems") }

it "treats it as non-standard and does not add the Chef source" do
expect(ChefLicensing).not_to receive(:license_keys)
expect(command_instance).not_to receive(:add_chef_gem_source)
allow(command_instance).to receive(:err)
command_instance.send(:ensure_chef_gem_source, %w{install plugin})
end

it "warns about air-gapped environment" do
allow(command_instance).to receive(:err)
expect(command_instance).to receive(:err).with(/air-gapped/)
command_instance.send(:ensure_chef_gem_source, %w{install plugin})
end
end

# TC-07: rubygems.org AND a custom source present
context "when a custom non-chef, non-rubygems source is configured" do
before { stub_sources("https://rubygems.org/", "https://airgap.internal/gems") }

it "assumes an air-gapped mirror and does not add the Chef source" do
expect(ChefLicensing).not_to receive(:license_keys)
expect(command_instance).not_to receive(:add_chef_gem_source)
allow(command_instance).to receive(:err)
command_instance.send(:ensure_chef_gem_source, %w{install knife})
end

it "warns the user about the air-gapped assumption" do
allow(command_instance).to receive(:err)
expect(command_instance).to receive(:err).with(/air-gapped/)
command_instance.send(:ensure_chef_gem_source, %w{install knife})
end
end

# TC-05: no license key — warn and allow install to proceed (graceful degradation)
context "when no license key can be obtained" do
before do
stub_sources("https://rubygems.org/")
allow(ChefLicensing).to receive(:license_keys).and_return([])
end

it "does not add a source and warns about premium extensions" do
expect(command_instance).not_to receive(:add_chef_gem_source)
allow(command_instance).to receive(:err)
expect(command_instance).to receive(:err).with(/premium extensions/)
command_instance.send(:ensure_chef_gem_source, %w{install knife})
end

it "does not raise an error so the install proceeds against rubygems.org" do
allow(command_instance).to receive(:err)
expect { command_instance.send(:ensure_chef_gem_source, %w{install knife}) }.not_to raise_error
end
end

context "when fetching the license key raises an error" do
before do
stub_sources("https://rubygems.org/")
allow(ChefLicensing).to receive(:license_keys).and_raise(StandardError)
end

it "treats it as no license key and does not add a source" do
expect(command_instance).not_to receive(:add_chef_gem_source)
allow(command_instance).to receive(:err)
command_instance.send(:ensure_chef_gem_source, %w{install knife})
end
end
end

describe "#chef_gem_source_configured?" do
let(:license_key) { "tmns-00000000-0000-0000-0000-000000000000-0000" }

def stub_sources(*urls)
allow(Gem).to receive(:sources).and_return(urls)
end

it "returns true when the Chef source has v1 user and non-empty password" do
stub_sources("https://rubygems.org/", "https://v1:#{license_key}@rubygems.chef.io")
expect(command_instance.send(:chef_gem_source_configured?)).to be(true)
end

it "returns false when the Chef source has no credentials" do
stub_sources("https://rubygems.org/", "https://rubygems.chef.io")
expect(command_instance.send(:chef_gem_source_configured?)).to be(false)
end

it "returns false when no Chef source is present" do
stub_sources("https://rubygems.org/")
expect(command_instance.send(:chef_gem_source_configured?)).to be(false)
end
end

describe "#add_chef_gem_source" do
let(:license_key) { "tmns-abc-123" }
let(:expected_source_url) { "https://v1:#{license_key}@rubygems.chef.io" }
let(:source_runner) { instance_double(Gem::GemRunner) }

before do
allow(Gem::GemRunner).to receive(:new).and_return(source_runner)
end

it "invokes gem sources --add with the premium URL" do
expect(source_runner).to receive(:run).with(["sources", "--add", expected_source_url])
command_instance.send(:add_chef_gem_source, license_key)
end

it "does not propagate a Gem::SystemExitException so the user command still runs" do
allow(source_runner).to receive(:run).and_raise(Gem::SystemExitException.new(1))
allow(command_instance).to receive(:err)
expect { command_instance.send(:add_chef_gem_source, license_key) }.not_to raise_error
end
end
end
Loading