Files
picopackage/lib/picopackage/provider.rb
T
Dan MilneandClaude Fable 5 6dd57e84f1 Version 0.3.0: resolver/merge/cache refactor, signing design notes
- 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
2026-08-13 21:18:19 +10:00

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