Skip to content

Commit

Permalink
Extract TUF Updater into tuf/ (#56)
Browse files Browse the repository at this point in the history
Separating the general TUF pieces from the sigstore-specific bits

Signed-off-by: Samuel Giddins <[email protected]>
  • Loading branch information
segiddins authored Jul 2, 2024
1 parent 35a03a7 commit b40da93
Show file tree
Hide file tree
Showing 7 changed files with 316 additions and 273 deletions.
7 changes: 5 additions & 2 deletions lib/sigstore/internal/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
module Sigstore::Internal
module JSON
# Implements https://wiki.laptop.org/go/Canonical_JSON
#
# TODO: This is a naive implementation. Performance can be improved by
# serializing into a buffer instead of concatenating strings.
def self.canonical_generate(data)
case data
when NilClass
Expand All @@ -34,11 +37,11 @@ def self.canonical_generate(data)
"[#{contents}]"
when Hash
contents = data.sort_by do |k, _|
raise ArgumentError, "Non-string key in hash" unless k.is_a?(String)

k.encode("utf-16").codepoints
end
contents.map! do |k, v|
raise ArgumentError, "Non-string key in hash" unless k.is_a?(String)

"#{canonical_generate(k)}:#{canonical_generate(v)}"
end
"{#{contents.join(",")}}"
Expand Down
243 changes: 4 additions & 239 deletions lib/sigstore/tuf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

require_relative "tuf/config"
require_relative "tuf/trusted_metadata_set"
require_relative "tuf/root"
require_relative "tuf/snapshot"
require_relative "tuf/targets"
require_relative "tuf/timestamp"
require_relative "tuf/updater"
require "tempfile"
require "uri"
require "net/http"
Expand Down Expand Up @@ -100,9 +95,10 @@ def get_dirs(url)
app_author = "segiddins"

repo_base = encode_uri_component(url)
home = Dir.home

data_home = ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share"))
cache_home = ENV.fetch("XDG_CACHE_HOME", File.join(Dir.home, ".cache"))
data_home = ENV.fetch("XDG_DATA_HOME", File.join(home, ".local", "share"))
cache_home = ENV.fetch("XDG_CACHE_HOME", File.join(home, ".cache"))
tuf_data_dir = File.join(data_home, app_name, app_author, "tuf")
tuf_cache_dir = File.join(cache_home, app_name, app_author, "tuf")

Expand Down Expand Up @@ -132,236 +128,5 @@ def trusted_root_path
path
end
end

class Updater
include Loggable

def initialize(metadata_dir:, metadata_base_url:, target_base_url:, target_dir:, fetcher:,
config: UpdaterConfig.new)
@dir = metadata_dir
@metadata_base_url = "#{metadata_base_url.to_s.chomp("/")}/"
@target_dir = target_dir
@target_base_url = target_base_url && "#{target_base_url.to_s.chomp("/")}/"

@fetcher = fetcher
@config = config

unless %i[metadata simple].include? @config.envelope_type
raise ArgumentError, "Unsupported envelope type: #{@config[:envelope_type].inspect}"
end

begin
data = load_local_metadata("root")
@trusted_set = TrustedMetadataSet.new(data, "metadata")
rescue ::JSON::ParserError => e # JSON::ParseError
raise "Invalid JSON in #{File.join(@dir, "root.json")}: #{e.class} #{e}"
end
end

def refresh
load_root
load_timestamp
load_snapshot
load_targets(Targets::TYPE, Root::TYPE)
end

def get_targetinfo(target_path)
refresh unless @trusted_set.include? Targets::TYPE
preorder_depth_first_walk(target_path)
end

def find_cached_target(target_info, filepath = nil)
filepath ||= generate_target_file_path(target_info)

begin
data = File.binread(filepath)
target_info.verify_length_and_hashes(data)
filepath
rescue Errno::ENOENT, Error::LengthOrHashMismatch
nil
end
end

def download_target(target_info, filepath = nil, target_base_url = nil)
target_base_url ||= @target_base_url
raise "No target_base_url set" unless target_base_url

filepath ||= generate_target_file_path(target_info)

target_filepath = target_info.path
consistent_snapshot = @trusted_set.root.consistent_snapshot

if consistent_snapshot && @config.prefix_targets_with_hash
hashes = target_info.hashes.values
dir, sep, basename = target_filepath.rpartition("/")
target_filepath = "#{dir}#{sep}#{hashes.first}.#{basename}"
end

full_url = URI.join(target_base_url, target_filepath)

@fetcher.get2(full_url) do |resp|
resp.value
target_info.verify_length_and_hashes(resp.body)

File.binwrite(filepath, resp.body)
rescue Net::HTTPClientException => e
raise "Failed to download target #{target_info.inspect} #{target_filepath.inspect} from #{full_url}: " \
"#{e.message}"
end
logger.info { "Downloaded #{target_filepath} to #{filepath}" }
filepath
end

private

def load_local_metadata(role_name)
encoded_name = URI.encode_www_form_component(role_name)

File.binread(File.join(@dir, "#{encoded_name}.json"))
end

def load_root
lower_bound = @trusted_set.root.version + 1
upper_bound = lower_bound + @config.max_root_rotations

lower_bound.upto(upper_bound) do |version|
data = download_metadata("root", version)
@trusted_set.root = data
persist_metadata("root", data)
rescue Net::HTTPClientException => e
break if %w[403 404].include? e.response.code

raise
end
end

def load_timestamp
begin
data = load_local_metadata(Timestamp::TYPE)
rescue Errno::ENOENT => e
logger.debug "Local timestamp not valid as final: #{e.class} #{e.message}"
else
@trusted_set.timestamp = data
end

data = download_metadata(Timestamp::TYPE, nil)

begin
@trusted_set.timestamp = data
rescue EqualVersionNumberError
return
end

persist_metadata(Timestamp::TYPE, data)
end

def load_snapshot
data = load_local_metadata(Snapshot::TYPE)
@trusted_set.snapshot = data
logger.debug "Loaded snapshot from local metadata"
rescue Errno::ENOENT => e
logger.debug "Local snapshot not valid as final: #{e.class} #{e.message}"

snapshot_meta = @trusted_set.timestamp.snapshot_meta
version = snapshot_meta.version if @trusted_set.root.consistent_snapshot

data = download_metadata(Snapshot::TYPE, version)
@trusted_set.snapshot = data
persist_metadata(Snapshot::TYPE, data)
end

def load_targets(role, parent_role)
return @trusted_set[role] if @trusted_set.include?(role)

begin
data = load_local_metadata(role)
@trusted_set.update_delegated_targets(data, role, parent_role).tap do
logger.debug { "Loaded targets for #{role} from local metadata" }
end
rescue Errno::ENOENT
logger.debug { "No local targets for #{role}, fetching" }

snapshot = @trusted_set.snapshot
metainfo = snapshot.meta.fetch("#{role}.json")
raise "No metadata for role: #{role}" unless metainfo

version = metainfo.version if @trusted_set.root.consistent_snapshot
data = download_metadata(role, version)
delegated_targets = @trusted_set.update_delegated_targets(data, role, parent_role)
persist_metadata(role, data)
delegated_targets
end
end

def download_metadata(role_name, version)
encoded_name = URI.encode_www_form_component(role_name)
url = if version.nil?
URI.join(@metadata_base_url, "#{encoded_name}.json")
else
URI.join(@metadata_base_url, "#{version}.#{encoded_name}.json")
end

resp = @fetcher.get(url)
resp.value

resp.body
end

def persist_metadata(role_name, data)
encoded_name = URI.encode_www_form_component(role_name)
filename = File.join(@dir, "#{encoded_name}.json")
Tempfile.create("", @dir) do |f|
f.binmode
f.write(data)
f.close

File.rename(f.path, filename)
end
end

def preorder_depth_first_walk(target_path)
# TODO
delegations_to_visit = [[Targets::TYPE, Root::TYPE]]
visited_role_names = Set.new

while delegations_to_visit.any? && visited_role_names.size < @config.max_delegations
role_name, parent_role = delegations_to_visit.shift
next if visited_role_names.include?(role_name)

targets = load_targets(role_name, parent_role)
target = targets.targets.fetch(target_path, nil)

return target if target

visited_role_names.add(role_name)

next unless targets.delegations.any?

child_roles_to_visit = []

targets.delegations.roles_for_target(target_path).each do |child_name, terminating|
child_roles_to_visit << [child_name, role_name]
next unless terminating

logger.debug { "Terminating delegation found for #{child_name}" }
delegations_to_visit.clear
break
end

delegations_to_visit.concat child_roles_to_visit.reverse
end

logger.warn { "Max delegations reached, stopping search" } if delegations_to_visit.any?

nil
end

def generate_target_file_path(target_info)
raise "target_dir not set" unless @target_dir

filename = URI.encode_www_form_component(target_info.path)
File.join(@target_dir, filename)
end
end
end
end
4 changes: 4 additions & 0 deletions lib/sigstore/tuf/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,9 @@
module Sigstore::TUF
class Error < ::Sigstore::Error
class LengthOrHashMismatch < Error; end
class ExpiredMetadata < Error; end
class EqualVersionNumber < Error; end
class BadVersionNumber < Error; end
class BadUpdateOrder < Error; end
end
end
Loading

0 comments on commit b40da93

Please sign in to comment.