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
This commit is contained in:
co-authored by
Claude Fable 5
parent
e0cd0f0d7a
commit
6dd57e84f1
+10
@@ -6,3 +6,13 @@
|
|||||||
/pkg/
|
/pkg/
|
||||||
/spec/reports/
|
/spec/reports/
|
||||||
/tmp/
|
/tmp/
|
||||||
|
*.gem
|
||||||
|
|
||||||
|
# Editors and OS
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Scratch: picopackages installed while trying the tool out on this repo
|
||||||
|
*.bkup
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# standardrb reads this, not .rubocop.yml — keep the two in step.
|
||||||
|
ruby_version: 3.1
|
||||||
|
|
||||||
|
ignore:
|
||||||
|
# Fixtures are deliberately malformed or copied verbatim from elsewhere;
|
||||||
|
# linting them defeats the point of having them.
|
||||||
|
- "test/files/**/*"
|
||||||
|
# The pre-refactor monolith, kept only for reference.
|
||||||
|
- "exe/pppkg"
|
||||||
@@ -1,5 +1,55 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Three-way merge on update, so a locally edited picopackage can still take
|
||||||
|
upstream changes. Conflicts are written to `<file>.picopackage-merge` and the
|
||||||
|
original file is never touched.
|
||||||
|
- `base_checksum` metadata: the upstream payload a file was last reconciled
|
||||||
|
with. Doubles as the key into a content-addressed merge-base cache under
|
||||||
|
`$XDG_CACHE_HOME/picopackage` (`PICOPACKAGE_CACHE` overrides).
|
||||||
|
- Unrecognised metadata keys are preserved across rewrites instead of being
|
||||||
|
dropped.
|
||||||
|
- An upstream metadata block is explicitly optional. Any plain file is a valid
|
||||||
|
source; a block lets a publisher assert a canonical `url`, `filename`,
|
||||||
|
`payload_version` and licence, which are adopted as given.
|
||||||
|
- `ppkg verify`, `ppkg package --url`, `ppkg scan` output with urls, and
|
||||||
|
`ppkg help`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Resolution is decided by checksum, never by mtime. A file whose payload
|
||||||
|
matches upstream is up to date however the timestamps compare.
|
||||||
|
- Merges run on the payload only; the metadata block is regenerated afterwards
|
||||||
|
rather than merged.
|
||||||
|
- A locally edited file whose upstream hasn't moved reports `current` instead of
|
||||||
|
running a no-op merge. It also restores the merge base to the cache, so a
|
||||||
|
cleared cache recovers on the next `update` rather than at the next conflict.
|
||||||
|
- Fetching uses `net/http` directly instead of `URI.open`, which treats a
|
||||||
|
leading `|` as a command to run. Redirects (max 5, `http`/`https` only),
|
||||||
|
`If-None-Match`, streamed size caps and typed errors come with it.
|
||||||
|
- `update` writes back to the local filename rather than upstream's preferred
|
||||||
|
one, so a renamed package doesn't install a second copy.
|
||||||
|
- Timestamps are ISO 8601 from every provider.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Checksums are computed over the normalised payload everywhere. Written
|
||||||
|
packages previously failed their own verification, so every file looked
|
||||||
|
locally modified.
|
||||||
|
- Provider selection no longer aborts when a speculative provider guesses wrong,
|
||||||
|
so plain URLs reach `DefaultProvider`. A 404 now reports as a 404 rather than
|
||||||
|
"no provider could handle this".
|
||||||
|
- `Scanner` returns usable results and survives binary, unreadable and oversized
|
||||||
|
files.
|
||||||
|
- `ppkg verify` reads the checksum it actually writes; `ppkg package` saves.
|
||||||
|
- A metadata block containing only unmodelled keys is no longer treated as a
|
||||||
|
bare file and overwritten.
|
||||||
|
- Checksums are derived at write time, never adopted from an input. Installing
|
||||||
|
from an upstream whose block carried a stale `payload_checksum` produced a
|
||||||
|
file that failed its own verification and reported as locally modified before
|
||||||
|
anyone had edited it.
|
||||||
|
|
||||||
## [0.2.0] - 2025-01-21
|
## [0.2.0] - 2025-01-21
|
||||||
|
|
||||||
- Rename to from Picop to Picopackage
|
- Rename to from Picop to Picopackage
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A Ruby gem (`picopackage`, CLI `ppkg`) for installing and updating single-file packages: ordinary source files that carry their own provenance in a commented YAML block at the end (`# @PICOPACKAGE_START` … `# @PICOPACKAGE_END`). See README.md for the format and notes.md for the resolution rules and open design questions.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/setup # install dependencies
|
||||||
|
rake # tests + StandardRB (the default task)
|
||||||
|
rake test # tests only
|
||||||
|
rake test N=/pattern/ # run tests whose names match a pattern
|
||||||
|
ruby -Itest test/test_merge.rb # run a single test file
|
||||||
|
bundle exec standardrb # lint (config in .standard.yml, not .rubocop.yml)
|
||||||
|
bundle exec standardrb --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
Releasing: bump `lib/picopackage/version.rb`, then `bundle exec rake release`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Everything lives under `lib/picopackage/`; `lib/picopackage.rb` just requires the pieces and defines the shared error classes.
|
||||||
|
|
||||||
|
The flow of an `install`/`update` is: **CLI → Fetch.fetch → Provider → Resolver → (Merge, Cache) → Package#save**.
|
||||||
|
|
||||||
|
- **package.rb** — the data model. `Package` wraps a file's content and splits it into `Payload` (the code, normalized to `rstrip + "\n\n"`) and `Metadata` (a Struct parsed from the YAML block). `Metadata#extra` round-trips unknown keys so old tools can't corrupt packages written by newer ones. Key distinction: `modified?` (payload differs from its recorded `payload_checksum` — a human edited it) vs `diverged?` (payload differs from `base_checksum`, the upstream ancestor — true after a merge even when `modified?` is false); `diverged?` is what decides whether a fast-forward is safe.
|
||||||
|
- **provider.rb** — resolves a URL to a fetch strategy. `Provider.for` walks `PROVIDERS` (FileProvider, GithubGistProvider, OpenGistProvider, DefaultProvider); `handles_url?` returns true/false/`:maybe`, and `:maybe` providers prove themselves by fetching (they fetch in their constructor). Upstream metadata *claims* (url, version, licence) are adopted with `||=`; *records* (checksums) are always recomputed from the payload actually received.
|
||||||
|
- **fetch.rb** — two things: the HTTP fetcher (`Fetch`: 1 MB cap, 10s timeout, ≤5 redirects to http/https only, ETag support; deliberately `Net::HTTP`, never `URI.open`) and the `Resolver`, which contains all install/update decision logic and returns a `Result` with a state (`installed/adopted/current/updated/merged/conflict`) — deciding what to do about a conflict is the CLI's job.
|
||||||
|
- **merge.rb** — three-way merge shelled out to `git merge-file` or `diff3`; no Ruby reimplementation.
|
||||||
|
- **cache.rb** — content-addressed store of upstream payloads under `$XDG_CACHE_HOME/picopackage` (override: `PICOPACKAGE_CACHE`), keyed by checksum. This is where merge bases come from; a cache miss means the merge is refused, not guessed.
|
||||||
|
- **cli.rb** — argument parsing and reporting only; commands are `install update package verify inspect scan`.
|
||||||
|
|
||||||
|
## Invariants (violating these is a bug, not a style choice)
|
||||||
|
|
||||||
|
1. **The checksum decides, never the timestamp.** mtime is not evidence about content; equal payload checksums settle the content question regardless of timestamps.
|
||||||
|
2. **Merges run on the payload alone; the metadata block is derived, never merged.** It is regenerated on every write — `Package#generate_package` is the single choke point where `payload_checksum` is computed, so a stale checksum can never survive into a written file.
|
||||||
|
3. **Conflicts are non-destructive.** The user's file is never touched; conflict markers go to a sibling `<file>.picopackage-merge`.
|
||||||
|
4. A bare local file whose payload differs from upstream is an *unrelated file*, not a modified package — refuse, don't merge.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Minitest, in `test/test_*.rb`. `test/test_helper.rb` provides `PicopackageTest#with_dirs`, which gives every test its own upstream dir, project dir, and cache root (the cache is global state; never share one across tests). Fake an upstream by writing a real file and fetching it through the real `FileProvider` (`publish` + `upstream_package` helpers) rather than stubbing. Fixtures in `test/files/` are deliberately malformed or copied verbatim and are excluded from linting.
|
||||||
+3
-11
@@ -1,9 +1,8 @@
|
|||||||
PATH
|
PATH
|
||||||
remote: .
|
remote: .
|
||||||
specs:
|
specs:
|
||||||
picopackage (0.2.1)
|
picopackage (0.3.0)
|
||||||
digest
|
digest
|
||||||
open-uri (~> 0.5)
|
|
||||||
yaml (~> 0.4)
|
yaml (~> 0.4)
|
||||||
|
|
||||||
GEM
|
GEM
|
||||||
@@ -23,11 +22,7 @@ GEM
|
|||||||
json (2.9.1)
|
json (2.9.1)
|
||||||
language_server-protocol (3.17.0.3)
|
language_server-protocol (3.17.0.3)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
minitest (5.25.4)
|
minitest (5.27.0)
|
||||||
open-uri (0.5.0)
|
|
||||||
stringio
|
|
||||||
time
|
|
||||||
uri
|
|
||||||
parallel (1.26.3)
|
parallel (1.26.3)
|
||||||
parser (3.3.7.0)
|
parser (3.3.7.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
@@ -75,12 +70,9 @@ GEM
|
|||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop-performance (~> 1.23.0)
|
rubocop-performance (~> 1.23.0)
|
||||||
stringio (3.1.2)
|
stringio (3.1.2)
|
||||||
time (0.4.1)
|
|
||||||
date
|
|
||||||
unicode-display_width (3.1.4)
|
unicode-display_width (3.1.4)
|
||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.2.0)
|
||||||
uri (1.0.2)
|
|
||||||
yaml (0.4.0)
|
yaml (0.4.0)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# Picopackage
|
# Picopackage
|
||||||
|
|
||||||
A command line tool for installing and managing [Picopackages](https://picopackage.org).
|
A command line tool for installing and updating [picopackages](https://picopackage.org):
|
||||||
|
single files that carry their own provenance, so you can install one into your
|
||||||
|
project, edit it, and still pull upstream fixes later.
|
||||||
|
|
||||||
|
For code that's too small to be a gem but copied often enough that pasting it
|
||||||
|
between projects hurts.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -10,18 +15,121 @@ gem install picopackage
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
`picopackage install <url|filepath>`
|
```bash
|
||||||
|
ppkg install https://gist.github.com/you/abc123 # fetch into .
|
||||||
|
ppkg install https://example.com/retry.rb lib/ # fetch into lib/
|
||||||
|
ppkg update lib/retry.rb # re-fetch from its recorded url
|
||||||
|
ppkg package lib/mine.rb --url https://… # turn a local file into a picopackage
|
||||||
|
ppkg verify lib/retry.rb # check it against its checksum
|
||||||
|
ppkg scan . # list picopackages in a tree
|
||||||
|
ppkg inspect lib/retry.rb # print its metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
## What a picopackage looks like
|
||||||
|
|
||||||
|
An ordinary source file with a commented YAML block at the end:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
def retry_with_backoff(attempts: 3)
|
||||||
|
# …
|
||||||
|
end
|
||||||
|
|
||||||
|
# @PICOPACKAGE_START
|
||||||
|
# ---
|
||||||
|
# url: https://gist.github.com/you/abc123
|
||||||
|
# filename: retry.rb
|
||||||
|
# payload_timestamp: '2026-08-12T14:06:38Z'
|
||||||
|
# payload_checksum: sha256:7b856b1b…
|
||||||
|
# base_checksum: sha256:7b856b1b…
|
||||||
|
# @PICOPACKAGE_END
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything above the block is the **payload**. The block is regenerated on every
|
||||||
|
write. Metadata keys the tool doesn't recognise are preserved as-is, so optional
|
||||||
|
fields survive a tool that predates them.
|
||||||
|
|
||||||
|
## Upstream doesn't need a block
|
||||||
|
|
||||||
|
A plain file with no metadata at all is a valid picopackage source. You can
|
||||||
|
install someone's gist, blog snippet or raw file without them adopting anything
|
||||||
|
— everything the block would have said is derived from the fetch.
|
||||||
|
|
||||||
|
When upstream *does* carry a block, it's opting in to extra guarantees, and only
|
||||||
|
half of it is upstream's to assert:
|
||||||
|
|
||||||
|
| | Fields | Who decides |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Claims** | `url`, `filename`, `payload_version`, `licence` | upstream — adopted as given |
|
||||||
|
| **Records** | `payload_checksum`, `base_checksum` | your machine — always recomputed |
|
||||||
|
|
||||||
|
A claim is something only the author knows: the canonical URL that outlives the
|
||||||
|
mirror you fetched from, the version, the licence. A record describes one copy on
|
||||||
|
one disk, so upstream can't speak for yours. In particular an upstream
|
||||||
|
`payload_checksum` is never trusted — a checksum stored beside the content it
|
||||||
|
hashes proves nothing, and an author who edits without re-running the tool ships
|
||||||
|
a stale one. Adopting it would install a file that failed its own verification
|
||||||
|
and reported as locally modified before you touched it.
|
||||||
|
|
||||||
|
## Updating a file you've edited
|
||||||
|
|
||||||
|
The point of a picopackage is that it's *your* file — so editing it has to be
|
||||||
|
allowed, and updates have to cope with that. `ppkg update` does a three-way
|
||||||
|
merge against the version you originally installed:
|
||||||
|
|
||||||
|
- **Unchanged locally** → fast-forwarded to upstream.
|
||||||
|
- **Edited locally, upstream unchanged** → nothing happens. No merge is
|
||||||
|
attempted; upstream is still the version you branched from.
|
||||||
|
- **Edited locally, no overlap with upstream's changes** → merged, keeping both.
|
||||||
|
- **Edited locally, overlapping changes** → refused. Your file is left exactly
|
||||||
|
as it is and a copy with conflict markers is written to
|
||||||
|
`<file>.picopackage-merge` for you to work from.
|
||||||
|
- `--force` discards local changes and takes upstream wholesale.
|
||||||
|
|
||||||
|
Two rules decide every case:
|
||||||
|
|
||||||
|
1. **The checksum decides, never the timestamp.** An mtime says nothing about
|
||||||
|
content — a file re-uploaded unchanged gets a fresh one.
|
||||||
|
2. **Merges run on the payload alone.** The metadata block is regenerated
|
||||||
|
afterwards, so it never conflicts.
|
||||||
|
|
||||||
|
The merge needs the common ancestor, which is kept in a content-addressed cache
|
||||||
|
under `$XDG_CACHE_HOME/picopackage` (override with `PICOPACKAGE_CACHE`). A file
|
||||||
|
installed on another machine, or one whose cache has been cleared, has no
|
||||||
|
ancestor available — `ppkg` says so and refuses rather than guessing.
|
||||||
|
|
||||||
|
`git merge-file` is used when available, `diff3` otherwise.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
| Source | Handling |
|
||||||
|
| --- | --- |
|
||||||
|
| GitHub Gist | Resolved through the Gist API |
|
||||||
|
| Opengist | Resolved via its `.json` endpoint |
|
||||||
|
| Any URL | Fetched directly |
|
||||||
|
| Local path | Read from disk |
|
||||||
|
|
||||||
|
Responses are capped at 1 MB and time out after 10s. Redirects are followed up
|
||||||
|
to 5 times and only to `http`/`https`.
|
||||||
|
|
||||||
|
## What it deliberately doesn't do
|
||||||
|
|
||||||
|
- **No dependencies.** Each file stands alone. There is no solver, no version
|
||||||
|
ranges, no transitive graph.
|
||||||
|
- **No registry.** The file's `url` is its identity; no central index exists.
|
||||||
|
- **No author verification.** A checksum stored beside the content it hashes
|
||||||
|
proves nothing about who wrote it — its job is detecting *local* modification.
|
||||||
|
Signing (SSH signatures, TOFU-pinned — see notes.md) is designed, not
|
||||||
|
implemented. Until then, the security model is that a picopackage is one
|
||||||
|
file and you can read it.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
`bin/setup` installs dependencies, `rake test` runs the tests, `rake` runs tests
|
||||||
|
and [Standard](https://github.com/standardrb/standard). `bin/console` gives you a
|
||||||
|
prompt.
|
||||||
|
|
||||||
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
To release: update `lib/picopackage/version.rb`, then `bundle exec rake release`.
|
||||||
|
|
||||||
## Contributing
|
## Licence
|
||||||
|
|
||||||
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/picop.
|
MIT.
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
||||||
|
|||||||
+6
-4
@@ -1,18 +1,20 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require_relative "picopackage/version"
|
require_relative "picopackage/version"
|
||||||
|
require_relative "picopackage/package"
|
||||||
|
require_relative "picopackage/cache"
|
||||||
|
require_relative "picopackage/merge"
|
||||||
require_relative "picopackage/fetch"
|
require_relative "picopackage/fetch"
|
||||||
require_relative "picopackage/provider"
|
require_relative "picopackage/provider"
|
||||||
require_relative "picopackage/package"
|
|
||||||
require_relative "picopackage/scanner"
|
require_relative "picopackage/scanner"
|
||||||
require_relative "picopackage/cli"
|
require_relative "picopackage/cli"
|
||||||
|
|
||||||
module Picopackage
|
module Picopackage
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
class FileTooLargeError < StandardError; end
|
class FileTooLargeError < Error; end
|
||||||
|
|
||||||
class FetchError < StandardError; end
|
class FetchError < Error; end
|
||||||
|
|
||||||
class LocalModificationError < StandardError; end
|
class LocalModificationError < Error; end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "fileutils"
|
||||||
|
|
||||||
|
module Picopackage
|
||||||
|
# Content-addressed store of upstream payloads, kept so that updates have a
|
||||||
|
# merge base to work from.
|
||||||
|
#
|
||||||
|
# A three-way merge needs the common ancestor: the payload exactly as upstream
|
||||||
|
# last sent it. Metadata already records that payload's checksum in
|
||||||
|
# `base_checksum`, so the checksum doubles as the cache key and a package
|
||||||
|
# carries the pointer to its own ancestor.
|
||||||
|
class Cache
|
||||||
|
def self.default_root
|
||||||
|
ENV["PICOPACKAGE_CACHE"] ||
|
||||||
|
File.join(ENV["XDG_CACHE_HOME"] || File.join(Dir.home, ".cache"), "picopackage")
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :root
|
||||||
|
|
||||||
|
def initialize(root: self.class.default_root)
|
||||||
|
@root = root
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the checksum the payload was stored under, for recording as a
|
||||||
|
# `base_checksum`.
|
||||||
|
def store(payload)
|
||||||
|
Payload.checksum(payload).tap do |checksum|
|
||||||
|
path = path_for(checksum)
|
||||||
|
next if File.exist?(path)
|
||||||
|
|
||||||
|
FileUtils.mkdir_p(File.dirname(path))
|
||||||
|
# Written via a sibling tempfile and renamed so a concurrent reader
|
||||||
|
# never sees a half-written ancestor.
|
||||||
|
tmp = "#{path}.#{Process.pid}.tmp"
|
||||||
|
File.write(tmp, payload)
|
||||||
|
File.rename(tmp, path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch(checksum)
|
||||||
|
path = path_for(checksum)
|
||||||
|
File.read(path) if path && File.exist?(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def include?(checksum) = !fetch(checksum).nil?
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# "sha256:abcdef…" => <root>/sha256/ab/cdef…
|
||||||
|
def path_for(checksum)
|
||||||
|
return nil if checksum.nil? || checksum.empty?
|
||||||
|
|
||||||
|
algorithm, digest = checksum.split(":", 2)
|
||||||
|
return nil if digest.nil? || digest.length < 3 || !digest.match?(/\A[a-f0-9]+\z/i)
|
||||||
|
|
||||||
|
File.join(root, algorithm, digest[0, 2], digest[2..])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+110
-104
@@ -1,140 +1,146 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "optparse"
|
require "optparse"
|
||||||
|
|
||||||
module Picopackage
|
module Picopackage
|
||||||
class CLI
|
class CLI
|
||||||
|
COMMANDS = %w[install update package verify inspect scan].freeze
|
||||||
|
|
||||||
def self.run(argv = ARGV)
|
def self.run(argv = ARGV)
|
||||||
command = argv.shift
|
command = argv.shift
|
||||||
|
|
||||||
case command
|
case command
|
||||||
when "scan"
|
when "install"
|
||||||
options = {}
|
|
||||||
OptionParser.new do |opts|
|
|
||||||
opts.banner = "Usage: ppkg scan [options] DIRECTORY"
|
|
||||||
# opts.on('-v', '--verbose', 'Run verbosely') { |v| options[:verbose] = v }
|
|
||||||
end.parse!(argv)
|
|
||||||
|
|
||||||
dir = argv.first || "."
|
|
||||||
Picopackage::Scanner.scan(dir).each { |f| puts f.file_path }
|
|
||||||
|
|
||||||
when "init"
|
|
||||||
OptionParser.new do |opts|
|
|
||||||
opts.banner = "Usage: ppkg init FILE"
|
|
||||||
end.parse!(argv)
|
|
||||||
|
|
||||||
file = argv.first
|
|
||||||
Picopackage::Package.from_file(file).init_metadata
|
|
||||||
|
|
||||||
when "checksum"
|
|
||||||
OptionParser.new do |opts|
|
|
||||||
opts.banner = "Usage: ppkg checksum FILE"
|
|
||||||
end.parse!(argv)
|
|
||||||
file = argv.first
|
|
||||||
puts Picopackage::Package.from_file(file).checksum
|
|
||||||
|
|
||||||
when "verify"
|
|
||||||
OptionParser.new do |opts|
|
|
||||||
opts.banner = "Usage: ppkg sign FILE"
|
|
||||||
end.parse!(argv)
|
|
||||||
|
|
||||||
path = argv.first
|
|
||||||
source = Package.from_file(path)
|
|
||||||
|
|
||||||
if source.metadata["content_checksum"].nil?
|
|
||||||
puts "⚠️ No checksum found in #{path}"
|
|
||||||
puts "Run 'ppkg sign #{path}' to add one"
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
|
|
||||||
unless source.verify_payload
|
|
||||||
puts "❌ Checksum verification failed for #{path}"
|
|
||||||
puts "Expected: #{source.metadata["content_checksum"]}"
|
|
||||||
puts "Got: #{source.checksum}"
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
|
|
||||||
puts "✅ #{path} verified successfully"
|
|
||||||
|
|
||||||
when "inspect"
|
|
||||||
OptionParser.new do |opts|
|
|
||||||
opts.banner = "Usage: ppkg inspect FILE|DIRECTORY"
|
|
||||||
end.parse!(argv)
|
|
||||||
|
|
||||||
path = argv.first
|
|
||||||
Picopackage::Package.from_file(path).inspect_metadata
|
|
||||||
|
|
||||||
when "fetch"
|
|
||||||
options = {force: false}
|
options = {force: false}
|
||||||
OptionParser.new do |opts|
|
OptionParser.new do |opts|
|
||||||
opts.banner = "Usage: ppkg fetch [options] URI [PATH]"
|
opts.banner = "Usage: ppkg install [options] URL [DIRECTORY]"
|
||||||
opts.on("-f", "--force", "Force fetch") { |f| options[:force] = f }
|
opts.on("-f", "--force", "Overwrite local changes") { options[:force] = true }
|
||||||
end.parse!(argv)
|
end.parse!(argv)
|
||||||
|
|
||||||
url = argv.shift
|
url = argv.shift
|
||||||
path = argv.shift || "." # use '.' if no path provided
|
destination = argv.shift || "."
|
||||||
|
abort "Error: URL is required" if url.nil?
|
||||||
|
|
||||||
if url.nil?
|
report Fetch.fetch(url, destination, force: options[:force])
|
||||||
puts "Error: URI is required"
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
Fetch.fetch(url, path, force: options[:force])
|
|
||||||
rescue LocalModificationError => e
|
|
||||||
puts "Error: #{e.message}"
|
|
||||||
rescue => e
|
|
||||||
puts "Error: #{e.message}"
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
|
|
||||||
when "update"
|
when "update"
|
||||||
options = {force: false}
|
options = {force: false}
|
||||||
OptionParser.new do |opts|
|
OptionParser.new do |opts|
|
||||||
opts.banner = "Usage: ppkg update [options] FILE"
|
opts.banner = "Usage: ppkg update [options] FILE"
|
||||||
opts.on("-f", "--force", "Force update") { |f| options[:force] = f }
|
opts.on("-f", "--force", "Discard local changes") { options[:force] = true }
|
||||||
end.parse!(argv)
|
end.parse!(argv)
|
||||||
|
|
||||||
file = argv.first
|
path = argv.shift
|
||||||
package = Package.from_file(file)
|
abort "Error: FILE is required" if path.nil?
|
||||||
begin
|
|
||||||
Fetch.fetch(package.url, File.dirname(file), force: options[:force])
|
package = load_package(path)
|
||||||
rescue LocalModificationError => e
|
abort "Error: #{path} has no url in its metadata, so there is nothing to update from" if package.url.nil? || package.url.empty?
|
||||||
puts "Error: #{e.message}"
|
|
||||||
rescue => e
|
# Updates write back to the file the user named, not to whatever
|
||||||
puts "Error: #{e.message}"
|
# filename upstream would prefer — renaming a package locally is
|
||||||
|
# allowed and shouldn't silently install a second copy.
|
||||||
|
report Fetch.fetch(package.url, File.dirname(path), force: options[:force], filename: File.basename(path))
|
||||||
|
|
||||||
|
when "package", "init"
|
||||||
|
options = {}
|
||||||
|
OptionParser.new do |opts|
|
||||||
|
opts.banner = "Usage: ppkg package [--url URL] FILE"
|
||||||
|
opts.on("--url URL", "Where updates should be fetched from") { |url| options[:url] = url }
|
||||||
|
end.parse!(argv)
|
||||||
|
|
||||||
|
path = argv.shift
|
||||||
|
abort "Error: FILE is required" if path.nil?
|
||||||
|
|
||||||
|
package = load_package(path)
|
||||||
|
was_bare = package.bare?
|
||||||
|
package.init_metadata(filename: File.basename(path), url: options[:url])
|
||||||
|
package.save(path)
|
||||||
|
Cache.new.store(package.payload)
|
||||||
|
|
||||||
|
puts was_bare ? "Packaged #{path}" : "Updated metadata in #{path}"
|
||||||
|
warn "Note: no url recorded — 'ppkg update' won't work until one is set with --url" if package.url.nil?
|
||||||
|
|
||||||
|
when "verify"
|
||||||
|
OptionParser.new { |opts| opts.banner = "Usage: ppkg verify FILE" }.parse!(argv)
|
||||||
|
|
||||||
|
path = argv.shift
|
||||||
|
abort "Error: FILE is required" if path.nil?
|
||||||
|
|
||||||
|
package = load_package(path)
|
||||||
|
|
||||||
|
if package.payload_checksum.nil?
|
||||||
|
puts "⚠️ No checksum found in #{path}"
|
||||||
|
puts "Run 'ppkg package #{path}' to add one"
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
unless package.verify_payload
|
||||||
|
puts "❌ #{path} does not match its recorded checksum"
|
||||||
|
puts "Recorded: #{package.payload_checksum}"
|
||||||
|
puts "Actual: #{package.payload_digest}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "✅ #{path} matches its recorded checksum"
|
||||||
|
|
||||||
|
when "inspect"
|
||||||
|
OptionParser.new { |opts| opts.banner = "Usage: ppkg inspect FILE" }.parse!(argv)
|
||||||
|
|
||||||
|
path = argv.shift
|
||||||
|
abort "Error: FILE is required" if path.nil?
|
||||||
|
load_package(path).inspect_metadata
|
||||||
|
|
||||||
|
when "scan"
|
||||||
|
OptionParser.new { |opts| opts.banner = "Usage: ppkg scan [DIRECTORY]" }.parse!(argv)
|
||||||
|
|
||||||
|
found = Scanner.scan(argv.shift || ".")
|
||||||
|
found.each { |entry| puts [entry.path, entry.package.url].compact.join("\t") }
|
||||||
|
puts "No picopackages found" if found.empty?
|
||||||
|
|
||||||
|
when "--version", "-v", "version"
|
||||||
|
puts Picopackage::VERSION
|
||||||
|
|
||||||
|
when nil, "help", "--help", "-h"
|
||||||
|
usage
|
||||||
|
exit(command.nil? ? 1 : 0)
|
||||||
|
|
||||||
else
|
else
|
||||||
puts "Unknown command: #{command}"
|
warn "Unknown command: #{command}"
|
||||||
puts "Available commands: fetch, update, scan, sign, inspect"
|
usage
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
rescue OptionParser::InvalidOption => e
|
rescue OptionParser::ParseError => e
|
||||||
puts e.message
|
abort e.message
|
||||||
exit 1
|
rescue FetchError, Fetch::Error, ArgumentError => e
|
||||||
rescue => e
|
warn "Error: #{e.message}"
|
||||||
puts "Error: #{e.message}"
|
warn e.backtrace if ENV["DEBUG"]
|
||||||
puts e.backtrace if ENV["DEBUG"]
|
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.determine_script_source
|
def self.usage
|
||||||
# Get the full path of the currently executing script
|
puts <<~TEXT
|
||||||
current_path = File.expand_path($0)
|
Usage: ppkg <command> [options]
|
||||||
|
|
||||||
# Check if script is in GEM_PATH
|
Commands:
|
||||||
gem_paths = Gem.path.map { |p| File.expand_path(p) }
|
install URL [DIR] Fetch a picopackage into DIR (default: .)
|
||||||
|
update FILE Re-fetch FILE from the url in its metadata
|
||||||
|
package FILE Add a metadata block to a local file
|
||||||
|
verify FILE Check FILE against its recorded checksum
|
||||||
|
inspect FILE Print FILE's metadata as JSON
|
||||||
|
scan [DIR] List picopackages under DIR
|
||||||
|
|
||||||
is_gem = gem_paths.any? { |path| current_path.start_with?(path) }
|
Run 'ppkg <command> --help' for command options.
|
||||||
|
TEXT
|
||||||
|
end
|
||||||
|
|
||||||
if is_gem
|
def self.load_package(path)
|
||||||
# Running from gem installation
|
abort "Error: no such file: #{path}" unless File.file?(path)
|
||||||
gem_name = File.basename(File.dirname(File.dirname(current_path)))
|
Package.from_file(path) or abort "Error: could not read #{path}"
|
||||||
version = File.basename(File.dirname(current_path))
|
|
||||||
{source: :gem, path: current_path, gem_name: gem_name, version: version}
|
|
||||||
else
|
|
||||||
# Running from local installation
|
|
||||||
{source: :local, path: current_path}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.report(result)
|
||||||
|
puts result.message
|
||||||
|
exit 1 if result.conflict?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+205
-95
@@ -1,168 +1,278 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "net/http"
|
require "net/http"
|
||||||
|
require "openssl"
|
||||||
require "fileutils"
|
require "fileutils"
|
||||||
require "tempfile"
|
|
||||||
require "json"
|
require "json"
|
||||||
require "debug"
|
|
||||||
|
|
||||||
module Picopackage
|
module Picopackage
|
||||||
class Fetch
|
class Fetch
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
class HTTPError < Error; end
|
|
||||||
class FileTooLargeError < Error; end
|
|
||||||
class NotModifiedError < Error; end # Add this
|
|
||||||
class TooManyRedirectsError < Error; end # Add this
|
|
||||||
|
|
||||||
MAX_REDIRECTS = 5 # This constant is used but not defined
|
class HTTPError < Error; end
|
||||||
|
|
||||||
|
class FileTooLargeError < Error; end
|
||||||
|
|
||||||
|
class NotModifiedError < Error; end
|
||||||
|
|
||||||
|
class TooManyRedirectsError < Error; end
|
||||||
|
|
||||||
|
MAX_REDIRECTS = 5
|
||||||
|
USER_AGENT = "picopackage/#{Picopackage::VERSION}"
|
||||||
|
SCHEMES = %w[http https].freeze
|
||||||
|
|
||||||
def initialize(max_size: 1024 * 1024, timeout: 10)
|
def initialize(max_size: 1024 * 1024, timeout: 10)
|
||||||
@max_size = max_size
|
@max_size = max_size
|
||||||
@timeout = timeout
|
@timeout = timeout
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch(uri)
|
def fetch(uri, etag: nil)
|
||||||
|
uri = URI.parse(uri.to_s) unless uri.is_a?(URI)
|
||||||
case uri.scheme
|
case uri.scheme
|
||||||
when "http", "https" then fetch_http(uri)
|
when "http", "https" then fetch_http(uri, etag: etag)
|
||||||
when "file" then fetch_file(uri)
|
when "file" then fetch_file(uri)
|
||||||
else
|
else
|
||||||
raise Error, "Unsupported scheme: #{uri.scheme}"
|
raise Error, "Unsupported scheme: #{uri.scheme.inspect}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.fetch(url, destination, force: false)
|
# Fetches a URL and reconciles it with whatever is already on disk.
|
||||||
|
# Returns a Resolver::Result; deciding what to do about a conflict is the
|
||||||
|
# caller's business, not ours.
|
||||||
|
def self.fetch(url, destination, force: false, filename: nil, cache: Cache.new)
|
||||||
raise ArgumentError, "Destination directory does not exist: #{destination}" unless Dir.exist?(destination)
|
raise ArgumentError, "Destination directory does not exist: #{destination}" unless Dir.exist?(destination)
|
||||||
|
|
||||||
provider = Provider.for(url)
|
provider = Provider.for(url)
|
||||||
package = provider.package
|
raise FetchError, "No provider could handle #{url}" if provider.nil?
|
||||||
file_path = File.join(destination, package.filename)
|
|
||||||
|
|
||||||
local_package = File.exist?(file_path) ? FileProvider.new(file_path).package : nil
|
remote = provider.package
|
||||||
|
target = filename || remote.filename
|
||||||
|
raise FetchError, "Could not determine a filename for #{url}" if target.nil?
|
||||||
|
|
||||||
resolver = Resolver.new(package, local_package, file_path, force: force).resolve
|
file_path = File.join(destination, File.basename(target))
|
||||||
|
local = Package.from_file(file_path)
|
||||||
|
|
||||||
case resolver[:state]
|
Resolver.new(remote, local, file_path, force: force, cache: cache).resolve
|
||||||
when :kept, :updated
|
|
||||||
puts resolver[:message]
|
|
||||||
when :conflict
|
|
||||||
raise LocalModificationError, resolver[:message]
|
|
||||||
end
|
|
||||||
provider.package
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fetch_http(uri, etag = nil)
|
# Net::HTTP rather than URI.open, deliberately. This method's whole job is to
|
||||||
Net::HTTP.start(uri.host, uri.port, connection_options(uri)) do |http|
|
# dereference a URL a user handed us, and URI.open treats a leading "|" as a
|
||||||
request = Net::HTTP::Get.new(uri.request_uri)
|
# command to run. The scheme check in #fetch already blocks that, but a
|
||||||
|
# remote-code-fetching tool should not be one refactor away from shelling
|
||||||
|
# out, and doing it by hand is also what makes redirect and 304 handling
|
||||||
|
# possible.
|
||||||
|
def fetch_http(uri, etag: nil, redirects: MAX_REDIRECTS)
|
||||||
|
raise TooManyRedirectsError, "More than #{MAX_REDIRECTS} redirects from #{uri}" if redirects.negative?
|
||||||
|
|
||||||
|
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
||||||
|
open_timeout: @timeout, read_timeout: @timeout) do |http|
|
||||||
|
request = Net::HTTP::Get.new(uri)
|
||||||
|
request["User-Agent"] = USER_AGENT
|
||||||
|
request["Accept"] = "*/*"
|
||||||
request["If-None-Match"] = etag if etag
|
request["If-None-Match"] = etag if etag
|
||||||
|
|
||||||
response = http.request(request)
|
http.request(request) do |response|
|
||||||
handle_response(response, uri)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_file(uri)
|
|
||||||
File.read(uri.path)
|
|
||||||
end
|
|
||||||
|
|
||||||
def connection_options(uri)
|
|
||||||
{
|
|
||||||
use_ssl: uri.scheme == "https",
|
|
||||||
read_timeout: @timeout,
|
|
||||||
open_timeout: @timeout
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_response(response, uri)
|
|
||||||
case response
|
case response
|
||||||
when Net::HTTPSuccess
|
|
||||||
{
|
|
||||||
body: read_body(response),
|
|
||||||
etag: response["ETag"]
|
|
||||||
}
|
|
||||||
when Net::HTTPNotModified
|
when Net::HTTPNotModified
|
||||||
raise NotModifiedError.new("Resource not modified", etag: response["ETag"])
|
raise NotModifiedError, "#{uri} is unchanged"
|
||||||
when Net::HTTPRedirection
|
when Net::HTTPRedirection
|
||||||
handle_redirect(response, uri)
|
return fetch_http(redirect_target(uri, response), etag: etag, redirects: redirects - 1)
|
||||||
|
when Net::HTTPSuccess
|
||||||
|
return read_capped(response)
|
||||||
else
|
else
|
||||||
raise HTTPError, "HTTP #{response.code}: #{response.message}"
|
raise HTTPError, "#{response.code} #{response.message} for #{uri}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
rescue Net::OpenTimeout, Net::ReadTimeout
|
||||||
|
raise HTTPError, "Timed out after #{@timeout}s fetching #{uri}"
|
||||||
|
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
|
||||||
|
raise HTTPError, "Could not fetch #{uri}: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
def handle_redirect(response, uri, redirect_count = 0)
|
def redirect_target(uri, response)
|
||||||
raise TooManyRedirectsError if redirect_count >= MAX_REDIRECTS
|
|
||||||
location = response["location"]
|
location = response["location"]
|
||||||
new_uri = URI(location)
|
raise HTTPError, "Redirect from #{uri} with no Location header" if location.nil?
|
||||||
# Handle both relative paths and full URLs
|
|
||||||
new_uri = uri.merge(location) if new_uri.relative?
|
URI.join(uri, location).tap do |target|
|
||||||
fetch(new_uri, redirect_count: redirect_count + 1)
|
raise Error, "Refusing to follow a redirect to #{target.scheme.inspect}" unless SCHEMES.include?(target.scheme)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def read_body(response)
|
# Trusts Content-Length only to fail early — the streamed tally is what
|
||||||
|
# actually enforces the cap, since the header can lie or be absent.
|
||||||
|
def read_capped(response)
|
||||||
|
declared = response["content-length"]&.to_i
|
||||||
|
raise FileTooLargeError, "#{declared} bytes exceeds the #{@max_size} byte limit" if declared&.positive? && declared > @max_size
|
||||||
|
|
||||||
buffer = String.new(capacity: @max_size)
|
buffer = String.new(capacity: @max_size)
|
||||||
response.read_body do |chunk|
|
response.read_body do |chunk|
|
||||||
raise FileTooLargeError, "Response would exceed #{@max_size} bytes" if buffer.bytesize + chunk.bytesize > @max_size
|
raise FileTooLargeError, "Response exceeds the #{@max_size} byte limit" if buffer.bytesize + chunk.bytesize > @max_size
|
||||||
|
|
||||||
buffer << chunk
|
buffer << chunk
|
||||||
end
|
end
|
||||||
buffer
|
buffer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def fetch_file(uri) = File.read(uri.path)
|
||||||
end
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
|
# Decides what happens when an upstream payload meets a local file.
|
||||||
|
#
|
||||||
|
# Two rules drive every branch, and they are worth stating because they are
|
||||||
|
# what make the table in notes.md collapse to something small:
|
||||||
|
#
|
||||||
|
# 1. The checksum decides, never the timestamp. mtime is not evidence about
|
||||||
|
# content — a file re-uploaded unchanged gets a fresh one — so equal
|
||||||
|
# checksums end the content question outright, whatever the clocks say.
|
||||||
|
# 2. Merges run on the payload alone. The metadata block is derived, so
|
||||||
|
# merging it would conflict on every update; it is regenerated after.
|
||||||
|
#
|
||||||
# States:
|
# States:
|
||||||
# - kept: local file was converted to a picopackage and kept
|
# - installed: no local file existed, upstream was written
|
||||||
# - updated: local file was updated with remote picopackage
|
# - adopted: a bare local file gained metadata; its content already matched
|
||||||
# - conflict: local and remote files differ - manually resolve or use -f to force
|
# - current: nothing to do
|
||||||
|
# - updated: local payload replaced with upstream
|
||||||
|
# - merged: local edits preserved, upstream changes applied on top
|
||||||
|
# - conflict: needs a human; nothing was written to the original file
|
||||||
class Resolver
|
class Resolver
|
||||||
attr_reader :remote, :local, :local_path, :force
|
STATES = %i[installed adopted current updated merged conflict].freeze
|
||||||
def initialize(remote_package, local_package, local_path, force: false)
|
|
||||||
|
Result = Struct.new(:state, :message, :path, keyword_init: true) do
|
||||||
|
def conflict? = state == :conflict
|
||||||
|
|
||||||
|
def written? = %i[installed adopted updated merged].include?(state)
|
||||||
|
end
|
||||||
|
|
||||||
|
MERGE_SUFFIX = ".picopackage-merge"
|
||||||
|
|
||||||
|
attr_reader :remote, :local, :local_path, :force, :cache
|
||||||
|
|
||||||
|
def initialize(remote_package, local_package, local_path, force: false, cache: Cache.new)
|
||||||
@remote = remote_package
|
@remote = remote_package
|
||||||
@local = local_package
|
@local = local_package
|
||||||
@local_path = local_path
|
@local_path = local_path
|
||||||
@force = force
|
@force = force
|
||||||
@same_checksum = @remote.payload_checksum == @local&.payload_checksum
|
@cache = cache
|
||||||
end
|
end
|
||||||
|
|
||||||
STATES = %i[kept updated conflict].freeze
|
|
||||||
|
|
||||||
def resolve
|
def resolve
|
||||||
validate_state_hash(
|
validate(
|
||||||
if @force
|
if local.nil?
|
||||||
@remote.save(local_path)
|
write(:installed, "Installed #{File.basename(local_path)}")
|
||||||
{state: :updated, message: "Force mode: overwrote local file with remote package"}
|
elsif force
|
||||||
elsif @local.nil?
|
write(:updated, "Forced: overwrote #{File.basename(local_path)} with upstream")
|
||||||
@remote.save(local_path)
|
elsif same_payload?
|
||||||
{state: :kept, message: "Saved Package as new file"}
|
reconcile_metadata
|
||||||
elsif @remote.payload_version != @local.payload_version
|
elsif local.bare?
|
||||||
{state: :conflict, message: "Version conflict. Local: #{@local.payload_version}, Remote: #{@remote.payload_version}"}
|
# No metadata, no ancestor, and a different payload: this is an
|
||||||
elsif @remote.payload_timestamp_as_time > @local.payload_timestamp_as_time
|
# unrelated file that happens to share a name, not a modified
|
||||||
@remote.save(local_path)
|
# picopackage. There is no shared history to merge along.
|
||||||
{state: :updated, message: "Updated to newer version"}
|
conflict("#{File.basename(local_path)} already exists, is not a picopackage, and differs from upstream. " \
|
||||||
elsif !@same_checksum
|
"Move it aside or use --force to overwrite it.")
|
||||||
handle_checksum_mismatch
|
elsif !local.diverged?
|
||||||
elsif @local.was_bare_file
|
write(:updated, "Updated #{File.basename(local_path)} to upstream")
|
||||||
debugger
|
elsif upstream_unchanged?
|
||||||
@local.save(local_path)
|
keep_local_changes
|
||||||
{state: :kept, message: "Packaged existing file as Picopackage"}
|
|
||||||
else
|
else
|
||||||
{state: :kept, message: "Local file is up to date"}
|
merge
|
||||||
end
|
end
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def validate_state_hash(hash)
|
def same_payload? = remote.payload_digest == local.payload_digest
|
||||||
raise "Invalid state" unless STATES.include?(hash[:state])
|
|
||||||
raise "Missing message" unless hash[:message].is_a?(String)
|
# Same content on both sides, so the only open question is whose metadata
|
||||||
hash
|
# to keep. A bare local file gets adopted; a package is already fine.
|
||||||
|
def reconcile_metadata
|
||||||
|
cache.store(remote.payload)
|
||||||
|
|
||||||
|
if local.bare?
|
||||||
|
write(:adopted, "Adopted existing #{File.basename(local_path)} as a picopackage")
|
||||||
|
else
|
||||||
|
Result.new(state: :current, message: "#{File.basename(local_path)} is up to date", path: local_path)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_checksum_mismatch
|
# Reached only when the payloads differ and the local one has diverged, so
|
||||||
if @force
|
# the difference is entirely ours: upstream still holds the ancestor we
|
||||||
@remote.save(local_path) # In force mode, remote wins
|
# branched from.
|
||||||
{state: :updated, message: "Overwrote local file with remote package"}
|
def upstream_unchanged? = remote.payload_digest == local.ancestor_checksum
|
||||||
|
|
||||||
|
# Nothing upstream to apply. Merging would be a correct no-op — base and
|
||||||
|
# theirs are the same text, so the result is ours — but it spawns a merge
|
||||||
|
# tool to prove it and reports `:merged` when nothing was merged.
|
||||||
|
#
|
||||||
|
# Storing the payload is not redundant: this is the one moment we are
|
||||||
|
# holding the ancestor itself, so a cache that has been cleared gets
|
||||||
|
# repopulated and the *next* update can still merge.
|
||||||
|
def keep_local_changes
|
||||||
|
cache.store(remote.payload)
|
||||||
|
Result.new(state: :current,
|
||||||
|
message: "#{File.basename(local_path)} is up to date; your local changes are untouched",
|
||||||
|
path: local_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def merge
|
||||||
|
return conflict("#{File.basename(local_path)} has local changes and no merge tool (git or diff3) is available.") unless Merge.available?
|
||||||
|
|
||||||
|
base = cache.fetch(local.ancestor_checksum)
|
||||||
|
if base.nil?
|
||||||
|
return conflict("#{File.basename(local_path)} has local changes and its original upstream version " \
|
||||||
|
"isn't in the cache, so there is nothing to merge against. " \
|
||||||
|
"Diff it against upstream by hand, or use --force to discard your changes.")
|
||||||
|
end
|
||||||
|
|
||||||
|
upstream_digest = remote.payload_digest
|
||||||
|
cache.store(remote.payload)
|
||||||
|
result = Merge.three_way(base: base, ours: local.payload, theirs: remote.payload)
|
||||||
|
|
||||||
|
case result.status
|
||||||
|
when :clean
|
||||||
|
remote.payload = result.content
|
||||||
|
remote.base_checksum = upstream_digest
|
||||||
|
remote.save(local_path)
|
||||||
|
Result.new(state: :merged, message: "Merged upstream changes into #{File.basename(local_path)}, keeping your edits", path: local_path)
|
||||||
|
when :conflict
|
||||||
|
write_conflict_file(result)
|
||||||
else
|
else
|
||||||
{state: :conflict, message: "Files differ. Use --force to convert both to packages"}
|
conflict("Merge tool failed on #{File.basename(local_path)}.")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The original is never touched. A tool with no index and no `reset` has no
|
||||||
|
# business writing conflict markers into a file the user may not have
|
||||||
|
# committed, so the markers go to a sibling and the user drives.
|
||||||
|
def write_conflict_file(result)
|
||||||
|
path = local_path + MERGE_SUFFIX
|
||||||
|
File.write(path, result.content)
|
||||||
|
conflict("#{File.basename(local_path)} has local changes that conflict with upstream " \
|
||||||
|
"(#{result.conflicts} #{(result.conflicts == 1) ? "conflict" : "conflicts"}). " \
|
||||||
|
"Resolved-with-markers copy written to #{File.basename(path)}; your file is untouched.")
|
||||||
|
end
|
||||||
|
|
||||||
|
def write(state, message)
|
||||||
|
# Metadata describes *this* file, so the filename follows the local name
|
||||||
|
# even when it differs from upstream's. `url` is what points upstream.
|
||||||
|
remote.filename = File.basename(local_path)
|
||||||
|
remote.base_checksum = remote.payload_digest
|
||||||
|
cache.store(remote.payload)
|
||||||
|
path = remote.save(local_path)
|
||||||
|
Result.new(state: state, message: message, path: path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def conflict(message) = Result.new(state: :conflict, message: message, path: local_path)
|
||||||
|
|
||||||
|
def validate(result)
|
||||||
|
raise "Invalid state: #{result.state.inspect}" unless STATES.include?(result.state)
|
||||||
|
raise "Missing message" unless result.message.is_a?(String)
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "tempfile"
|
||||||
|
|
||||||
|
module Picopackage
|
||||||
|
# Three-way merge of payloads, delegated to `git merge-file` or POSIX `diff3`.
|
||||||
|
#
|
||||||
|
# Reimplementing diff3 in Ruby would be a few hundred lines of the most
|
||||||
|
# bug-prone code in the project, to produce output people already know how to
|
||||||
|
# read. Both external tools take their arguments in the same order — ours,
|
||||||
|
# base, theirs — and both report a clean merge with status 0 and conflicts
|
||||||
|
# with a positive status, so the two backends differ only in the command line.
|
||||||
|
module Merge
|
||||||
|
Result = Struct.new(:status, :content, :conflicts, keyword_init: true) do
|
||||||
|
def clean? = status == :clean
|
||||||
|
|
||||||
|
def conflict? = status == :conflict
|
||||||
|
|
||||||
|
def unavailable? = status == :unavailable
|
||||||
|
end
|
||||||
|
|
||||||
|
LABELS = {ours: "local", base: "ancestor", theirs: "upstream"}.freeze
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
# Returns :git, :diff3, or nil.
|
||||||
|
def backend
|
||||||
|
return @backend if defined?(@backend)
|
||||||
|
|
||||||
|
@backend = if command?("git")
|
||||||
|
:git
|
||||||
|
elsif command?("diff3")
|
||||||
|
:diff3
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def available? = !backend.nil?
|
||||||
|
|
||||||
|
def command?(name)
|
||||||
|
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
||||||
|
File.executable?(File.join(dir, name)) && !File.directory?(File.join(dir, name))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def three_way(base:, ours:, theirs:, labels: LABELS)
|
||||||
|
return Result.new(status: :unavailable, content: nil, conflicts: 0) unless available?
|
||||||
|
|
||||||
|
with_temp_files(ours, base, theirs) do |ours_path, base_path, theirs_path|
|
||||||
|
merged, status = run(backend, ours_path, base_path, theirs_path, labels)
|
||||||
|
|
||||||
|
# Negative (255 as unsigned) means the tool itself failed rather than
|
||||||
|
# finding conflicts — don't report that as a mergeable conflict.
|
||||||
|
if status.nil? || status > 128
|
||||||
|
Result.new(status: :unavailable, content: nil, conflicts: 0)
|
||||||
|
elsif status.zero?
|
||||||
|
Result.new(status: :clean, content: merged, conflicts: 0)
|
||||||
|
else
|
||||||
|
Result.new(status: :conflict, content: merged, conflicts: status)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(backend, ours_path, base_path, theirs_path, labels)
|
||||||
|
argv = case backend
|
||||||
|
when :git
|
||||||
|
["git", "merge-file", "-p", "--diff3",
|
||||||
|
"-L", labels[:ours], "-L", labels[:base], "-L", labels[:theirs],
|
||||||
|
ours_path, base_path, theirs_path]
|
||||||
|
when :diff3
|
||||||
|
["diff3", "-m",
|
||||||
|
"-L", labels[:ours], "-L", labels[:base], "-L", labels[:theirs],
|
||||||
|
ours_path, base_path, theirs_path]
|
||||||
|
end
|
||||||
|
|
||||||
|
output = IO.popen(argv, err: File::NULL, &:read)
|
||||||
|
[output, $?&.exitstatus]
|
||||||
|
rescue SystemCallError
|
||||||
|
[nil, nil]
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_temp_files(ours, base, theirs)
|
||||||
|
files = {ours: ours, base: base, theirs: theirs}.map do |name, content|
|
||||||
|
file = Tempfile.new(["picopackage-#{name}", ".txt"])
|
||||||
|
file.binmode
|
||||||
|
file.write(content)
|
||||||
|
file.flush
|
||||||
|
file
|
||||||
|
end
|
||||||
|
yield(*files.map(&:path))
|
||||||
|
ensure
|
||||||
|
files&.each { |f| f.close! }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+137
-67
@@ -1,33 +1,48 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "yaml"
|
require "yaml"
|
||||||
|
require "json"
|
||||||
require "digest"
|
require "digest"
|
||||||
|
require "time"
|
||||||
require "forwardable"
|
require "forwardable"
|
||||||
|
|
||||||
module Picopackage
|
module Picopackage
|
||||||
METADATA_PATTERN = /^\n*#\s*@PICOPACKAGE_START\n(.*?)^\s*#\s*@PICOPACKAGE_END\s*$/m
|
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, :etag, keyword_init: true)
|
class Metadata < Struct.new(:url, :filename, :payload_version, :payload_timestamp,
|
||||||
# the #from_file method will create a new instance of Metadata from a file path, rather than read a package's metadata
|
:payload_checksum, :base_checksum, :etag, keyword_init: true)
|
||||||
def self.from_file(file_path, content: nil)
|
|
||||||
new(content: File.read(file_path))
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.from_url_response(url, response)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.from_content(content)
|
def self.from_content(content)
|
||||||
return new unless content =~ METADATA_PATTERN
|
return new unless (match = METADATA_PATTERN.match(content))
|
||||||
|
|
||||||
yaml_content = $1.each_line.map { |line| line.sub(/^\s*#\s?/, "").rstrip }.join("\n")
|
parsed = YAML.safe_load(uncomment(match[1]))
|
||||||
|
return new unless parsed.is_a?(Hash)
|
||||||
|
|
||||||
# Load and transform in one chain
|
known, unknown = parsed.partition { |key, _| members.include?(key.to_s.to_sym) }
|
||||||
@metadata = new(**YAML.safe_load(yaml_content)
|
new(**known.to_h.transform_keys(&:to_sym)).tap { |metadata| metadata.extra = unknown.to_h }
|
||||||
.slice(*Metadata.members.map(&:to_s))
|
rescue Psych::Exception, TypeError, ArgumentError
|
||||||
.transform_keys(&:to_sym))
|
new
|
||||||
rescue
|
|
||||||
new # Return empty hash on any YAML/transformation errors
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def empty? = to_h.values.all?(&:nil?)
|
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
|
end
|
||||||
|
|
||||||
class Payload
|
class Payload
|
||||||
@@ -35,94 +50,149 @@ module Picopackage
|
|||||||
|
|
||||||
def self.normalize(payload) = payload.rstrip + "\n\n"
|
def self.normalize(payload) = payload.rstrip + "\n\n"
|
||||||
|
|
||||||
def self.normalized_from_content(content) = Payload.from_content(content).then { Payload.normalize(_1) }
|
def self.normalized_from_content(content) = normalize(from_content(content))
|
||||||
|
|
||||||
def self.from_file(file_path) = normalized_from_content(File.read(file_path))
|
def self.from_file(path) = normalized_from_content(File.read(path))
|
||||||
|
|
||||||
def self.checksum(payload) = "sha256:#{Digest::SHA256.hexdigest(payload)}"
|
def self.checksum(payload) = "sha256:#{Digest::SHA256.hexdigest(payload)}"
|
||||||
|
|
||||||
def self.checksum_from_content(content) = checksum(from_content(content))
|
# 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
|
end
|
||||||
|
|
||||||
class Package
|
class Package
|
||||||
extend Forwardable
|
extend Forwardable
|
||||||
attr_reader :content, :payload, :metadata, :was_bare_file
|
|
||||||
|
attr_reader :content, :payload, :metadata
|
||||||
|
|
||||||
def_delegators :@metadata,
|
def_delegators :@metadata,
|
||||||
:url, :url=,
|
:url, :url=,
|
||||||
:filename, :filename=,
|
:filename, :filename=,
|
||||||
:payload_version, :payload_version=,
|
:payload_version, :payload_version=,
|
||||||
:payload_timestamp, :payload_timestamp=,
|
:payload_timestamp, :payload_timestamp=,
|
||||||
:payload_checksum, :payload_checksum=
|
:payload_checksum, :payload_checksum=,
|
||||||
|
:base_checksum, :base_checksum=,
|
||||||
|
:etag, :etag=
|
||||||
|
|
||||||
def self.from_file(file_path)
|
def self.from_file(file_path)
|
||||||
if File.exist?(file_path)
|
new(content: File.read(file_path)) if File.exist?(file_path)
|
||||||
new(content: File.read(file_path))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(content:)
|
def initialize(content:)
|
||||||
@content = content
|
@content = content
|
||||||
@payload = Payload.normalized_from_content(@content)
|
@had_metadata_block = METADATA_PATTERN.match?(content)
|
||||||
@metadata = Metadata.from_content(@content)
|
@payload = Payload.normalized_from_content(content)
|
||||||
|
@metadata = Metadata.from_content(content)
|
||||||
if is_bare_file?
|
|
||||||
@was_bare_file = true
|
|
||||||
init_metadata
|
|
||||||
else
|
|
||||||
@was_bare_file = false
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def is_bare_file? = @metadata.empty?
|
# 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
|
||||||
|
|
||||||
def init_metadata
|
# What the payload hashes to right now.
|
||||||
@metadata.url ||= url
|
def payload_digest = Payload.checksum(payload)
|
||||||
@metadata.filename ||= filename
|
|
||||||
@metadata.payload_checksum ||= Payload.checksum_from_content(content)
|
|
||||||
@metadata.payload_timestamp ||= payload_timestamp
|
|
||||||
end
|
|
||||||
|
|
||||||
def save(path, filename = nil)
|
# Local edits break the recorded checksum, and that divergence is the only
|
||||||
path = File.join(path, filename || @metadata.filename) if File.directory?(path)
|
# 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?
|
||||||
|
|
||||||
File.write(path, generate_package)
|
payload_digest != payload_checksum
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_payload
|
def verify_payload
|
||||||
return false if metadata.payload_checksum.nil? || metadata.payload_checksum&.empty?
|
return false unless recorded_checksum?
|
||||||
Payload.checksum(payload) == metadata.payload_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
|
end
|
||||||
|
|
||||||
def payload_timestamp_as_time
|
def payload_timestamp_as_time
|
||||||
@metadata&.payload_timestamp ? Time.parse(@metadata.payload_timestamp) : nil
|
payload_timestamp ? Time.parse(payload_timestamp.to_s) : nil
|
||||||
|
rescue ArgumentError
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def modified? = !verify_payload
|
# 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_h)
|
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
|
private
|
||||||
|
|
||||||
def generate_package
|
def recorded_checksum? = !(payload_checksum.nil? || payload_checksum.empty?)
|
||||||
@metadata.url = url.to_s
|
|
||||||
metadata_block = generate_metadata
|
|
||||||
if METADATA_PATTERN.match?(content)
|
|
||||||
content.sub(METADATA_PATTERN, "\n#{metadata_block}")
|
|
||||||
else
|
|
||||||
[content.rstrip, "\n#{metadata_block}"].join("\n")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# This will need a comment style one day, to work with other languages
|
# This will need a comment style one day, to work with other languages.
|
||||||
def generate_metadata
|
def generate_metadata
|
||||||
yaml_content = @metadata.to_h.transform_keys(&:to_s).to_yaml.strip
|
commented = @metadata.to_yaml_hash.to_yaml.strip.each_line.map { |line| "# #{line.chomp}".rstrip }
|
||||||
[
|
["# @PICOPACKAGE_START", *commented, "# @PICOPACKAGE_END", ""].join("\n")
|
||||||
"# @PICOPACKAGE_START",
|
|
||||||
yaml_content.lines.map { |line| "# #{line}" }.join,
|
|
||||||
"# @PICOPACKAGE_END",
|
|
||||||
""
|
|
||||||
].join("\n")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+80
-37
@@ -1,3 +1,5 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "time"
|
require "time"
|
||||||
require "pathname"
|
require "pathname"
|
||||||
|
|
||||||
@@ -6,34 +8,49 @@ module Picopackage
|
|||||||
def self.for(url)
|
def self.for(url)
|
||||||
PROVIDERS.each do |provider|
|
PROVIDERS.each do |provider|
|
||||||
case provider.handles_url?(url)
|
case provider.handles_url?(url)
|
||||||
when false
|
when false, nil
|
||||||
next
|
next
|
||||||
when true
|
when true
|
||||||
return provider.new(url)
|
return provider.new(url)
|
||||||
when :maybe
|
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)
|
instance = provider.new(url)
|
||||||
return instance if instance.handles_body?
|
return instance if instance.handles_body?
|
||||||
|
rescue FetchError, Fetch::Error, URI::Error
|
||||||
|
next
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
nil # Return nil if no provider found
|
end
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Base class for fetching content from a URL
|
# Base class for fetching content from a URL.
|
||||||
# The variable `body` will contain the package_data retrieved from the URL
|
#
|
||||||
# The variable `package_data` will contain both and payload + metadata - this would be writen to a file.
|
# A provider's job is to fetch the body, and to pull the package content and a
|
||||||
# The variable `payload` will contain the payload extracted from `package_data`
|
# filename out of it. Splitting content into payload and metadata is the
|
||||||
# The variable `metadata` will contain the metadata extracted from `package_data`
|
# Package class's job, not a provider's.
|
||||||
|
|
||||||
# Job of the Provider class is to fetch the body from the URL, and then extract the package_data
|
|
||||||
# and the filename from the body. The Package class will then take the body and split it into payload and metadata
|
|
||||||
|
|
||||||
class DefaultProvider
|
class DefaultProvider
|
||||||
MAX_SIZE = 1024 * 1024
|
MAX_SIZE = 1024 * 1024
|
||||||
TIMEOUT = 10
|
TIMEOUT = 10
|
||||||
|
|
||||||
attr_reader :url, :package
|
attr_reader :url, :package
|
||||||
|
|
||||||
def self.handles_url?(url) = :maybe
|
# 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))
|
def initialize(url, fetcher: Fetch.new(max_size: MAX_SIZE, timeout: TIMEOUT))
|
||||||
@url = transform_url(url)
|
@url = transform_url(url)
|
||||||
@@ -42,7 +59,7 @@ module Picopackage
|
|||||||
populate_metadata
|
populate_metadata
|
||||||
end
|
end
|
||||||
|
|
||||||
def transform_url(url) = URI(url)
|
def transform_url(url) = URI(url.to_s)
|
||||||
|
|
||||||
def body
|
def body
|
||||||
@body ||= @fetcher.fetch(@url)
|
@body ||= @fetcher.fetch(@url)
|
||||||
@@ -56,78 +73,104 @@ module Picopackage
|
|||||||
raise FetchError, "Failed to parse JSON response"
|
raise FetchError, "Failed to parse JSON response"
|
||||||
end
|
end
|
||||||
|
|
||||||
def payload_timestamp = Time.now.httpdate
|
# 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?
|
def handles_body?
|
||||||
true
|
!body.nil? && !body.empty?
|
||||||
rescue FileTooLargeError, Net::HTTPError, RuntimeError
|
rescue FetchError, FileTooLargeError
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
# Implement in subclass - this come from the `body`.
|
# Implement in a subclass when the content is wrapped in something (JSON,
|
||||||
# Spliting content into payload and metadata is the job of the Package class
|
# HTML). Splitting payload from metadata is the Package class's job.
|
||||||
def content = body
|
def content = body
|
||||||
|
|
||||||
# Implement in subclass - this should return the filename extracted from the body - if it exists, but not from the metadata
|
# Implement in a subclass when the body carries a filename. Not to be
|
||||||
def filename = File.basename @url
|
# 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
|
def populate_metadata
|
||||||
@package.filename ||= filename
|
@package.filename ||= filename
|
||||||
@package.url ||= @url
|
@package.url ||= @url.to_s
|
||||||
@package.payload_timestamp ||= payload_timestamp
|
@package.payload_timestamp ||= payload_timestamp
|
||||||
@package.payload_checksum ||= Payload.checksum(content)
|
@package.payload_checksum = @package.payload_digest
|
||||||
|
@package.base_checksum = @package.payload_checksum
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class GithubGistProvider < DefaultProvider
|
class GithubGistProvider < DefaultProvider
|
||||||
def self.handles_url?(url) = url.match?(%r{gist\.github\.com})
|
def self.handles_url?(url) = url.to_s.match?(%r{gist\.github\.com})
|
||||||
|
|
||||||
def content = json_body["files"].values.first["content"]
|
def content = json_body["files"].values.first["content"]
|
||||||
|
|
||||||
def filename = json_body["files"].values.first["filename"]
|
def filename = json_body["files"].values.first["filename"]
|
||||||
|
|
||||||
def transform_url(url)
|
def transform_url(url)
|
||||||
gist_id = url[/gist\.github\.com\/[^\/]+\/([a-f0-9]+)/, 1]
|
gist_id = url.to_s[%r{gist\.github\.com/[^/]+/([a-f0-9]+)}, 1]
|
||||||
"https://api.github.com/gists/#{gist_id}"
|
raise FetchError, "Could not extract a gist id from #{url}" if gist_id.nil?
|
||||||
|
|
||||||
|
URI("https://api.github.com/gists/#{gist_id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def payload_timestamp
|
def payload_timestamp
|
||||||
Time.parse(json_body["created_at"])
|
Time.parse(json_body["updated_at"] || json_body["created_at"]).utc.iso8601
|
||||||
rescue ArgumentError
|
rescue ArgumentError, TypeError
|
||||||
nil
|
super
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class OpenGistProvider < DefaultProvider
|
class OpenGistProvider < DefaultProvider
|
||||||
def handles_url?(url) = :maybe
|
# 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) = "#{url}.json"
|
def transform_url(url) = URI("#{url}.json")
|
||||||
|
|
||||||
def content = json_body.dig("files", 0, "content")
|
def content = json_body.dig("files", 0, "content")
|
||||||
|
|
||||||
def filename = json_body.dig("files", 0, "filename")
|
def filename = json_body.dig("files", 0, "filename")
|
||||||
|
|
||||||
|
# Only claims the URL if the JSON actually looks like a gist listing.
|
||||||
def handles_body?
|
def handles_body?
|
||||||
content && filename
|
!content.nil? && !filename.nil?
|
||||||
rescue FileTooLargeError, Net::HTTPError, RuntimeError
|
rescue FetchError, FileTooLargeError
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
# If we successfully fetch the body, and the body contains content and a filename, then we can handle the body
|
|
||||||
end
|
end
|
||||||
|
|
||||||
class FileProvider < DefaultProvider
|
class FileProvider < DefaultProvider
|
||||||
def self.handles_url?(url) = File.exist?(url)
|
def self.handles_url?(url) = !url.to_s.empty? && File.exist?(url.to_s)
|
||||||
|
|
||||||
def transform_url(url) = Pathname(url)
|
def transform_url(url) = Pathname(url.to_s)
|
||||||
|
|
||||||
def content = url.read
|
def content = url.read
|
||||||
|
|
||||||
def filename = url.basename.to_s
|
def filename = url.basename.to_s
|
||||||
|
|
||||||
def payload_timestamp
|
def payload_timestamp
|
||||||
url.mtime.httpdate
|
url.mtime.utc.iso8601
|
||||||
rescue Errno::ENOENT
|
rescue Errno::ENOENT
|
||||||
nil
|
super
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Picopackage
|
module Picopackage
|
||||||
module Scanner
|
module Scanner
|
||||||
|
Found = Struct.new(:path, :package)
|
||||||
|
|
||||||
|
# Scanning walks whole trees, so it has to survive binaries, unreadable
|
||||||
|
# files and anything large enough that reading it to look for a comment
|
||||||
|
# would be silly.
|
||||||
|
MAX_SCAN_SIZE = 1024 * 1024
|
||||||
|
|
||||||
def self.scan(directory, pattern: "**/*")
|
def self.scan(directory, pattern: "**/*")
|
||||||
Dir.glob(File.join(directory, pattern)).select do |file|
|
Dir.glob(File.join(directory, pattern)).sort.filter_map do |path|
|
||||||
next unless File.file?(file)
|
content = readable_text(path)
|
||||||
content = File.read(file)
|
next unless content&.match?(METADATA_PATTERN)
|
||||||
content.match?(Package::METADATA_PATTERN)
|
|
||||||
end.map { |file| Package.new(file) }
|
Found.new(path, Package.new(content: content))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.readable_text(path)
|
||||||
|
return nil unless File.file?(path)
|
||||||
|
return nil if File.size(path) > MAX_SCAN_SIZE
|
||||||
|
|
||||||
|
content = File.read(path, encoding: "UTF-8")
|
||||||
|
content.valid_encoding? ? content : nil
|
||||||
|
rescue SystemCallError, ArgumentError
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Picopackage
|
module Picopackage
|
||||||
VERSION = "0.2.1"
|
VERSION = "0.3.0"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,25 +1,195 @@
|
|||||||
## Package Installation Flow
|
## Resolution rules
|
||||||
|
|
||||||
1. **Fetch URL**
|
Two principles decide every case below:
|
||||||
└─> Download content
|
|
||||||
└─> Build Package Instance (Metadata & Payload)
|
|
||||||
└─> Check local file status
|
|
||||||
|
|
||||||
2. **Local File Check**
|
1. **The checksum decides, never the timestamp.** mtime is unreliable (a raw
|
||||||
├─> If file doesn't exist:
|
file re-uploaded unchanged gets a new mtime; a gist mirrored elsewhere gets
|
||||||
│ └─> Save file
|
an arbitrary one) and it is not evidence about content. If the payload
|
||||||
│
|
checksums agree, the payload is settled — there is nothing to resolve no
|
||||||
└─> If file exists:
|
matter what the timestamps say. This is why every `????` in the old table
|
||||||
└─> Compare versions
|
collapsed: all of those rows assumed equal checksums, so all of them are
|
||||||
├─> If older/same:
|
metadata questions, not content questions.
|
||||||
│ └─> "Package already installed"
|
|
||||||
│
|
2. **Merge the payload, regenerate the metadata.** The metadata block is
|
||||||
└─> If newer:
|
derived, not authored, so it never takes part in a merge — merging it would
|
||||||
└─> Check local modifications
|
conflict on every single update. Three-way merges run on the payload alone
|
||||||
├─> If modified:
|
and the block is rewritten afterwards.
|
||||||
│ └─> "Local modifications detected"
|
|
||||||
│ └─> "Use 'update <file_path>'"
|
### Terms
|
||||||
│
|
|
||||||
└─> If unmodified:
|
- **payload** — the file with its metadata block removed, then normalised.
|
||||||
└─> "Update available"
|
- **bare** — a file with no metadata block. A hand-copied file, or an upstream
|
||||||
└─> "Use 'update <file_path> -f' to force update"
|
that has never been packaged.
|
||||||
|
- **`payload_checksum`** — checksum of the payload as it sits in this file.
|
||||||
|
Local edits break it, which is how modification is detected.
|
||||||
|
- **`base_checksum`** — checksum of the upstream payload this file was last
|
||||||
|
reconciled with. Equal to `payload_checksum` on a clean install; they diverge
|
||||||
|
after a merge or a local edit. This is the merge base pointer and the cache
|
||||||
|
key.
|
||||||
|
|
||||||
|
### Same payload checksum
|
||||||
|
|
||||||
|
The content is identical, so the only question is which metadata to keep.
|
||||||
|
|
||||||
|
| Remote | Local | Action | State |
|
||||||
|
|---------|---------|-------------------------------------------|-------------|
|
||||||
|
| Bare | missing | Write payload + generated metadata | `installed` |
|
||||||
|
| Package | missing | Write payload + upstream metadata | `installed` |
|
||||||
|
| Bare | Bare | Adopt: attach generated metadata | `adopted` |
|
||||||
|
| Package | Bare | Adopt: attach upstream metadata | `adopted` |
|
||||||
|
| Bare | Package | Nothing to do | `current` |
|
||||||
|
| Package | Package | Rewrite only if the metadata itself changed | `current` |
|
||||||
|
|
||||||
|
mtime appears nowhere in this table. That is the point of principle 1.
|
||||||
|
|
||||||
|
### Different payload checksum
|
||||||
|
|
||||||
|
| Local | Modified | Base available | Action | State |
|
||||||
|
|--------------------|----------|----------------|-------------------------------------|-------------|
|
||||||
|
| missing | — | — | Write it | `installed` |
|
||||||
|
| Package | no | — | Fast-forward to upstream | `updated` |
|
||||||
|
| Package | yes | yes | Three-way merge, clean | `merged` |
|
||||||
|
| Package | yes | yes | Three-way merge, conflicted | `conflict` |
|
||||||
|
| Package | yes | no | Refuse — no ancestor to merge from | `conflict` |
|
||||||
|
| Bare | n/a | never | Refuse — unrelated file, same name | `conflict` |
|
||||||
|
| any | any | any | `--force` overwrites unconditionally | `updated` |
|
||||||
|
|
||||||
|
A bare local file whose payload differs is not a modified picopackage — it is an
|
||||||
|
unrelated file that happens to share a name. There is no ancestor and no claim
|
||||||
|
that the two share a history, so merging would be guesswork. Refuse and say so.
|
||||||
|
|
||||||
|
### Conflicts are non-destructive
|
||||||
|
|
||||||
|
`git`/`diff3` conflict markers are written to `<file>.picopackage-merge`, beside
|
||||||
|
the original. The original is never touched. A package manager with no `reset`
|
||||||
|
and no index has no business writing conflict markers into a file the user may
|
||||||
|
not have committed.
|
||||||
|
|
||||||
|
## Merge base cache
|
||||||
|
|
||||||
|
Three-way merge needs the common ancestor — the payload exactly as upstream last
|
||||||
|
sent it. It is content-addressed under `$XDG_CACHE_HOME/picopackage/sha256/ab/cdef…`
|
||||||
|
and written on every save, so a file installed by `ppkg` carries the pointer to
|
||||||
|
its own ancestor in `base_checksum`.
|
||||||
|
|
||||||
|
Cache misses are expected and survivable: a file installed on another machine, a
|
||||||
|
cleared cache, a package predating this feature. The fallback is to refuse the
|
||||||
|
merge and say why, which is honest. A future `source_url` pinned to an immutable
|
||||||
|
revision would let us refetch the ancestor instead of refusing — see below.
|
||||||
|
|
||||||
|
## Update UX and signing (designed 2026-08-13, not implemented)
|
||||||
|
|
||||||
|
The security question splits in three, and today's answers are: **first
|
||||||
|
install** — pure trust in the URL, same as copy+paste, mitigated by "it's one
|
||||||
|
file and you can read it"; **update** — worse than copy+paste, because `ppkg
|
||||||
|
update` fetches whatever the mutable url serves and writes it with less
|
||||||
|
friction than a human re-pasting; **transport** — plain `http` is currently
|
||||||
|
allowed. The checksum contributes nothing adversarial: it lives inside the
|
||||||
|
file it hashes and is regenerated on every write, so it is integrity against
|
||||||
|
yourself, not against an attacker. The design below closes the update gap.
|
||||||
|
|
||||||
|
### Diff by default
|
||||||
|
|
||||||
|
`ppkg update` shows the diff and asks before writing. The diff shown is
|
||||||
|
**ancestor → upstream** — the untrusted delta. Local edits are not news to the
|
||||||
|
user, and the Resolver already holds all three payloads at that point, so this
|
||||||
|
is nearly free. We are not a diff tool: shell out (`git diff --no-index`, else
|
||||||
|
`diff -u`), respect `$PAGER` — the same philosophy as merge.rb. `-y`/`--yes`
|
||||||
|
skips the prompt for scripts; it never covers an identity change (below).
|
||||||
|
|
||||||
|
### Identity: SSH signatures, TOFU-pinned
|
||||||
|
|
||||||
|
SSH rather than Sigstore. A full Sigstore bundle is 4–10KB of JSON — routinely
|
||||||
|
larger than the payload it certifies — and drags a dependency tree into a
|
||||||
|
zero-dependency gem. An armored ed25519 ssh signature is ~0.5KB, embeds in the
|
||||||
|
metadata block, and the authors this format serves (gists, blogs) already hold
|
||||||
|
ssh keys, with `github.com/<user>.keys` as a verification channel separate
|
||||||
|
from the file itself. What Sigstore would add — identity that survives key
|
||||||
|
loss, a transparency log — is real but not a different security class at this
|
||||||
|
scale. It stays a possible opt-in upgrade, not the price of entry.
|
||||||
|
|
||||||
|
- The signature covers the **normalized payload + the claims** (url, filename,
|
||||||
|
payload_version). Signing the payload alone leaves `url` unsigned, so a
|
||||||
|
tampered first install could silently redirect every future update. The
|
||||||
|
claims/records split in provider.rb is the seam: sign the claims, keep
|
||||||
|
deriving the records.
|
||||||
|
- TOFU: the key fingerprint is pinned at install, or the first time a
|
||||||
|
signature appears on an already-installed file. Unsigned packages keep
|
||||||
|
working; signing is opt-in per file.
|
||||||
|
|
||||||
|
### Key changes are a hard stop
|
||||||
|
|
||||||
|
The known_hosts model: banner, refuse, distinct exit code — not a y/n prompt
|
||||||
|
someone can fat-finger through. The only door is `--accept-key <fingerprint>`;
|
||||||
|
making the human transcribe the new fingerprint is what proves they looked.
|
||||||
|
|
||||||
|
Routine updates and identity changes are different signals and get different
|
||||||
|
ceremonies. "Upstream changed, here's the diff" is routine: default-proceed,
|
||||||
|
informational. "The signing identity changed" is how account compromise
|
||||||
|
presents: default-refuse. Flattening both into one always-maximum-caution
|
||||||
|
ceremony is how alarm fatigue happens.
|
||||||
|
|
||||||
|
### No rotation chains
|
||||||
|
|
||||||
|
Old-key-signs-new-key succession (signify/TUF style) was considered and
|
||||||
|
dropped. If every key change is loud and manual anyway, a valid rotation proof
|
||||||
|
can only *downgrade* the alarm — and a stolen key holds everything needed to
|
||||||
|
mint that proof, so chains sharpen theft (a thief can rotate the author out of
|
||||||
|
their own package) while buying convenience only for planned rotation. At this
|
||||||
|
scale, "key changed, human reads the diff carefully, re-pins" *is* the
|
||||||
|
rotation ceremony, and it covers loss and theft with the same motion.
|
||||||
|
|
||||||
|
### Exit codes are the contract
|
||||||
|
|
||||||
|
Callers — scripts and reviewing agents — build policy on exit codes, not on
|
||||||
|
prose. An agent's policy may auto-accept a clean diff under a continuous key;
|
||||||
|
an identity change must be unrepresentable in that path — no flag an agent
|
||||||
|
routinely passes gets through it.
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|------------------------------------------------------------|
|
||||||
|
| 0 | written (installed/adopted/updated/merged) or already current |
|
||||||
|
| 1 | conflict — original untouched, as ever |
|
||||||
|
| 2 | declined — user said no to the diff |
|
||||||
|
| 3 | identity change — signing key differs from the pin |
|
||||||
|
|
||||||
|
### Review is the content check; the signature is the identity check
|
||||||
|
|
||||||
|
A signature answers "same author?" and says nothing about whether the code is
|
||||||
|
safe. Review answers "does this change look sane?" and says nothing about who
|
||||||
|
made it. They are complements, and single files invert the economics that make
|
||||||
|
library review hopeless: the whole unit — old file, new file, three-way diff,
|
||||||
|
the project it lives in — fits in one reading (or one context window), and
|
||||||
|
with no install hooks, build steps, or dependencies, what you review is
|
||||||
|
exactly what runs.
|
||||||
|
|
||||||
|
The gem's job is to surface facts, not to judge: emit the diff plus the
|
||||||
|
machine-readable facts (`--json` — key changed, size delta, e.g. "adds
|
||||||
|
net/http") and exit accordingly. Any LLM stays out of the gem. Note for
|
||||||
|
whoever builds the reviewing agent: the payload is adversarial input —
|
||||||
|
prompt injection in comments, aimed at the reviewer, is an expected attack.
|
||||||
|
|
||||||
|
### Order of work
|
||||||
|
|
||||||
|
1. Refuse plain `http`, including via redirect — cheap, orthogonal.
|
||||||
|
2. `source_url` pinning (see open question below) — makes the update diff a
|
||||||
|
pointer change and recovers merge bases on cache miss.
|
||||||
|
3. Diff-by-default + the exit-code contract.
|
||||||
|
4. SSH signing and pinning.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Immutable source pinning.** `url` is mutable ("whatever is at this address
|
||||||
|
today"), which means builds aren't reproducible and "upstream improved" is
|
||||||
|
indistinguishable from "upstream was compromised". Wants splitting into a
|
||||||
|
pinned `source_url` (gist revision SHA, commit SHA) and a mutable `update_url`
|
||||||
|
used only for checking. This also recovers merge bases on a cache miss.
|
||||||
|
- **Multi-file packages.** `Jmap::Client` + `Jmap::Http` are one unit of reuse
|
||||||
|
across two files. Either the spec grows a flat file-set, or single-file
|
||||||
|
packages accept that they define several classes in one file and lose Zeitwerk
|
||||||
|
file-per-class layout. Unresolved, and the first real consumer hits it.
|
||||||
|
- **Comment styles.** `#` covers Ruby/Python/shell/YAML/Perl. Needs a table for
|
||||||
|
`//`, `--`, `<!-- -->`, `/* */`, `;` before the format is honestly
|
||||||
|
language-agnostic.
|
||||||
|
- **Unknown metadata keys** are preserved verbatim on rewrite (see
|
||||||
|
`Metadata#extra`), so optional spec fields survive a tool that predates them.
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ Gem::Specification.new do |spec|
|
|||||||
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
||||||
spec.require_paths = ["lib"]
|
spec.require_paths = ["lib"]
|
||||||
|
|
||||||
spec.add_dependency "open-uri", "~> 0.5"
|
# Fetching is done with net/http directly — see the note in Fetch#fetch_http.
|
||||||
spec.add_dependency "yaml", "~> 0.4"
|
spec.add_dependency "yaml", "~> 0.4"
|
||||||
spec.add_dependency "digest"
|
spec.add_dependency "digest"
|
||||||
spec.add_development_dependency "debug"
|
spec.add_development_dependency "debug"
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb",
|
||||||
|
"forks_url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/forks",
|
||||||
|
"commits_url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/commits",
|
||||||
|
"id": "a06926360de8edf108be8591368ce1fb",
|
||||||
|
"node_id": "G_kwDNRzjaACBhMDY5MjYzNjBkZThlZGYxMDhiZTg1OTEzNjhjZTFmYg",
|
||||||
|
"git_pull_url": "https://gist.github.com/a06926360de8edf108be8591368ce1fb.git",
|
||||||
|
"git_push_url": "https://gist.github.com/a06926360de8edf108be8591368ce1fb.git",
|
||||||
|
"html_url": "https://gist.github.com/dkam/a06926360de8edf108be8591368ce1fb",
|
||||||
|
"files": {
|
||||||
|
"ipv6_in_sqlite.rb": {
|
||||||
|
"filename": "ipv6_in_sqlite.rb",
|
||||||
|
"type": "application/x-ruby",
|
||||||
|
"language": "Ruby",
|
||||||
|
"raw_url": "https://gist.githubusercontent.com/dkam/a06926360de8edf108be8591368ce1fb/raw/13e47fb5d3c981a407997fc954b163fe50a37b45/ipv6_in_sqlite.rb",
|
||||||
|
"size": 373,
|
||||||
|
"truncated": false,
|
||||||
|
"content": "class IpAddress < ApplicationRecord\n def ipv6\n (ipv6_high << 64) | ipv6_low\n end\n\n def ipv6=(addr)\n self.ipv6_high = addr >> 64\n self.ipv6_low = addr & 0xFFFFFFFFFFFFFFFF\n end\n\n def self.ipv6_range(start_ip, end_ip)\n where(ipv6_high: start_ip >> 64..end_ip >> 64)\n .where(ipv6_low: start_ip & 0xFFFFFFFFFFFFFFFF..end_ip & 0xFFFFFFFFFFFFFFFF)\n end\nend",
|
||||||
|
"encoding": "utf-8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"public": false,
|
||||||
|
"created_at": "2025-01-25T00:07:09Z",
|
||||||
|
"updated_at": "2025-01-25T00:07:09Z",
|
||||||
|
"description": "Store IPv6 address in SQLite, via Active Record",
|
||||||
|
"comments": 0,
|
||||||
|
"user": null,
|
||||||
|
"comments_enabled": true,
|
||||||
|
"comments_url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/comments",
|
||||||
|
"owner": {
|
||||||
|
"login": "dkam",
|
||||||
|
"id": 18232,
|
||||||
|
"node_id": "MDQ6VXNlcjE4MjMy",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18232?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/dkam",
|
||||||
|
"html_url": "https://github.com/dkam",
|
||||||
|
"followers_url": "https://api.github.com/users/dkam/followers",
|
||||||
|
"following_url": "https://api.github.com/users/dkam/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/dkam/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/dkam/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/dkam/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/dkam/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/dkam/repos",
|
||||||
|
"events_url": "https://api.github.com/users/dkam/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/dkam/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"forks": [
|
||||||
|
|
||||||
|
],
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"login": "dkam",
|
||||||
|
"id": 18232,
|
||||||
|
"node_id": "MDQ6VXNlcjE4MjMy",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18232?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/dkam",
|
||||||
|
"html_url": "https://github.com/dkam",
|
||||||
|
"followers_url": "https://api.github.com/users/dkam/followers",
|
||||||
|
"following_url": "https://api.github.com/users/dkam/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/dkam/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/dkam/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/dkam/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/dkam/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/dkam/repos",
|
||||||
|
"events_url": "https://api.github.com/users/dkam/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/dkam/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"version": "7f52a7373edd4cbf186162dc71444792eece75b3",
|
||||||
|
"committed_at": "2025-01-25T00:07:09Z",
|
||||||
|
"change_status": {
|
||||||
|
"total": 15,
|
||||||
|
"additions": 15,
|
||||||
|
"deletions": 0
|
||||||
|
},
|
||||||
|
"url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/7f52a7373edd4cbf186162dc71444792eece75b3"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"truncated": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb",
|
||||||
|
"forks_url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/forks",
|
||||||
|
"commits_url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/commits",
|
||||||
|
"id": "a06926360de8edf108be8591368ce1fb",
|
||||||
|
"node_id": "G_kwDNRzjaACBhMDY5MjYzNjBkZThlZGYxMDhiZTg1OTEzNjhjZTFmYg",
|
||||||
|
"git_pull_url": "https://gist.github.com/a06926360de8edf108be8591368ce1fb.git",
|
||||||
|
"git_push_url": "https://gist.github.com/a06926360de8edf108be8591368ce1fb.git",
|
||||||
|
"html_url": "https://gist.github.com/dkam/a06926360de8edf108be8591368ce1fb",
|
||||||
|
"files": {
|
||||||
|
"ipv6_in_sqlite.rb": {
|
||||||
|
"filename": "ipv6_in_sqlite.rb",
|
||||||
|
"type": "application/x-ruby",
|
||||||
|
"language": "Ruby",
|
||||||
|
"raw_url": "https://gist.githubusercontent.com/dkam/a06926360de8edf108be8591368ce1fb/raw/458a32b55c57e42d6e541f29317e9445a87d4e43/ipv6_in_sqlite.rb",
|
||||||
|
"size": 384,
|
||||||
|
"truncated": false,
|
||||||
|
"content": "# Comments\nclass IpAddress < ApplicationRecord\n def ipv6\n (ipv6_high << 64) | ipv6_low\n end\n\n def ipv6=(addr)\n self.ipv6_high = addr >> 64\n self.ipv6_low = addr & 0xFFFFFFFFFFFFFFFF\n end\n\n def self.ipv6_range(start_ip, end_ip)\n where(ipv6_high: start_ip >> 64..end_ip >> 64)\n .where(ipv6_low: start_ip & 0xFFFFFFFFFFFFFFFF..end_ip & 0xFFFFFFFFFFFFFFFF)\n end\nend",
|
||||||
|
"encoding": "utf-8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"public": false,
|
||||||
|
"created_at": "2025-01-25T00:07:09Z",
|
||||||
|
"updated_at": "2025-01-25T00:08:48Z",
|
||||||
|
"description": "Store IPv6 address in SQLite, via Active Record",
|
||||||
|
"comments": 0,
|
||||||
|
"user": null,
|
||||||
|
"comments_enabled": true,
|
||||||
|
"comments_url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/comments",
|
||||||
|
"owner": {
|
||||||
|
"login": "dkam",
|
||||||
|
"id": 18232,
|
||||||
|
"node_id": "MDQ6VXNlcjE4MjMy",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18232?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/dkam",
|
||||||
|
"html_url": "https://github.com/dkam",
|
||||||
|
"followers_url": "https://api.github.com/users/dkam/followers",
|
||||||
|
"following_url": "https://api.github.com/users/dkam/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/dkam/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/dkam/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/dkam/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/dkam/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/dkam/repos",
|
||||||
|
"events_url": "https://api.github.com/users/dkam/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/dkam/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"forks": [
|
||||||
|
|
||||||
|
],
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"login": "dkam",
|
||||||
|
"id": 18232,
|
||||||
|
"node_id": "MDQ6VXNlcjE4MjMy",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18232?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/dkam",
|
||||||
|
"html_url": "https://github.com/dkam",
|
||||||
|
"followers_url": "https://api.github.com/users/dkam/followers",
|
||||||
|
"following_url": "https://api.github.com/users/dkam/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/dkam/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/dkam/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/dkam/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/dkam/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/dkam/repos",
|
||||||
|
"events_url": "https://api.github.com/users/dkam/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/dkam/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"version": "9774aa95cbf7c1fa7bf2ea27b2cb66898d0769c8",
|
||||||
|
"committed_at": "2025-01-25T00:08:48Z",
|
||||||
|
"change_status": {
|
||||||
|
"total": 1,
|
||||||
|
"additions": 1,
|
||||||
|
"deletions": 0
|
||||||
|
},
|
||||||
|
"url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/9774aa95cbf7c1fa7bf2ea27b2cb66898d0769c8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"login": "dkam",
|
||||||
|
"id": 18232,
|
||||||
|
"node_id": "MDQ6VXNlcjE4MjMy",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18232?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/dkam",
|
||||||
|
"html_url": "https://github.com/dkam",
|
||||||
|
"followers_url": "https://api.github.com/users/dkam/followers",
|
||||||
|
"following_url": "https://api.github.com/users/dkam/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/dkam/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/dkam/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/dkam/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/dkam/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/dkam/repos",
|
||||||
|
"events_url": "https://api.github.com/users/dkam/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/dkam/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"version": "7f52a7373edd4cbf186162dc71444792eece75b3",
|
||||||
|
"committed_at": "2025-01-25T00:07:09Z",
|
||||||
|
"change_status": {
|
||||||
|
"total": 15,
|
||||||
|
"additions": 15,
|
||||||
|
"deletions": 0
|
||||||
|
},
|
||||||
|
"url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/7f52a7373edd4cbf186162dc71444792eece75b3"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"truncated": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Make an array of hashes contain a unique value for key:
|
||||||
|
def uniquify_array(array, key)
|
||||||
|
array.group_by { |item| item[key] }.map do |_, items|
|
||||||
|
(items.length > 1) ? yield(items) : items.first
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# new_array = uniquify_array(array, :some_key) do |dupes|
|
||||||
|
# dupes.max_by { |item| item[:updated_at] }
|
||||||
|
# end
|
||||||
|
|
||||||
|
# @PICOPACKAGE_START
|
||||||
|
# ---
|
||||||
|
# filename: uniquify_array.rb
|
||||||
|
# url: https://api.github.com/gists/525ac4177964549ee2f2ca7febd03eea
|
||||||
|
# version: 0.1
|
||||||
|
# source_url: https://gist.github.com/dkam/525ac4177964549ee2f2ca7febd03eea
|
||||||
|
# @PICOPACKAGE_END
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TestCache < Minitest::Test
|
||||||
|
def with_cache
|
||||||
|
Dir.mktmpdir("picopackage-cache") { |root| yield Picopackage::Cache.new(root: root) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_stores_and_fetches_by_content
|
||||||
|
with_cache do |cache|
|
||||||
|
checksum = cache.store("hello\n")
|
||||||
|
assert_equal Picopackage::Payload.checksum("hello\n"), checksum
|
||||||
|
assert_equal "hello\n", cache.fetch(checksum)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_storing_twice_is_harmless
|
||||||
|
with_cache do |cache|
|
||||||
|
a = cache.store("hello\n")
|
||||||
|
b = cache.store("hello\n")
|
||||||
|
assert_equal a, b
|
||||||
|
assert_equal "hello\n", cache.fetch(a)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_unknown_and_malformed_checksums_return_nil
|
||||||
|
with_cache do |cache|
|
||||||
|
assert_nil cache.fetch(Picopackage::Payload.checksum("never stored"))
|
||||||
|
assert_nil cache.fetch(nil)
|
||||||
|
assert_nil cache.fetch("")
|
||||||
|
assert_nil cache.fetch("sha256:../../escape")
|
||||||
|
refute cache.include?(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,3 +4,29 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|||||||
require "picopackage"
|
require "picopackage"
|
||||||
|
|
||||||
require "minitest/autorun"
|
require "minitest/autorun"
|
||||||
|
require "tmpdir"
|
||||||
|
|
||||||
|
module PicopackageTest
|
||||||
|
# Every test gets its own cache root: the merge base cache is global state and
|
||||||
|
# a suite that shared one would pass or fail depending on what ran before it.
|
||||||
|
def with_dirs
|
||||||
|
Dir.mktmpdir("picopackage-test") do |root|
|
||||||
|
upstream = File.join(root, "upstream")
|
||||||
|
project = File.join(root, "project")
|
||||||
|
[upstream, project].each { |dir| Dir.mkdir(dir) }
|
||||||
|
yield(upstream, project, Picopackage::Cache.new(root: File.join(root, "cache")))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Stands in for a server: a real file, fetched through the real FileProvider,
|
||||||
|
# so provider metadata population is exercised rather than faked.
|
||||||
|
def publish(dir, name, content)
|
||||||
|
path = File.join(dir, name)
|
||||||
|
File.write(path, content)
|
||||||
|
path
|
||||||
|
end
|
||||||
|
|
||||||
|
def upstream_package(path)
|
||||||
|
Picopackage::FileProvider.new(path).package
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TestMerge < Minitest::Test
|
||||||
|
BASE = "one\ntwo\nthree\n"
|
||||||
|
|
||||||
|
def test_a_backend_is_available
|
||||||
|
assert Picopackage::Merge.available?, "expected git or diff3 on PATH"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_non_overlapping_changes_merge_cleanly
|
||||||
|
result = Picopackage::Merge.three_way(
|
||||||
|
base: BASE,
|
||||||
|
ours: "ONE\ntwo\nthree\n",
|
||||||
|
theirs: "one\ntwo\nTHREE\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_predicate result, :clean?
|
||||||
|
assert_equal "ONE\ntwo\nTHREE\n", result.content
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_identical_changes_on_both_sides_merge_cleanly
|
||||||
|
changed = "one\nTWO\nthree\n"
|
||||||
|
result = Picopackage::Merge.three_way(base: BASE, ours: changed, theirs: changed)
|
||||||
|
|
||||||
|
assert_predicate result, :clean?
|
||||||
|
assert_equal changed, result.content
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_overlapping_changes_conflict
|
||||||
|
result = Picopackage::Merge.three_way(
|
||||||
|
base: BASE,
|
||||||
|
ours: "one\nMINE\nthree\n",
|
||||||
|
theirs: "one\nTHEIRS\nthree\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_predicate result, :conflict?
|
||||||
|
assert_operator result.conflicts, :>, 0
|
||||||
|
assert_includes result.content, "MINE"
|
||||||
|
assert_includes result.content, "THEIRS"
|
||||||
|
assert_includes result.content, "<<<<<<<"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_labels_appear_in_conflict_markers
|
||||||
|
result = Picopackage::Merge.three_way(
|
||||||
|
base: BASE,
|
||||||
|
ours: "one\nMINE\nthree\n",
|
||||||
|
theirs: "one\nTHEIRS\nthree\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_includes result.content, "local"
|
||||||
|
assert_includes result.content, "upstream"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TestPackage < Minitest::Test
|
||||||
|
include PicopackageTest
|
||||||
|
|
||||||
|
BARE = "def hello\n :world\nend\n"
|
||||||
|
|
||||||
|
def test_it_has_a_version_number
|
||||||
|
refute_nil ::Picopackage::VERSION
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_a_bare_file_is_bare
|
||||||
|
assert Picopackage::Package.new(content: BARE).bare?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_a_generated_package_parses_back_as_a_package
|
||||||
|
package = Picopackage::Package.new(content: BARE).init_metadata(filename: "hello.rb")
|
||||||
|
round_tripped = Picopackage::Package.new(content: package.generate_package)
|
||||||
|
|
||||||
|
refute_predicate round_tripped, :bare?
|
||||||
|
assert_equal "hello.rb", round_tripped.filename
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_reads_metadata_from_a_packaged_file
|
||||||
|
package = Picopackage::Package.from_file("test/files/uniquify_array_packaged.rb")
|
||||||
|
assert_equal "uniquify_array.rb", package.filename
|
||||||
|
assert_equal "https://api.github.com/gists/525ac4177964549ee2f2ca7febd03eea", package.url
|
||||||
|
end
|
||||||
|
|
||||||
|
# This was the bug that made every file look locally modified: the checksum
|
||||||
|
# written into a package hashed the un-normalised payload while verification
|
||||||
|
# hashed the normalised one, so nothing ever verified against itself.
|
||||||
|
def test_a_freshly_packaged_file_verifies_against_its_own_checksum
|
||||||
|
package = Picopackage::Package.new(content: BARE).init_metadata(filename: "hello.rb")
|
||||||
|
written = Picopackage::Package.new(content: package.generate_package)
|
||||||
|
|
||||||
|
assert written.verify_payload, "a package should satisfy its own checksum"
|
||||||
|
refute_predicate written, :modified?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_payload_digest_is_stable_across_packaging
|
||||||
|
bare = Picopackage::Package.new(content: BARE)
|
||||||
|
packaged = Picopackage::Package.new(
|
||||||
|
content: Picopackage::Package.new(content: BARE).init_metadata(filename: "hello.rb").generate_package
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal bare.payload_digest, packaged.payload_digest,
|
||||||
|
"adding a metadata block must not change the payload digest"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_editing_a_payload_is_detected
|
||||||
|
packaged = Picopackage::Package.new(content: BARE).init_metadata(filename: "hello.rb").generate_package
|
||||||
|
edited = Picopackage::Package.new(content: packaged.sub(":world", ":everyone"))
|
||||||
|
|
||||||
|
assert_predicate edited, :modified?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_unmodelled_metadata_keys_survive_a_rewrite
|
||||||
|
content = <<~RUBY
|
||||||
|
code_here
|
||||||
|
|
||||||
|
# @PICOPACKAGE_START
|
||||||
|
# ---
|
||||||
|
# filename: thing.rb
|
||||||
|
# licence: MIT
|
||||||
|
# test_url: https://example.com/thing_test.rb
|
||||||
|
# @PICOPACKAGE_END
|
||||||
|
RUBY
|
||||||
|
|
||||||
|
package = Picopackage::Package.new(content: content)
|
||||||
|
assert_equal "MIT", package.metadata.extra["licence"]
|
||||||
|
|
||||||
|
rewritten = Picopackage::Package.new(content: package.generate_package)
|
||||||
|
assert_equal "MIT", rewritten.metadata.extra["licence"]
|
||||||
|
assert_equal "https://example.com/thing_test.rb", rewritten.metadata.extra["test_url"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# A checksum is derived, never preserved. An author who edits a packaged file
|
||||||
|
# by hand and re-runs the tool must get a block describing what is now there,
|
||||||
|
# not the value that happened to already be sitting in it.
|
||||||
|
def test_a_stale_recorded_checksum_is_corrected_on_write
|
||||||
|
content = "code\n\n# @PICOPACKAGE_START\n# ---\n# filename: thing.rb\n" \
|
||||||
|
"# payload_checksum: sha256:#{"de" * 32}\n# @PICOPACKAGE_END\n"
|
||||||
|
|
||||||
|
stale = Picopackage::Package.new(content: content)
|
||||||
|
refute stale.verify_payload, "precondition: the recorded checksum is wrong"
|
||||||
|
|
||||||
|
rewritten = Picopackage::Package.new(content: stale.generate_package)
|
||||||
|
assert rewritten.verify_payload
|
||||||
|
refute_predicate rewritten, :modified?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_a_block_of_only_unmodelled_keys_is_not_treated_as_bare
|
||||||
|
content = "code\n\n# @PICOPACKAGE_START\n# ---\n# licence: MIT\n# @PICOPACKAGE_END\n"
|
||||||
|
refute_predicate Picopackage::Package.new(content: content), :bare?,
|
||||||
|
"a block we don't fully understand is still a block"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_malformed_front_matter_does_not_raise
|
||||||
|
package = Picopackage::Package.from_file("test/files/broken_front_matter_1.rb")
|
||||||
|
refute_nil package
|
||||||
|
assert_predicate package.metadata, :empty?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class TestPicopackage < Minitest::Test
|
|
||||||
def test_that_it_has_a_version_number
|
|
||||||
refute_nil ::Picopackage::VERSION
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_it_can_load_a_picopackage_file
|
|
||||||
sf = Picopackage::FileProvider.new(File.read("test/files/uniquify_array_packaged.rb"))
|
|
||||||
assert_equal "uniquify_array_packaged.rb", sf.metadata.filename
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_it_can_create_a_picopackage_from_bare_file
|
|
||||||
sf = Picopackage::FileProvider.new(File.read("test/files/uniquify_array_bare.rb"))
|
|
||||||
assert_equal "uniquify_array_bare.rb", sf.metadata.filename
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Two captured responses for the same gist, one revision apart: v2 prepends a
|
||||||
|
# `# Comments` line and carries a later `updated_at`. Real API shapes, no
|
||||||
|
# network, and enough of a difference to drive an update through the resolver.
|
||||||
|
class TestGithubGistProvider < Minitest::Test
|
||||||
|
include PicopackageTest
|
||||||
|
|
||||||
|
GIST_URL = "https://gist.github.com/dkam/a06926360de8edf108be8591368ce1fb"
|
||||||
|
|
||||||
|
StubFetcher = Struct.new(:body) do
|
||||||
|
def fetch(_uri) = body
|
||||||
|
end
|
||||||
|
|
||||||
|
def gist(revision)
|
||||||
|
Picopackage::GithubGistProvider.new(
|
||||||
|
GIST_URL, fetcher: StubFetcher.new(File.read("test/files/gist_#{revision}.json"))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_resolves_the_page_url_to_the_api
|
||||||
|
assert_equal "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb",
|
||||||
|
gist("v1").url.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_extracts_the_file_from_the_json_envelope
|
||||||
|
package = gist("v1").package
|
||||||
|
|
||||||
|
assert_equal "ipv6_in_sqlite.rb", package.filename
|
||||||
|
assert_includes package.payload, "def ipv6"
|
||||||
|
refute_includes package.payload, "raw_url", "the JSON envelope must not leak into the payload"
|
||||||
|
assert_predicate package, :bare?, "the gist carries no metadata block of its own"
|
||||||
|
end
|
||||||
|
|
||||||
|
# The gist's own revision time, not the moment we happened to fetch it.
|
||||||
|
def test_uses_the_gists_updated_at_as_the_timestamp
|
||||||
|
assert_equal "2025-01-25T00:07:09Z", gist("v1").package.payload_timestamp
|
||||||
|
assert_equal "2025-01-25T00:08:48Z", gist("v2").package.payload_timestamp
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_a_new_revision_is_a_different_payload
|
||||||
|
refute_equal gist("v1").package.payload_digest, gist("v2").package.payload_digest
|
||||||
|
end
|
||||||
|
|
||||||
|
# End to end against a real upstream shape: install v1, edit it, take v2.
|
||||||
|
def test_updating_across_revisions_keeps_local_edits
|
||||||
|
with_dirs do |_upstream, project, cache|
|
||||||
|
local_path = File.join(project, "ipv6_in_sqlite.rb")
|
||||||
|
assert_equal :installed, resolve(gist("v1").package, nil, local_path, cache).state
|
||||||
|
|
||||||
|
File.write(local_path, File.read(local_path).sub("where(ipv6_high:", "where!(ipv6_high:"))
|
||||||
|
result = resolve(gist("v2").package, Picopackage::Package.from_file(local_path), local_path, cache)
|
||||||
|
|
||||||
|
assert_equal :merged, result.state, result.message
|
||||||
|
merged = File.read(local_path)
|
||||||
|
assert_includes merged, "where!(ipv6_high:", "local edit should survive"
|
||||||
|
assert_includes merged, "# Comments", "the new revision should be applied"
|
||||||
|
refute_includes merged, "<<<<<<<"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def resolve(remote, local, path, cache)
|
||||||
|
Picopackage::Resolver.new(remote, local, path, cache: cache).resolve
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TestResolver < Minitest::Test
|
||||||
|
include PicopackageTest
|
||||||
|
|
||||||
|
V1 = <<~RUBY
|
||||||
|
module Poller
|
||||||
|
INTERVAL = 30
|
||||||
|
|
||||||
|
def self.run
|
||||||
|
fetch
|
||||||
|
end
|
||||||
|
end
|
||||||
|
RUBY
|
||||||
|
|
||||||
|
# Upstream carrying its own block is optional, not required — but when it does,
|
||||||
|
# only some of the block is upstream's to assert. This one is stale, as any
|
||||||
|
# author who edits without re-running the tool will ship.
|
||||||
|
PACKAGED = V1 + <<~RUBY
|
||||||
|
# @PICOPACKAGE_START
|
||||||
|
# ---
|
||||||
|
# url: https://example.com/canonical/poller.rb
|
||||||
|
# filename: upstream_name.rb
|
||||||
|
# payload_version: 1.4.0
|
||||||
|
# payload_checksum: sha256:#{"de" * 32}
|
||||||
|
# licence: MIT
|
||||||
|
# @PICOPACKAGE_END
|
||||||
|
RUBY
|
||||||
|
|
||||||
|
def resolve(upstream_path, project, cache, force: false, filename: nil)
|
||||||
|
Picopackage::Fetch.fetch(upstream_path, project, force: force, filename: filename, cache: cache)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_installs_when_nothing_is_there
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
result = resolve(path, project, cache)
|
||||||
|
|
||||||
|
assert_equal :installed, result.state
|
||||||
|
installed = Picopackage::Package.from_file(File.join(project, "poller.rb"))
|
||||||
|
refute_predicate installed, :bare?
|
||||||
|
assert_equal path, installed.url
|
||||||
|
assert installed.verify_payload
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# A bare upstream file is a valid picopackage source: nobody has to adopt the
|
||||||
|
# format for their code to be installable. Everything the block would have
|
||||||
|
# said gets derived from the fetch instead.
|
||||||
|
def test_installs_from_an_upstream_with_no_metadata_block
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
assert_predicate upstream_package(path), :bare?, "precondition: upstream carries no block"
|
||||||
|
|
||||||
|
assert_equal :installed, resolve(path, project, cache).state
|
||||||
|
installed = Picopackage::Package.from_file(File.join(project, "poller.rb"))
|
||||||
|
assert installed.verify_payload
|
||||||
|
assert_equal path, installed.url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_publisher_claims_are_adopted_and_local_records_are_recomputed
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", PACKAGED)
|
||||||
|
|
||||||
|
assert_equal :installed, resolve(path, project, cache).state
|
||||||
|
|
||||||
|
# Claims: upstream's, and worth more than anything we could infer. The
|
||||||
|
# declared filename beats the url's basename, so the file lands as the
|
||||||
|
# author named it rather than as whatever the mirror called it.
|
||||||
|
local_path = File.join(project, "upstream_name.rb")
|
||||||
|
assert_path_exists local_path
|
||||||
|
installed = Picopackage::Package.from_file(local_path)
|
||||||
|
assert_equal "https://example.com/canonical/poller.rb", installed.url,
|
||||||
|
"the canonical url should outlive the mirror we fetched from"
|
||||||
|
assert_equal "1.4.0", installed.payload_version
|
||||||
|
assert_equal "MIT", installed.metadata.extra["licence"]
|
||||||
|
|
||||||
|
# Records: ours, describing this copy on this disk.
|
||||||
|
assert installed.verify_payload, "a freshly installed file must verify, whatever upstream claimed"
|
||||||
|
refute_predicate installed, :modified?
|
||||||
|
refute_predicate installed, :diverged?
|
||||||
|
assert_equal "upstream_name.rb", installed.filename, "filename records where it actually landed"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_a_stale_upstream_checksum_does_not_break_a_later_merge
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", PACKAGED)
|
||||||
|
resolve(path, project, cache)
|
||||||
|
local_path = File.join(project, "upstream_name.rb")
|
||||||
|
|
||||||
|
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||||
|
File.write(path, PACKAGED.sub(" fetch\n", " fetch\n prune\n"))
|
||||||
|
|
||||||
|
result = resolve(path, project, cache)
|
||||||
|
|
||||||
|
assert_equal :merged, result.state, result.message
|
||||||
|
merged = File.read(local_path)
|
||||||
|
assert_includes merged, "INTERVAL = 5"
|
||||||
|
assert_includes merged, "prune"
|
||||||
|
assert_equal "MIT", Picopackage::Package.from_file(local_path).metadata.extra["licence"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_reinstalling_the_same_thing_is_a_no_op
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
resolve(path, project, cache)
|
||||||
|
before = File.read(File.join(project, "poller.rb"))
|
||||||
|
|
||||||
|
result = resolve(path, project, cache)
|
||||||
|
|
||||||
|
assert_equal :current, result.state
|
||||||
|
assert_equal before, File.read(File.join(project, "poller.rb")), "an up-to-date file should not be rewritten"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The pleasant case: a file you pasted in by hand months ago gets its
|
||||||
|
# provenance attached without its content being touched.
|
||||||
|
def test_adopts_a_bare_local_file_with_identical_content
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
File.write(File.join(project, "poller.rb"), V1)
|
||||||
|
|
||||||
|
result = resolve(path, project, cache)
|
||||||
|
|
||||||
|
assert_equal :adopted, result.state
|
||||||
|
adopted = Picopackage::Package.from_file(File.join(project, "poller.rb"))
|
||||||
|
refute_predicate adopted, :bare?
|
||||||
|
assert_equal Picopackage::Payload.normalize(V1), adopted.payload
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_fast_forwards_an_unmodified_package
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
resolve(path, project, cache)
|
||||||
|
|
||||||
|
File.write(path, V1.sub("INTERVAL = 30", "INTERVAL = 60"))
|
||||||
|
result = resolve(path, project, cache)
|
||||||
|
|
||||||
|
assert_equal :updated, result.state
|
||||||
|
assert_includes File.read(File.join(project, "poller.rb")), "INTERVAL = 60"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The headline capability: local edits and upstream edits both survive.
|
||||||
|
def test_merges_local_edits_with_upstream_changes
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
resolve(path, project, cache)
|
||||||
|
|
||||||
|
local_path = File.join(project, "poller.rb")
|
||||||
|
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5 # tuned for us"))
|
||||||
|
|
||||||
|
File.write(path, V1.sub(" fetch\n", " fetch\n prune\n"))
|
||||||
|
result = resolve(path, project, cache)
|
||||||
|
|
||||||
|
assert_equal :merged, result.state, result.message
|
||||||
|
merged = File.read(local_path)
|
||||||
|
assert_includes merged, "INTERVAL = 5 # tuned for us", "local edit should survive"
|
||||||
|
assert_includes merged, "prune", "upstream change should be applied"
|
||||||
|
refute_includes merged, "<<<<<<<"
|
||||||
|
|
||||||
|
package = Picopackage::Package.from_file(local_path)
|
||||||
|
assert package.verify_payload, "a merged file should verify against its own recorded checksum"
|
||||||
|
refute_equal package.payload_checksum, package.base_checksum,
|
||||||
|
"base_checksum should still point at upstream, not at the merged result"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# A merged file must remain updatable: the base pointer has to advance to the
|
||||||
|
# upstream payload, not to the merge result, or the next merge replays old
|
||||||
|
# upstream changes as conflicts.
|
||||||
|
def test_a_merged_file_can_be_updated_again
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
resolve(path, project, cache)
|
||||||
|
local_path = File.join(project, "poller.rb")
|
||||||
|
|
||||||
|
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||||
|
File.write(path, V1.sub(" fetch\n", " fetch\n prune\n"))
|
||||||
|
assert_equal :merged, resolve(path, project, cache).state
|
||||||
|
|
||||||
|
File.write(path, V1.sub(" fetch\n", " fetch\n prune\n report\n"))
|
||||||
|
result = resolve(path, project, cache)
|
||||||
|
|
||||||
|
assert_equal :merged, result.state, result.message
|
||||||
|
merged = File.read(local_path)
|
||||||
|
assert_includes merged, "INTERVAL = 5"
|
||||||
|
assert_includes merged, "report"
|
||||||
|
refute_includes merged, "<<<<<<<"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Editing locally while upstream stands still is the common case, and there is
|
||||||
|
# nothing to merge: upstream is still the ancestor we branched from.
|
||||||
|
def test_local_edits_with_no_upstream_change_are_left_alone
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
resolve(path, project, cache)
|
||||||
|
local_path = File.join(project, "poller.rb")
|
||||||
|
|
||||||
|
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||||
|
before = File.read(local_path)
|
||||||
|
|
||||||
|
result = resolve(path, project, cache)
|
||||||
|
|
||||||
|
assert_equal :current, result.state, result.message
|
||||||
|
assert_equal before, File.read(local_path), "an edited file with no upstream change must not be rewritten"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Repopulating the merge base from an unchanged upstream is the whole reason
|
||||||
|
# that branch still touches the cache.
|
||||||
|
def test_an_unchanged_upstream_restores_a_lost_merge_base
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
resolve(path, project, cache)
|
||||||
|
local_path = File.join(project, "poller.rb")
|
||||||
|
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||||
|
|
||||||
|
# A fresh machine, or a cleared cache: the ancestor is gone.
|
||||||
|
empty = Picopackage::Cache.new(root: File.join(Dir.mktmpdir, "empty"))
|
||||||
|
assert_equal :current, resolve(path, project, empty).state
|
||||||
|
|
||||||
|
# Upstream now moves. The base recovered above makes this mergeable.
|
||||||
|
File.write(path, V1.sub(" fetch\n", " fetch\n prune\n"))
|
||||||
|
result = resolve(path, project, empty)
|
||||||
|
|
||||||
|
assert_equal :merged, result.state, result.message
|
||||||
|
assert_includes File.read(local_path), "INTERVAL = 5"
|
||||||
|
assert_includes File.read(local_path), "prune"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_conflicting_edits_leave_the_original_alone
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
resolve(path, project, cache)
|
||||||
|
local_path = File.join(project, "poller.rb")
|
||||||
|
|
||||||
|
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||||
|
before = File.read(local_path)
|
||||||
|
|
||||||
|
File.write(path, V1.sub("INTERVAL = 30", "INTERVAL = 90"))
|
||||||
|
result = resolve(path, project, cache)
|
||||||
|
|
||||||
|
assert_equal :conflict, result.state
|
||||||
|
assert_equal before, File.read(local_path), "the user's file must not be touched"
|
||||||
|
|
||||||
|
markers = local_path + Picopackage::Resolver::MERGE_SUFFIX
|
||||||
|
assert_path_exists markers
|
||||||
|
assert_includes File.read(markers), "<<<<<<<"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_force_discards_local_changes
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
resolve(path, project, cache)
|
||||||
|
local_path = File.join(project, "poller.rb")
|
||||||
|
|
||||||
|
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||||
|
File.write(path, V1.sub("INTERVAL = 30", "INTERVAL = 90"))
|
||||||
|
|
||||||
|
result = resolve(path, project, cache, force: true)
|
||||||
|
|
||||||
|
assert_equal :updated, result.state
|
||||||
|
assert_includes File.read(local_path), "INTERVAL = 90"
|
||||||
|
refute_includes File.read(local_path), "INTERVAL = 5"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_an_unrelated_bare_file_with_the_same_name_is_refused
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
File.write(File.join(project, "poller.rb"), "# something else entirely\n")
|
||||||
|
|
||||||
|
result = resolve(path, project, cache)
|
||||||
|
|
||||||
|
assert_equal :conflict, result.state
|
||||||
|
assert_equal "# something else entirely\n", File.read(File.join(project, "poller.rb"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_a_modified_file_with_no_cached_ancestor_is_refused
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
resolve(path, project, cache)
|
||||||
|
local_path = File.join(project, "poller.rb")
|
||||||
|
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||||
|
File.write(path, V1.sub(" fetch\n", " fetch\n prune\n"))
|
||||||
|
|
||||||
|
empty_cache = Picopackage::Cache.new(root: File.join(Dir.mktmpdir, "empty"))
|
||||||
|
result = resolve(path, project, empty_cache)
|
||||||
|
|
||||||
|
assert_equal :conflict, result.state
|
||||||
|
assert_match(/cache/, result.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Renaming a package locally is allowed; updating it must not install a second
|
||||||
|
# copy under upstream's preferred name.
|
||||||
|
def test_update_writes_back_to_the_local_filename
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
resolve(path, project, cache)
|
||||||
|
FileUtils.mv(File.join(project, "poller.rb"), File.join(project, "my_poller.rb"))
|
||||||
|
|
||||||
|
File.write(path, V1.sub("INTERVAL = 30", "INTERVAL = 60"))
|
||||||
|
result = resolve(path, project, cache, filename: "my_poller.rb")
|
||||||
|
|
||||||
|
assert_equal :updated, result.state
|
||||||
|
refute_path_exists File.join(project, "poller.rb"), "should not have created a second copy"
|
||||||
|
assert_includes File.read(File.join(project, "my_poller.rb")), "INTERVAL = 60"
|
||||||
|
assert_equal "my_poller.rb", Picopackage::Package.from_file(File.join(project, "my_poller.rb")).filename
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_timestamps_never_override_the_checksum
|
||||||
|
with_dirs do |upstream, project, cache|
|
||||||
|
path = publish(upstream, "poller.rb", V1)
|
||||||
|
resolve(path, project, cache)
|
||||||
|
local_path = File.join(project, "poller.rb")
|
||||||
|
before = File.read(local_path)
|
||||||
|
|
||||||
|
# Same bytes, much newer mtime — the old timestamp-driven logic called
|
||||||
|
# this an update and rewrote the file.
|
||||||
|
FileUtils.touch(path, mtime: Time.now + 86_400)
|
||||||
|
result = resolve(path, project, cache)
|
||||||
|
|
||||||
|
assert_equal :current, result.state
|
||||||
|
assert_equal before, File.read(local_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user