Files
brew/Library/Homebrew/api/packages_index.rb
T
Mike McQuaid 5ab33c9e63 Parse only needed internal API payload entries
- Every command loading internal API data paid a full `JSON.parse` of
  the 13MB packages payload (~8500 formulae and ~7700 casks, ~80ms and
  a large retained object graph) even when installing one formula.
- Add `Homebrew::API::PackagesIndex`, a byte-offset index sidecar over
  the signature-verified payload. The index is derived, unverified
  cache data guarded in layers: the payload bytes it points into are
  RSA-PSS-verified every run, loading requires the recorded top-level
  spans to tile that payload exactly (proving the formulae and casks
  section spans are the real top-level values) and every lookup
  revalidates that its offsets sit at the expected `"<name>":` key
  inside the requested section's span and that the slice parses. A
  forged or stale index therefore cannot inject unverified content or
  remap a name to another entry, even a same-named key in the other
  section; it fails validation and callers fall back to a full parse.
- Build the index whenever a download, revalidation or index miss has
  already paid for a full parse, by locating each entry's bytes via
  their exact `JSON.generate` round trip (the payload is generated by
  the same serialiser, verified against the whole document up front).
- `Internal` now serves per-name entries (`formula_hash`, `cask_hash`,
  name lists and membership) and the small top-level keys through the
  index, materialising `formula_hashes`/`cask_hashes` only for
  full-iteration callers such as `search` and `formula_auditor`.
- `write_names_file!`, `write_aliases_file!` and
  `write_executables_file!` take blocks so their freshness short
  circuits no longer materialise every entry on warm loads.
- `brew update` prewarms the index: `brew update-report`'s existing
  `write_names_and_aliases` call rebuilds the payload sidecar and
  index whenever the envelope changed, during the full parse it needs
  for the names files anyway, so the first command after an update
  loads through the index. The previous-OS-version removals in
  `cmd/update.sh` and `brew cleanup --scrub` keep the current OS's
  `.payload.index` alongside its `.payload` instead of deleting it on
  every update.
- Internal API data load drops from ~100ms to ~30ms in `brew install`
  (signature verification retained) and a warm no-op `brew install`
  from ~530ms to ~440ms.
2026-07-31 17:43:41 +01:00

300 lines
12 KiB
Ruby

# typed: strict
# frozen_string_literal: true
module Homebrew
module API
# Byte-offset index into a signature-verified internal packages JWS
# payload, so commands can parse only the entries they need instead of
# the whole multi-megabyte document.
#
# The index is derived, unverified cache data guarded in layers: the
# payload bytes it points into are signature-verified on every run,
# loading requires the recorded top-level spans to tile that payload
# exactly (so the formulae and casks section spans are provably the
# real top-level values) and every lookup revalidates that its offsets
# sit at the expected `"<name>":` key inside the requested section's
# span and that the slice parses. A forged or stale index therefore
# cannot inject unverified content or remap a name to another entry,
# even a matching key in the other section; it fails validation and
# callers fall back to a full parse when {Invalid} is raised.
class PackagesIndex
FORMAT_VERSION = 1
SECTION_KEYS = %w[formulae casks].freeze
# Bounds index building when payload bytes stop round-tripping through
# `JSON.generate`; giving up just means no index is written.
MAX_FALSE_MATCH_RETRIES = 100
# Raised when index contents do not match the verified payload.
class Invalid < RuntimeError; end
sig { params(target: Pathname).returns(Pathname) }
def self.path_for(target)
Pathname("#{target}.payload.index")
end
sig { params(stat: File::Stat).returns(T::Hash[String, Integer]) }
def self.source_fingerprint(stat)
{
"source_size" => stat.size,
"source_mtime_ns" => (stat.mtime.to_r * 1_000_000_000).to_i,
}
end
sig { params(target: Pathname, payload: String, source_stat: File::Stat).returns(T.nilable(PackagesIndex)) }
def self.load(target, payload:, source_stat:)
data = JSON.parse(path_for(target).read(encoding: Encoding::UTF_8))
return unless data.is_a?(Hash)
return if data["version"] != FORMAT_VERSION
return if source_fingerprint(source_stat).any? { |key, value| data[key] != value }
return if data["payload_bytesize"] != payload.bytesize
top_level = data["top_level"]
sections = data.slice(*SECTION_KEYS)
return unless top_level.is_a?(Hash)
return unless sections.values.all?(Hash)
return unless top_level_spans_tile_payload?(payload, top_level)
new(payload:, source_stat:, top_level:, sections:)
rescue SystemCallError, JSON::ParserError
nil
end
# The recorded top-level spans must reconstruct the payload's
# top-level object exactly: starting at the opening brace, each span
# is immediately preceded by its own comma-separated JSON key and the
# last ends at the closing brace. This proves every span, including
# the section spans entry lookups are bounded by, is the real
# top-level value for its key rather than an arbitrary or inflated
# byte range.
sig { params(payload: String, top_level: T::Hash[String, T.untyped]).returns(T::Boolean) }
private_class_method def self.top_level_spans_tile_payload?(payload, top_level)
return false if payload.byteslice(0, 1) != "{"
spans = top_level.map do |key, location|
offset, bytesize = location
return false if !offset.is_a?(Integer) || !bytesize.is_a?(Integer) || bytesize.negative?
[key.to_s, offset, bytesize]
end
spans.sort_by! { |_, offset, _| offset }
position = 1
spans.each_with_index do |(key, offset, bytesize), index|
key_bytes = "#{key.to_json}:"
key_bytes = ",#{key_bytes}" if index.positive?
return false if payload.byteslice(position, key_bytes.bytesize) != key_bytes
return false if position + key_bytes.bytesize != offset
position = offset + bytesize
end
position + 1 == payload.bytesize && payload.byteslice(position, 1) == "}"
end
# Builds and persists an index for a freshly verified and parsed
# payload. Failing to build or write one only costs the fast path.
sig {
params(target: Pathname, payload: String, parsed: T::Hash[String, T.untyped],
source_stat: File::Stat).void
}
def self.write!(target, payload:, parsed:, source_stat:)
# Never write to a user-owned cache as root, matching `skip_download?`.
return if Homebrew.running_as_root_but_not_owned_by_root?
return if (data = build(payload:, parsed:)).nil?
data = {
"version" => FORMAT_VERSION,
**source_fingerprint(source_stat),
"payload_bytesize" => payload.bytesize,
**data,
}
index_path = path_for(target)
temporary_path = Pathname("#{index_path}.tmp")
begin
temporary_path.write(JSON.generate(data))
File.rename(temporary_path, index_path)
ensure
temporary_path.unlink if temporary_path.exist?
end
rescue SystemCallError
nil
end
# Locates every top-level value and every formula and cask entry in the
# payload bytes. Offsets are found by searching for each JSON key in
# document order and validating that the following bytes byte-match the
# entry's `JSON.generate` round trip, so every recorded offset provably
# reproduces the canonical parse.
sig {
params(payload: String, parsed: T::Hash[String, T.untyped])
.returns(T.nilable(T::Hash[String, T::Hash[String, [Integer, Integer]]]))
}
def self.build(payload:, parsed:)
data = T.let({ "top_level" => {} }, T::Hash[String, T::Hash[String, [Integer, Integer]]])
SECTION_KEYS.each { |section| data[section] = {} }
retries = 0
position = 0
parsed.each do |key, value|
location = locate(payload, key, value, position)
return nil if location.nil?
value_start, value_bytesize = location
T.must(data["top_level"])[key] = [value_start, value_bytesize]
if SECTION_KEYS.include?(key) && value.is_a?(Hash)
entry_position = value_start
value.each do |name, entry|
entry_location = T.let(nil, T.nilable([Integer, Integer]))
loop do
entry_location = locate(payload, name, entry, entry_position)
break unless entry_location.nil?
retries += 1
return nil if retries > MAX_FALSE_MATCH_RETRIES
next_position = payload.byteindex("#{name.to_json}:", entry_position)
return nil if next_position.nil?
entry_position = next_position + 1
end
entry_start, entry_bytesize = entry_location
T.must(data[key])[name] = [entry_start, entry_bytesize]
entry_position = entry_start + entry_bytesize
end
end
position = value_start + value_bytesize
end
data
end
# Finds `"<key>":<value>` at or after `position`, returning the value's
# byte offset and length only when the payload bytes match the value's
# canonical serialisation exactly.
sig {
params(payload: String, key: String, value: T.untyped, position: Integer)
.returns(T.nilable([Integer, Integer]))
}
private_class_method def self.locate(payload, key, value, position)
key_bytes = "#{key.to_json}:"
key_position = payload.byteindex(key_bytes, position)
return if key_position.nil?
value_bytes = JSON.generate(value)
value_start = key_position + key_bytes.bytesize
return if payload.byteslice(value_start, value_bytes.bytesize) != value_bytes
[value_start, value_bytes.bytesize]
end
sig { returns(String) }
attr_reader :payload
sig { returns(File::Stat) }
attr_reader :source_stat
sig {
params(payload: String, source_stat: File::Stat, top_level: T::Hash[String, T.untyped],
sections: T::Hash[String, T::Hash[String, T.untyped]]).void
}
def initialize(payload:, source_stat:, top_level:, sections:)
@payload = payload
@source_stat = source_stat
@top_level = top_level
@sections = sections
end
sig { params(name: String).returns(T.nilable(T::Hash[String, T.untyped])) }
def formula_hash(name)
entry_value("formulae", name)
end
sig { params(name: String).returns(T.nilable(T::Hash[String, T.untyped])) }
def cask_hash(name)
entry_value("casks", name)
end
sig { returns(T::Array[String]) }
def formula_names
@sections.fetch("formulae", {}).keys
end
sig { returns(T::Array[String]) }
def cask_names
@sections.fetch("casks", {}).keys
end
sig { params(name: String).returns(T::Boolean) }
def formula_name?(name)
@sections.fetch("formulae", {}).key?(name)
end
sig { params(name: String).returns(T::Boolean) }
def cask_name?(name)
@sections.fetch("casks", {}).key?(name)
end
sig { params(key: String).returns(T.untyped) }
def top_level_value(key)
return if SECTION_KEYS.include?(key)
location = @top_level[key]
return if location.nil?
slice_value(key, location)
end
private
sig { params(section: String, name: String).returns(T.nilable(T::Hash[String, T.untyped])) }
def entry_value(section, name)
location = @sections.fetch(section, {})[name]
return if location.nil?
section_location = @top_level[section]
raise Invalid, "no #{section} span for the #{name} index entry" unless section_location.is_a?(Array)
value = slice_value(name, location, within: section_location)
raise Invalid, "#{section} index entry for #{name} is not a hash" unless value.is_a?(Hash)
value
end
# Revalidates a recorded location against the verified payload bytes:
# it must be preceded by the expected JSON key, sit inside the given
# load-validated span and parse cleanly.
sig { params(name: String, location: T.untyped, within: T.untyped).returns(T.untyped) }
def slice_value(name, location, within: nil)
offset, bytesize = location
key_bytes = "#{name.to_json}:"
key_offset = offset - key_bytes.bytesize if offset.is_a?(Integer)
if !offset.is_a?(Integer) || !bytesize.is_a?(Integer) ||
key_offset.nil? || key_offset.negative? || (offset + bytesize) > payload.bytesize ||
payload.byteslice(key_offset, key_bytes.bytesize) != key_bytes ||
outside_span?(key_offset, offset + bytesize, within)
raise Invalid, "index location for #{name} does not match the payload"
end
begin
JSON.parse(T.must(payload.byteslice(offset, bytesize)), freeze: true)
rescue JSON::ParserError
raise Invalid, "index slice for #{name} does not parse"
end
end
sig { params(start_offset: Integer, end_offset: Integer, within: T.untyped).returns(T::Boolean) }
def outside_span?(start_offset, end_offset, within)
return false if within.nil?
within_offset, within_bytesize = within
return true if !within_offset.is_a?(Integer) || !within_bytesize.is_a?(Integer)
start_offset < within_offset || end_offset > within_offset + within_bytesize
end
end
end
end