Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract TUF Updater into tuf/ #56

Merged
merged 1 commit into from
Jul 2, 2024
Merged
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
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
Loading