Files
brew/Library/Homebrew/trust.rb
T
Mike McQuaid 271fe96061 Limit implicit item trust to install commands
- Match implicit trust to the documented installation behaviour.
  Other commands require existing trust for the tap or item.
- Retain the dispatched name in `Homebrew.running_command` and limit
  `ARGV` allowances to `install`, `reinstall` and `upgrade`, which
  announce and persist item trust.
- Preserve early formula loading for command-specific options before
  `trust_fully_qualified_items!` runs.
- Cover formula and cask loads, both `--tap` forms and existing alias,
  rename and external command behaviour.
2026-10-24 18:58:00 +01:00

654 lines
23 KiB
Ruby

# typed: strict
# frozen_string_literal: true
require "env_config"
require "etc"
require "json"
require "tap"
require "utils"
require "utils/output"
module Homebrew
class InsecureTrustStoreError < RuntimeError; end
class UntrustedTapError < RuntimeError; end
module Trust
extend Utils::Output::Mixin
SETTING_KEYS = T.let({
tap: :trustedtaps,
formula: :trustedformulae,
cask: :trustedcasks,
command: :trustedcommands,
}.freeze, T::Hash[Symbol, Symbol])
private_constant :SETTING_KEYS
sig { params(home: T.nilable(T.any(String, Pathname))).returns(Pathname) }
def self.trust_file(home: nil)
user = ENV.fetch("USER")
user_home = Pathname.new begin
Dir.home(user)
rescue ArgumentError
fallback_home = Dir.home
opoo "Could not determine home directory for `$USER` (#{user}); falling back to #{fallback_home}."
fallback_home
end
home ||= user_home
user_config_home = Pathname.new(ENV.fetch("HOMEBREW_USER_CONFIG_HOME"))
if user_config_home == user_home/".homebrew"
Pathname.new(home.to_s)/".homebrew/trust.json"
else
user_config_home/"trust.json"
end
end
sig { params(type: Symbol, name: T.any(String, Tap)).returns(T::Boolean) }
def self.trust!(type, name)
if name.is_a?(Tap)
raise ArgumentError, "a #{type} trust name must be a String, not a Tap" if type != :tap
name = name.reference
end
key = setting_key(type)
name = normalise_name(name)
with_trust_store_lock do
store = trust_store
entries = store.fetch(key, [])
next false if entries.include?(name)
store[key] = (entries + [name]).sort
write_trust_store(store)
true
end
end
sig { params(type: Symbol, name: String).returns(T::Boolean) }
def self.untrust!(type, name)
key = setting_key(type)
name = normalise_name(name)
entries_to_delete = T.let([name], T::Array[String])
if type != :tap && ::Utils.full_name?(name) && (tap_name = ::Utils.tap_from_full_name(name))
tap = Tap.fetch(tap_name)
entries_to_delete << item_trust_name(type, tap, ::Utils.name_from_full_name(name)) if tap.uses_custom_remote?
end
with_trust_store_lock do
store = trust_store
entries = store.fetch(key, [])
removed = T.let(false, T::Boolean)
entries_to_delete.uniq.each { |entry| removed = true if entries.delete(entry) }
next false unless removed
if entries.empty?
store.delete(key)
else
store[key] = entries.sort
end
write_trust_store(store)
true
end
end
sig { params(name: String, remote: T.nilable(String)).returns(T::Boolean) }
def self.invalidate_tap_references!(name, remote: nil)
name = normalise_name(name)
references = [name]
references << normalise_name(remote) if remote.present?
if remote.present? && (remote_reference = Tap.remote_to_reference(remote))
references << normalise_name(remote_reference)
end
references.uniq!
with_trust_store_lock do
store = trust_store
changed = T.let(false, T::Boolean)
store.keys.each do |key|
entries = store.fetch(key)
filtered_entries = entries.reject do |entry|
references.include?(entry) || entry.start_with?("#{name}/")
end
next if filtered_entries == entries
changed = true
if filtered_entries.empty?
store.delete(key)
else
store[key] = filtered_entries.sort
end
end
write_trust_store(store) if changed
changed
end
end
sig { params(names: T::Array[String], type: T.nilable(Symbol)).void }
def self.trust_fully_qualified_items!(names, type: nil)
names.each do |name|
next unless ::Utils.full_name?(name)
tap_name = name.split("/").first(2).join("/")
item_name = ::Utils.name_from_full_name(name)
tap = Tap.fetch(tap_name)
next if tap.official?
formula_name = (type == :cask) ? item_name : tap_formula_name(tap, item_name)
types = if type == :formula
tap.formula_files_by_name.key?(formula_name) ? [:formula] : []
elsif type == :cask
tap.cask_files_by_name.key?(item_name) ? [:cask] : []
elsif tap.formula_files_by_name.key?(formula_name)
[:formula]
elsif tap.cask_files_by_name.key?(item_name)
[:cask]
else
[]
end
types.each do |item_type|
if item_type == :formula && formula_name != item_name.downcase &&
trusted?(:formula, "#{tap.name}/#{formula_name}")
next
end
full_name = "#{tap.name}/#{item_name}"
if trust!(item_type, trust_name(item_type, name))
$stderr.ohai "Trusted #{item_type} #{full_name}"
end
end
rescue Tap::InvalidNameError
nil
end
end
sig { params(type: Symbol).void }
def self.clear!(type)
with_trust_store_lock do
store = trust_store
store.delete(setting_key(type))
write_trust_store(store)
end
end
sig { params(entries: T::Array[[Symbol, String]]).void }
def self.replace!(entries)
store = T.let({}, T::Hash[String, T::Array[String]])
entries.each do |type, name|
key = setting_key(type)
store[key] ||= []
store.fetch(key) << normalise_name(name)
end
store.keys.each do |key|
store[key] = store.fetch(key).uniq.sort
end
with_trust_store_lock do
write_trust_store(store)
end
end
sig { params(type: Symbol, name: String).returns(T::Boolean) }
def self.trusted?(type, name)
name = normalise_name(name)
entries = trusted_entries(type)
return true if entries.include?(name)
if type == :tap
return false if Tap.remote_reference?(name)
return explicitly_trusted_tap?(Tap.fetch(name))
end
return false unless (tap_name = ::Utils.tap_from_full_name(name))
tap = Tap.fetch(tap_name)
return true if trusted_tap?(tap)
item_name = normalise_name(::Utils.name_from_full_name(name))
return true if entries.include?(item_trust_name(type, tap, item_name))
return false unless tap.uses_custom_remote?
entries.any? do |entry|
next false unless entry.end_with?("/#{item_name}")
Tap.same_remote?(entry.delete_suffix("/#{item_name}"), tap.remote)
end
rescue Tap::InvalidNameError
false
end
sig { params(tap: Tap).returns(T::Boolean) }
def self.trusted_tap?(tap)
tap.implicitly_trusted? || explicitly_trusted_tap?(tap)
end
# Whether the tap appears in the trust list, ignoring any implicit official-tap trust. The
# entries may be `user/repository` names or remote URLs, so match via {Tap#matches_reference?}.
sig { params(tap: Tap).returns(T::Boolean) }
def self.explicitly_trusted_tap?(tap)
trusted_entries(:tap).any? { |reference| tap.matches_reference?(reference) }
end
sig { params(name: String, path: Pathname).void }
def self.require_trusted_formula!(name, path)
return if Homebrew::EnvConfig.no_require_tap_trust?
return unless (tap = tap_from_path(path))
return if trusted_tap?(tap)
full_name = "#{tap.name}/#{::Utils.name_from_full_name(name)}"
return if trusted?(:formula, full_name)
return if explicitly_allowed?(:formula, full_name, tap)
raise_untrusted!(:formula, full_name, tap)
end
sig { params(token: String, path: Pathname).void }
def self.require_trusted_cask!(token, path)
return if Homebrew::EnvConfig.no_require_tap_trust?
return unless (tap = tap_from_path(path))
return if trusted_tap?(tap)
full_name = "#{tap.name}/#{::Utils.name_from_full_name(token)}"
return if trusted?(:cask, full_name)
return if explicitly_allowed?(:cask, full_name, tap)
raise_untrusted!(:cask, full_name, tap)
end
sig { params(path: Pathname, command: T.nilable(String)).void }
def self.require_trusted_command!(path, command = nil)
return if Homebrew::EnvConfig.no_require_tap_trust?
return unless (tap = tap_from_path(path))
return if trusted_tap?(tap)
full_name = "#{tap.name}/#{command || path.basename(path.extname).to_s.delete_prefix("brew-")}"
return if trusted?(:command, full_name)
raise_untrusted!(:command, full_name, tap)
end
sig { params(path: Pathname).returns(T::Boolean) }
def self.trusted_formula_file?(path)
trusted_file?(:formula, path)
end
sig { params(path: Pathname).returns(T::Boolean) }
def self.trusted_cask_file?(path)
trusted_file?(:cask, path)
end
sig { params(files: T::Array[Pathname]).returns(T::Array[Pathname]) }
def self.trusted_formula_files(files)
trusted_files(:formula, files)
end
sig { params(files: T::Array[Pathname]).returns(T::Array[Pathname]) }
def self.trusted_cask_files(files)
trusted_files(:cask, files)
end
sig { params(files: T::Array[Pathname]).returns(T::Array[Pathname]) }
def self.trusted_command_files(files)
trusted_files(:command, files)
end
sig { returns(T::Array[Tap]) }
def self.untrusted_taps
Tap.installed.reject(&:official?).reject { |tap| trusted_tap?(tap) }.sort_by(&:name)
end
sig { returns(T::Array[Tap]) }
def self.wholly_untrusted_taps
untrusted_taps.reject { |tap| partially_trusted_tap?(tap) }
end
sig { params(tap: Tap).returns(T::Boolean) }
def self.partially_trusted_tap?(tap)
trusted_entry_prefix?(:formula, tap) ||
trusted_entry_prefix?(:cask, tap) ||
trusted_entry_prefix?(:command, tap)
end
private_class_method :partially_trusted_tap?
sig { params(type: Symbol).returns(String) }
def self.setting_key(type)
SETTING_KEYS.fetch(type).to_s
end
sig { params(type: Symbol).returns(T::Array[String]) }
def self.trusted_entries(type)
trust_store.fetch(setting_key(type), [])
end
sig { params(name: String).returns(String) }
def self.normalise_name(name)
name.downcase
end
sig {
params(
name: String, type: T.nilable(Symbol), include_existing: T::Boolean, tap_remote: T.nilable(String),
).returns([Symbol, String])
}
def self.target(name, type: nil, include_existing: false, tap_remote: nil)
return [type, trust_name(type, name, include_existing:, tap_remote:)] if type
infer_target(name, include_existing:)
end
sig { params(name: String, include_existing: T::Boolean).returns([Symbol, String]) }
def self.infer_target(name, include_existing:)
return [:tap, trust_name(:tap, name)] if name.count("/") == 1 || Tap.remote_reference?(name)
tap_with_name = Tap.with_formula_name(name)
unless tap_with_name
raise UsageError,
"Trust targets must be fully-qualified tap, formula, cask or command names."
end
tap, token = tap_with_name
candidate_types = T.let([], T::Array[Symbol])
if tap.formula_files_by_name.key?(token) || tap_formula_name(tap, token) != token
candidate_types << :formula
end
candidate_types << :cask if tap.cask_files_by_name.key?(token)
if tap.command_files.any? { |path| path.basename(path.extname).to_s.delete_prefix("brew-") == token }
candidate_types << :command
end
if include_existing
full_name = "#{tap.name}/#{token}"
candidate_types << :formula if trusted?(:formula, full_name)
candidate_types << :cask if trusted?(:cask, full_name)
candidate_types << :command if trusted?(:command, full_name)
end
candidates = T.let([], T::Array[[Symbol, String]])
candidate_types.uniq.each do |candidate_type|
candidates << [candidate_type, trust_name(candidate_type, name, include_existing:)]
end
return candidates.fetch(0) if candidates.one?
raise UsageError, "No formula, cask or command found for #{name}." if candidates.empty?
raise UsageError, "Ambiguous trust target #{name}. Use `--formula`, `--cask` or `--command`."
end
private_class_method :infer_target
sig {
params(
type: Symbol, name: String, include_existing: T::Boolean, tap_remote: T.nilable(String),
).returns(String)
}
def self.trust_name(type, name, include_existing: false, tap_remote: nil)
case type
when :tap
if Tap.remote_reference?(name)
reference = Tap.remote_to_reference(name)
raise UsageError, "Invalid tap remote URL: #{name}" if reference.nil?
reference
else
normalise_name(Tap.fetch(name).reference(remote: tap_remote))
end
when :formula
tap, formula_name = fully_qualified_package_name(name, "Formulae")
if include_existing
existing_name = item_trust_name(type, tap, formula_name, include_existing:, tap_remote:)
return existing_name if trusted_entries(type).include?(existing_name)
end
if tap_remote.nil? || Tap.same_remote?(tap_remote, tap.remote)
canonical_name = tap_formula_name(tap, formula_name)
if !include_existing && canonical_name != formula_name
raise UsageError, "#{name} resolves to #{tap.name}/#{canonical_name}.\n" \
"Trust the canonical formula explicitly:\n " \
"brew trust --formula #{tap.name}/#{canonical_name}"
end
formula_name = canonical_name
end
item_trust_name(type, tap, formula_name, include_existing:, tap_remote:)
when :cask
tap, token = fully_qualified_package_name(name, "Casks")
item_trust_name(type, tap, token, include_existing:, tap_remote:)
when :command
tap, command_name = fully_qualified_package_name(name, "Commands")
item_trust_name(type, tap, command_name, include_existing:, tap_remote:)
else
raise UsageError, "Unsupported trust target type: #{type}"
end
rescue Tap::InvalidNameError => e
raise UsageError, e.message
end
private_class_method :trust_name
sig {
params(
type: Symbol, tap: Tap, item_name: String, include_existing: T::Boolean, tap_remote: T.nilable(String),
).returns(String)
}
def self.item_trust_name(type, tap, item_name, include_existing: false, tap_remote: nil)
item_name = normalise_name(item_name)
full_name = "#{tap.name}/#{item_name}"
return full_name if include_existing && trusted_entries(type).include?(normalise_name(full_name))
reference = tap.reference(remote: tap_remote)
return full_name if reference == tap.name
"#{normalise_name(reference)}/#{item_name}"
end
private_class_method :item_trust_name
sig { params(name: String, noun: String).returns([Tap, String]) }
def self.fully_qualified_package_name(name, noun)
tap_with_name = Tap.with_formula_name(name)
raise UsageError, "#{noun} must be fully-qualified as <user>/<tap>/<name>." unless tap_with_name
tap_with_name
end
private_class_method :fully_qualified_package_name
sig { returns(T::Hash[String, T::Array[String]]) }
def self.trust_store
trust_path = if Homebrew.running_as_root? &&
(sudo_user = ENV.fetch("HOMEBREW_SUDO_USER", nil).presence) &&
sudo_user != "root"
passwd = begin
Etc.getpwnam(sudo_user)
rescue ArgumentError
nil
end
if passwd
trust_file(home: passwd.dir)
else
opoo "Could not determine home directory for `$HOMEBREW_SUDO_USER` (#{sudo_user}); " \
"falling back to #{trust_file}."
trust_file
end
else
trust_file
end
return {} unless trust_path.exist?
parsed_store = JSON.parse(trust_path.read)
return {} unless parsed_store.is_a?(Hash)
parsed_store.transform_values { |entries| Array(entries).map { |entry| normalise_name(entry.to_s) } }
rescue Errno::ENOENT, JSON::ParserError
{}
end
private_class_method :trust_store
sig { params(store: T::Hash[String, T::Array[String]]).void }
def self.write_trust_store(store)
write_path = trust_file
if write_path.symlink?
link_parent = write_path.dirname.realpath
assert_secure_trust_store_path!(link_parent, link_parent.stat, "symlink directory")
if write_path.lstat.uid != Process.euid
raise InsecureTrustStoreError,
"Refusing to write insecure trust store: symlink #{write_path} is not owned by the current user."
end
target_path = write_path.readlink
target_path = write_path.dirname/target_path unless target_path.absolute?
target_parent = target_path.dirname.realpath
assert_secure_trust_store_path!(target_parent, target_parent.stat, "target directory")
write_path = target_parent/target_path.basename
if write_path.symlink?
raise InsecureTrustStoreError,
"Refusing to write insecure trust store: target is a symlink."
end
if write_path.exist?
assert_secure_trust_store_path!(write_path, write_path.stat, "target")
unless write_path.file?
raise InsecureTrustStoreError,
"Refusing to write insecure trust store: target is not a regular file."
end
end
else
ensure_secure_trust_store_directory!(write_path.dirname, "trust store directory")
if write_path.exist?
assert_secure_trust_store_path!(write_path, write_path.stat, "trust store")
unless write_path.file?
raise InsecureTrustStoreError,
"Refusing to write insecure trust store: trust store is not a regular file."
end
end
end
if store.empty?
write_path.unlink if write_path.exist?
return
end
write_path.atomic_write("#{JSON.pretty_generate(store)}\n")
write_path.chmod(0600)
rescue Errno::ENOENT
raise InsecureTrustStoreError, "Refusing to write insecure trust store: target directory does not exist."
end
private_class_method :write_trust_store
sig { params(path: Pathname, description: String).void }
def self.ensure_secure_trust_store_directory!(path, description)
existed = path.exist?
path.mkpath
path.chmod(0700) unless existed
assert_secure_trust_store_path!(path, path.stat, description)
end
private_class_method :ensure_secure_trust_store_directory!
sig { params(path: Pathname, stat: File::Stat, description: String).void }
def self.assert_secure_trust_store_path!(path, stat, description)
if stat.uid != Process.euid
raise InsecureTrustStoreError,
"Refusing to write insecure trust store: #{description} #{path} is not owned by the current user."
end
return if stat.mode.nobits?(022)
raise InsecureTrustStoreError,
"Refusing to write insecure trust store: #{description} #{path} is group or world writable."
end
private_class_method :assert_secure_trust_store_path!
# Serialises trust store mutations so concurrent processes or threads
# (e.g. parallel `brew bundle` installs) cannot lose entries in the
# read-modify-write cycle.
sig {
type_parameters(:U).params(_block: T.proc.returns(T.type_parameter(:U))).returns(T.type_parameter(:U))
}
def self.with_trust_store_lock(&_block)
lock_path = Pathname.new("#{trust_file}.lock")
ensure_secure_trust_store_directory!(lock_path.dirname, "trust store directory")
File.open(lock_path, File::RDWR | File::CREAT, 0600) do |lock_file|
lock_file.flock(File::LOCK_EX)
yield
end
end
private_class_method :with_trust_store_lock
sig { params(path: Pathname).returns(T.nilable(Tap)) }
def self.tap_from_path(path)
Tap.from_path(path.realpath) || Tap.from_path(path)
rescue SystemCallError
Tap.from_path(path)
end
private_class_method :tap_from_path
sig { params(type: Symbol, path: Pathname).returns(T::Boolean) }
def self.trusted_file?(type, path)
return true if Homebrew::EnvConfig.no_require_tap_trust?
return true unless (tap = tap_from_path(path))
return true if trusted_tap?(tap)
name = path.basename(path.extname).to_s
name = name.delete_prefix("brew-") if type == :command
full_name = "#{tap.name}/#{name}"
return true if trusted?(type, full_name)
return true if explicitly_allowed?(type, full_name, tap)
false
end
private_class_method :trusted_file?
sig { params(type: Symbol, full_name: String, tap: Tap).returns(T::Boolean) }
def self.explicitly_allowed?(type, full_name, tap)
return false if type == :command
return false unless %w[install reinstall upgrade].include?(Homebrew.running_command)
downcased_args = ARGV.map(&:downcase)
downcased_full_name = full_name.downcase
tap_name = tap.name.downcase
if downcased_args.include?(downcased_full_name)
return true if type != :formula
name = ::Utils.name_from_full_name(downcased_full_name)
return true if tap_formula_name(tap, name) == name
end
downcased_args.include?("--tap=#{tap_name}") ||
downcased_args.each_cons(2).any? { |option, value| option == "--tap" && value == tap_name }
end
private_class_method :explicitly_allowed?
# Match Formulary.tap_formula_name_type without loading Ruby or following tap migrations.
sig { params(tap: Tap, name: String).returns(String) }
def self.tap_formula_name(tap, name)
name = name.downcase
if (alias_target = tap.alias_table[tap.core_tap? ? name : "#{tap.name}/#{name}"].presence)
name = ::Utils.name_from_full_name(alias_target)
elsif (new_name = tap.formula_renames[name].presence)
name = new_name
end
name
end
private_class_method :tap_formula_name
sig { params(type: Symbol, files: T::Array[Pathname]).returns(T::Array[Pathname]) }
def self.trusted_files(type, files)
trusted_files = files.select { |file| trusted_file?(type, file) }
skipped_taps = (files - trusted_files).filter_map { |file| tap_from_path(file) }.uniq.sort_by(&:name)
skipped_taps.each do |tap|
next if partially_trusted_tap?(tap)
opoo "Skipping #{tap.name} because it is not trusted. Run `brew trust #{tap.name}` to trust it."
end
trusted_files
end
private_class_method :trusted_files
sig { params(type: Symbol, tap: Tap).returns(T::Boolean) }
def self.trusted_entry_prefix?(type, tap)
prefixes = T.let([normalise_name(tap.reference)], T::Array[String])
prefixes << tap.name if tap.uses_custom_remote?
trusted_entries(type).any? do |entry|
prefixes.any? { |prefix| entry.start_with?("#{prefix}/") }
end
end
private_class_method :trusted_entry_prefix?
sig { params(type: Symbol, name: String, tap: Tap).void }
def self.raise_untrusted!(type, name, tap)
raise UntrustedTapError, "Refusing to load #{type} #{name} from untrusted tap #{tap.name}.\n" \
"Run `brew trust --#{type} #{name}` or `brew trust #{tap.name}` to trust it."
end
private_class_method :raise_untrusted!
end
end