- 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
199 lines
7.3 KiB
Ruby
199 lines
7.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "yaml"
|
|
require "json"
|
|
require "digest"
|
|
require "time"
|
|
require "forwardable"
|
|
|
|
module Picopackage
|
|
METADATA_PATTERN = /^\n*#\s*@PICOPACKAGE_START\n(.*?)^\s*#\s*@PICOPACKAGE_END\s*$/m
|
|
|
|
class Metadata < Struct.new(:url, :filename, :payload_version, :payload_timestamp,
|
|
:payload_checksum, :base_checksum, :etag, keyword_init: true)
|
|
def self.from_content(content)
|
|
return new unless (match = METADATA_PATTERN.match(content))
|
|
|
|
parsed = YAML.safe_load(uncomment(match[1]))
|
|
return new unless parsed.is_a?(Hash)
|
|
|
|
known, unknown = parsed.partition { |key, _| members.include?(key.to_s.to_sym) }
|
|
new(**known.to_h.transform_keys(&:to_sym)).tap { |metadata| metadata.extra = unknown.to_h }
|
|
rescue Psych::Exception, TypeError, ArgumentError
|
|
new
|
|
end
|
|
|
|
def self.from_file(path) = from_content(File.read(path))
|
|
|
|
def self.uncomment(block)
|
|
block.each_line.map { |line| line.sub(/^\s*#\s?/, "").rstrip }.join("\n")
|
|
end
|
|
|
|
# Keys the struct doesn't model. The spec has a long tail of optional fields
|
|
# — licence, test_url, signature, parent_revision — and a tool that dropped
|
|
# the ones it didn't recognise would quietly destroy them the first time it
|
|
# rewrote a file. Round-tripping them costs nothing and means old tools
|
|
# can't corrupt packages written by newer ones.
|
|
attr_writer :extra
|
|
|
|
def extra = @extra ||= {}
|
|
|
|
def empty? = to_h.values.all?(&:nil?) && extra.empty?
|
|
|
|
# Modelled members in declaration order, then unmodelled keys sorted, so a
|
|
# rewrite that changes one field produces a one-line diff.
|
|
def to_yaml_hash = to_h.compact.transform_keys(&:to_s).merge(extra.sort.to_h)
|
|
end
|
|
|
|
class Payload
|
|
def self.from_content(content) = content.sub(METADATA_PATTERN, "")
|
|
|
|
def self.normalize(payload) = payload.rstrip + "\n\n"
|
|
|
|
def self.normalized_from_content(content) = normalize(from_content(content))
|
|
|
|
def self.from_file(path) = normalized_from_content(File.read(path))
|
|
|
|
def self.checksum(payload) = "sha256:#{Digest::SHA256.hexdigest(payload)}"
|
|
|
|
# Always hashes the *normalised* payload. Hashing the raw payload here while
|
|
# verification hashed the normalised one meant freshly written packages
|
|
# failed their own checksum, so every file looked locally modified.
|
|
def self.checksum_from_content(content) = checksum(normalized_from_content(content))
|
|
end
|
|
|
|
class Package
|
|
extend Forwardable
|
|
|
|
attr_reader :content, :payload, :metadata
|
|
|
|
def_delegators :@metadata,
|
|
:url, :url=,
|
|
:filename, :filename=,
|
|
:payload_version, :payload_version=,
|
|
:payload_timestamp, :payload_timestamp=,
|
|
:payload_checksum, :payload_checksum=,
|
|
:base_checksum, :base_checksum=,
|
|
:etag, :etag=
|
|
|
|
def self.from_file(file_path)
|
|
new(content: File.read(file_path)) if File.exist?(file_path)
|
|
end
|
|
|
|
def initialize(content:)
|
|
@content = content
|
|
@had_metadata_block = METADATA_PATTERN.match?(content)
|
|
@payload = Payload.normalized_from_content(content)
|
|
@metadata = Metadata.from_content(content)
|
|
end
|
|
|
|
# True when the file arrived with no metadata block at all: a hand-copied
|
|
# file, or an upstream that was never packaged. Deliberately not "its
|
|
# metadata parsed to nothing" — a block holding only optional keys we don't
|
|
# model would answer yes to that and get clobbered as though it were bare.
|
|
def bare? = !@had_metadata_block
|
|
|
|
# What the payload hashes to right now.
|
|
def payload_digest = Payload.checksum(payload)
|
|
|
|
# Local edits break the recorded checksum, and that divergence is the only
|
|
# modification signal available — mtime says nothing about content.
|
|
#
|
|
# Note the asymmetry with #verify_payload when no checksum is recorded: we
|
|
# have no *evidence of modification*, but we also cannot *verify*. Both
|
|
# answers are false, and they mean different things.
|
|
def modified?
|
|
return false unless recorded_checksum?
|
|
|
|
payload_digest != payload_checksum
|
|
end
|
|
|
|
def verify_payload
|
|
return false unless recorded_checksum?
|
|
|
|
payload_digest == payload_checksum
|
|
end
|
|
|
|
# The upstream version this file was last reconciled with. Packages written
|
|
# before `base_checksum` existed, and hand-written metadata, have only
|
|
# `payload_checksum` — which meant exactly this before merges did.
|
|
def ancestor_checksum = base_checksum || payload_checksum
|
|
|
|
# Whether the payload carries changes relative to upstream — from a human
|
|
# edit *or* from an earlier merge.
|
|
#
|
|
# This, not #modified?, is what decides whether upstream may simply
|
|
# overwrite the file. A merged file is unmodified (we wrote it, so its
|
|
# recorded checksum matches) while still holding local edits that a
|
|
# fast-forward would silently discard.
|
|
def diverged?
|
|
return false if ancestor_checksum.nil? || ancestor_checksum.empty?
|
|
|
|
payload_digest != ancestor_checksum
|
|
end
|
|
|
|
# Replaces the payload, e.g. with the result of a merge, and re-points the
|
|
# recorded checksum at it so the file reads as unmodified until a human
|
|
# touches it. `base_checksum` is left alone: it tracks the upstream ancestor,
|
|
# which is not what we just wrote.
|
|
def payload=(new_payload)
|
|
@payload = Payload.normalize(new_payload)
|
|
@content = @payload
|
|
@metadata.payload_checksum = Payload.checksum(@payload)
|
|
end
|
|
|
|
# Fills in what's missing. `payload_checksum` is deliberately absent: it is
|
|
# derived at write time by #generate_package rather than carried over from
|
|
# an input, so a stale value can never survive into a file we write.
|
|
def init_metadata(filename: nil, url: nil)
|
|
@metadata.filename ||= filename
|
|
@metadata.url ||= url
|
|
@metadata.payload_timestamp ||= Time.now.utc.iso8601
|
|
@metadata.base_checksum ||= payload_digest
|
|
self
|
|
end
|
|
|
|
def payload_timestamp_as_time
|
|
payload_timestamp ? Time.parse(payload_timestamp.to_s) : nil
|
|
rescue ArgumentError
|
|
nil
|
|
end
|
|
|
|
# Returns the path written to.
|
|
def save(path, filename = nil)
|
|
path = File.join(path, filename || @metadata.filename) if File.directory?(path)
|
|
File.write(path, generate_package)
|
|
path
|
|
end
|
|
|
|
def inspect_metadata = puts(JSON.pretty_generate(@metadata.to_yaml_hash))
|
|
|
|
def generate_package
|
|
@metadata.url = url.to_s unless url.nil?
|
|
# The single choke point for the rule that a checksum is *derived*, never
|
|
# adopted or preserved. Every write goes through here, so every file we
|
|
# produce verifies against itself no matter which path built the package —
|
|
# including one built from an upstream block whose author edited the file
|
|
# without re-running the tool.
|
|
@metadata.payload_checksum = payload_digest
|
|
block = generate_metadata
|
|
|
|
if METADATA_PATTERN.match?(@content)
|
|
@content.sub(METADATA_PATTERN, "\n#{block}")
|
|
else
|
|
[@content.rstrip, "\n#{block}"].join("\n")
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def recorded_checksum? = !(payload_checksum.nil? || payload_checksum.empty?)
|
|
|
|
# This will need a comment style one day, to work with other languages.
|
|
def generate_metadata
|
|
commented = @metadata.to_yaml_hash.to_yaml.strip.each_line.map { |line| "# #{line.chomp}".rstrip }
|
|
["# @PICOPACKAGE_START", *commented, "# @PICOPACKAGE_END", ""].join("\n")
|
|
end
|
|
end
|
|
end
|