- Split the monolith into package/provider/fetch/merge/cache modules with the Resolver deciding install/adopt/update/merge/conflict outcomes - Three-way merges via git merge-file/diff3 against a content-addressed merge-base cache; conflicts go to a .picopackage-merge sibling - Tests for package, provider, resolver, merge, and cache - notes.md: update UX and signing design — diff-by-default, SSH signature identity pinning (TOFU), key changes as a hard stop, exit-code contract - Add CLAUDE.md; remove the pre-refactor exe/pppkg monolith and scratch files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EiyJC73Mz8xZyCTvCEY8qn
184 lines
5.8 KiB
Ruby
184 lines
5.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "time"
|
|
require "pathname"
|
|
|
|
module Picopackage
|
|
class Provider
|
|
def self.for(url)
|
|
PROVIDERS.each do |provider|
|
|
case provider.handles_url?(url)
|
|
when false, nil
|
|
next
|
|
when true
|
|
return provider.new(url)
|
|
when :maybe
|
|
# A `:maybe` provider proves itself by fetching, and providers fetch in
|
|
# their constructor — so a speculative provider that guesses wrong
|
|
# (OpenGistProvider appends ".json" to every URL it sees) raises here.
|
|
# That has to fall through to the next candidate rather than abort the
|
|
# whole resolution, or no plain URL would ever reach DefaultProvider.
|
|
begin
|
|
instance = provider.new(url)
|
|
return instance if instance.handles_body?
|
|
rescue FetchError, Fetch::Error, URI::Error
|
|
next
|
|
end
|
|
end
|
|
end
|
|
nil
|
|
end
|
|
end
|
|
|
|
# Base class for fetching content from a URL.
|
|
#
|
|
# A provider's job is to fetch the body, and to pull the package content and a
|
|
# filename out of it. Splitting content into payload and metadata is the
|
|
# Package class's job, not a provider's.
|
|
class DefaultProvider
|
|
MAX_SIZE = 1024 * 1024
|
|
TIMEOUT = 10
|
|
|
|
attr_reader :url, :package
|
|
|
|
# Not `:maybe`. This is the last resort in PROVIDERS, so if it declined on a
|
|
# failed fetch the loop would fall off the end and report "no provider could
|
|
# handle this" for what is really a 404. Claiming any URL it understands the
|
|
# scheme of lets the actual HTTP error reach the user.
|
|
def self.handles_url?(url)
|
|
scheme = URI(url.to_s).scheme
|
|
%w[http https file].include?(scheme)
|
|
rescue URI::InvalidURIError
|
|
false
|
|
end
|
|
|
|
def initialize(url, fetcher: Fetch.new(max_size: MAX_SIZE, timeout: TIMEOUT))
|
|
@url = transform_url(url)
|
|
@fetcher = fetcher
|
|
@package = Package.new(content: content)
|
|
populate_metadata
|
|
end
|
|
|
|
def transform_url(url) = URI(url.to_s)
|
|
|
|
def body
|
|
@body ||= @fetcher.fetch(@url)
|
|
rescue Fetch::Error => e
|
|
raise FetchError, e.message
|
|
end
|
|
|
|
def json_body
|
|
@json_body ||= JSON.parse(body)
|
|
rescue JSON::ParserError
|
|
raise FetchError, "Failed to parse JSON response"
|
|
end
|
|
|
|
# ISO 8601 throughout: sortable, unambiguous, and the same shape from every
|
|
# provider, which httpdate-here-iso-there was not.
|
|
def payload_timestamp = Time.now.utc.iso8601
|
|
|
|
def handles_body?
|
|
!body.nil? && !body.empty?
|
|
rescue FetchError, FileTooLargeError
|
|
false
|
|
end
|
|
|
|
# Implement in a subclass when the content is wrapped in something (JSON,
|
|
# HTML). Splitting payload from metadata is the Package class's job.
|
|
def content = body
|
|
|
|
# Implement in a subclass when the body carries a filename. Not to be
|
|
# confused with the filename in a package's metadata.
|
|
def filename
|
|
candidate = File.basename(@url.path.to_s)
|
|
candidate.empty? ? nil : candidate
|
|
end
|
|
|
|
# Upstream may or may not carry a metadata block, and the two halves of one
|
|
# are not equally trustworthy:
|
|
#
|
|
# - *Claims* are upstream's to make — the canonical url (which outlives the
|
|
# mirror we happened to fetch from), payload_version, licence, signature.
|
|
# Those are adopted with `||=`.
|
|
# - *Records* describe a particular copy on a particular disk, so upstream
|
|
# cannot speak for ours. Checksums are recomputed from the payload we
|
|
# actually received. Adopting a stale payload_checksum installed a file
|
|
# that failed its own verification and read as locally modified before
|
|
# anyone had touched it.
|
|
#
|
|
# The checksum covers the extracted payload, not the raw body: hashing the
|
|
# body meant an upstream carrying a metadata block hashed its own metadata,
|
|
# so it never matched the same payload fetched from anywhere else.
|
|
def populate_metadata
|
|
@package.filename ||= filename
|
|
@package.url ||= @url.to_s
|
|
@package.payload_timestamp ||= payload_timestamp
|
|
@package.payload_checksum = @package.payload_digest
|
|
@package.base_checksum = @package.payload_checksum
|
|
end
|
|
end
|
|
|
|
class GithubGistProvider < DefaultProvider
|
|
def self.handles_url?(url) = url.to_s.match?(%r{gist\.github\.com})
|
|
|
|
def content = json_body["files"].values.first["content"]
|
|
|
|
def filename = json_body["files"].values.first["filename"]
|
|
|
|
def transform_url(url)
|
|
gist_id = url.to_s[%r{gist\.github\.com/[^/]+/([a-f0-9]+)}, 1]
|
|
raise FetchError, "Could not extract a gist id from #{url}" if gist_id.nil?
|
|
|
|
URI("https://api.github.com/gists/#{gist_id}")
|
|
end
|
|
|
|
def payload_timestamp
|
|
Time.parse(json_body["updated_at"] || json_body["created_at"]).utc.iso8601
|
|
rescue ArgumentError, TypeError
|
|
super
|
|
end
|
|
end
|
|
|
|
class OpenGistProvider < DefaultProvider
|
|
# Speculative: it appends ".json" to anything and sees what comes back, so it
|
|
# must not inherit DefaultProvider's unconditional claim.
|
|
def self.handles_url?(url) = :maybe
|
|
|
|
def transform_url(url) = URI("#{url}.json")
|
|
|
|
def content = json_body.dig("files", 0, "content")
|
|
|
|
def filename = json_body.dig("files", 0, "filename")
|
|
|
|
# Only claims the URL if the JSON actually looks like a gist listing.
|
|
def handles_body?
|
|
!content.nil? && !filename.nil?
|
|
rescue FetchError, FileTooLargeError
|
|
false
|
|
end
|
|
end
|
|
|
|
class FileProvider < DefaultProvider
|
|
def self.handles_url?(url) = !url.to_s.empty? && File.exist?(url.to_s)
|
|
|
|
def transform_url(url) = Pathname(url.to_s)
|
|
|
|
def content = url.read
|
|
|
|
def filename = url.basename.to_s
|
|
|
|
def payload_timestamp
|
|
url.mtime.utc.iso8601
|
|
rescue Errno::ENOENT
|
|
super
|
|
end
|
|
end
|
|
|
|
PROVIDERS = [
|
|
FileProvider,
|
|
GithubGistProvider,
|
|
OpenGistProvider,
|
|
DefaultProvider
|
|
].freeze
|
|
end
|