So many changes. Some tests
This commit is contained in:
18
README.md
18
README.md
@@ -1,28 +1,16 @@
|
|||||||
# Picopackage
|
# Picopackage
|
||||||
|
|
||||||
TODO: Delete this and the text below, and describe your gem
|
A command line tool for installing and managing [Picopackages](https://picopackage.org).
|
||||||
|
|
||||||
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/picopackge`. To experiment with that code, run `bin/console` for an interactive prompt.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
|
|
||||||
|
|
||||||
Install the gem and add to the application's Gemfile by executing:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
gem install picopackage
|
||||||
```
|
|
||||||
|
|
||||||
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
TODO: Write usage instructions here
|
`picopackage install <url|filepath>`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require_relative "picopackage/version"
|
require_relative "picopackage/version"
|
||||||
# require_relative "picopackage/http_fetcher"
|
|
||||||
require_relative "picopackage/provider"
|
|
||||||
require_relative "picopackage/source_file"
|
|
||||||
require_relative "picopackage/scanner"
|
|
||||||
require_relative "picopackage/fetch"
|
require_relative "picopackage/fetch"
|
||||||
|
require_relative "picopackage/provider"
|
||||||
|
require_relative "picopackage/package"
|
||||||
|
require_relative "picopackage/scanner"
|
||||||
require_relative "picopackage/cli"
|
require_relative "picopackage/cli"
|
||||||
|
|
||||||
module Picopackage
|
module Picopackage
|
||||||
|
|||||||
@@ -15,20 +15,20 @@ module Picopackage
|
|||||||
dir = argv.first || "."
|
dir = argv.first || "."
|
||||||
Picopackage::Scanner.scan(dir).each { |f| puts f.file_path }
|
Picopackage::Scanner.scan(dir).each { |f| puts f.file_path }
|
||||||
|
|
||||||
when "digest"
|
when "init"
|
||||||
OptionParser.new do |opts|
|
OptionParser.new do |opts|
|
||||||
opts.banner = "Usage: ppkg digest FILE"
|
opts.banner = "Usage: ppkg init FILE"
|
||||||
end.parse!(argv)
|
end.parse!(argv)
|
||||||
|
|
||||||
file = argv.first
|
file = argv.first
|
||||||
Picopackage::SourceFile.from_file(file).digest!
|
Picopackage::Package.from_file(file).init_metadata
|
||||||
|
|
||||||
when "checksum"
|
when "checksum"
|
||||||
OptionParser.new do |opts|
|
OptionParser.new do |opts|
|
||||||
opts.banner = "Usage: ppkg checksum FILE"
|
opts.banner = "Usage: ppkg checksum FILE"
|
||||||
end.parse!(argv)
|
end.parse!(argv)
|
||||||
file = argv.first
|
file = argv.first
|
||||||
puts Picopackage::SourceFile.from_file(file).checksum
|
puts Picopackage::Package.from_file(file).checksum
|
||||||
|
|
||||||
when "verify"
|
when "verify"
|
||||||
OptionParser.new do |opts|
|
OptionParser.new do |opts|
|
||||||
@@ -36,7 +36,7 @@ module Picopackage
|
|||||||
end.parse!(argv)
|
end.parse!(argv)
|
||||||
|
|
||||||
path = argv.first
|
path = argv.first
|
||||||
source = SourceFile.from_file(path)
|
source = Package.from_file(path)
|
||||||
|
|
||||||
if source.metadata["content_checksum"].nil?
|
if source.metadata["content_checksum"].nil?
|
||||||
puts "⚠️ No checksum found in #{path}"
|
puts "⚠️ No checksum found in #{path}"
|
||||||
@@ -44,7 +44,7 @@ module Picopackage
|
|||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
unless source.verify
|
unless source.verify_payload
|
||||||
puts "❌ Checksum verification failed for #{path}"
|
puts "❌ Checksum verification failed for #{path}"
|
||||||
puts "Expected: #{source.metadata["content_checksum"]}"
|
puts "Expected: #{source.metadata["content_checksum"]}"
|
||||||
puts "Got: #{source.checksum}"
|
puts "Got: #{source.checksum}"
|
||||||
@@ -59,7 +59,7 @@ module Picopackage
|
|||||||
end.parse!(argv)
|
end.parse!(argv)
|
||||||
|
|
||||||
path = argv.first
|
path = argv.first
|
||||||
Picopackage::SourceFile.from_file(path).inspect
|
Picopackage::Package.from_file(path).inspect_metadata
|
||||||
|
|
||||||
when "fetch"
|
when "fetch"
|
||||||
options = {force: false}
|
options = {force: false}
|
||||||
@@ -93,9 +93,9 @@ module Picopackage
|
|||||||
end.parse!(argv)
|
end.parse!(argv)
|
||||||
|
|
||||||
file = argv.first
|
file = argv.first
|
||||||
source_file = SourceFile.from_file(file)
|
package = Package.from_file(file)
|
||||||
begin
|
begin
|
||||||
Fetch.fetch(source_file.url, File.dirname(file), force: options[:force])
|
Fetch.fetch(package.url, File.dirname(file), force: options[:force])
|
||||||
rescue LocalModificationError => e
|
rescue LocalModificationError => e
|
||||||
puts "Error: #{e.message}"
|
puts "Error: #{e.message}"
|
||||||
rescue => e
|
rescue => e
|
||||||
@@ -105,7 +105,7 @@ module Picopackage
|
|||||||
|
|
||||||
else
|
else
|
||||||
puts "Unknown command: #{command}"
|
puts "Unknown command: #{command}"
|
||||||
puts "Available commands: scan, sign, inspect, update"
|
puts "Available commands: fetch, update, scan, sign, inspect"
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
rescue OptionParser::InvalidOption => e
|
rescue OptionParser::InvalidOption => e
|
||||||
@@ -116,5 +116,25 @@ module Picopackage
|
|||||||
puts e.backtrace if ENV["DEBUG"]
|
puts e.backtrace if ENV["DEBUG"]
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.determine_script_source
|
||||||
|
# Get the full path of the currently executing script
|
||||||
|
current_path = File.expand_path($0)
|
||||||
|
|
||||||
|
# Check if script is in GEM_PATH
|
||||||
|
gem_paths = Gem.path.map { |p| File.expand_path(p) }
|
||||||
|
|
||||||
|
is_gem = gem_paths.any? { |path| current_path.start_with?(path) }
|
||||||
|
|
||||||
|
if is_gem
|
||||||
|
# Running from gem installation
|
||||||
|
gem_name = File.basename(File.dirname(File.dirname(current_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
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,98 +6,162 @@ require "debug"
|
|||||||
|
|
||||||
module Picopackage
|
module Picopackage
|
||||||
class Fetch
|
class Fetch
|
||||||
|
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
|
||||||
|
|
||||||
|
def initialize(max_size: 1024 * 1024, timeout: 10)
|
||||||
|
@max_size = max_size
|
||||||
|
@timeout = timeout
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch(uri)
|
||||||
|
case uri.scheme
|
||||||
|
when "http", "https" then fetch_http(uri)
|
||||||
|
when "file" then fetch_file(uri)
|
||||||
|
else
|
||||||
|
raise Error, "Unsupported scheme: #{uri.scheme}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.fetch(url, destination, force: false)
|
def self.fetch(url, destination, force: false)
|
||||||
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)
|
||||||
source_file = provider.source_file
|
package = provider.package
|
||||||
|
file_path = File.join(destination, package.filename)
|
||||||
|
|
||||||
file_path = File.join(destination, source_file.filename)
|
local_package = File.exist?(file_path) ? FileProvider.new(file_path).package : nil
|
||||||
|
|
||||||
if File.exist?(file_path) && force
|
resolver = Resolver.new(package, local_package, file_path, force: force).resolve
|
||||||
source_file.save(destination)
|
|
||||||
elsif File.exist?(file_path)
|
|
||||||
local_source_file = SourceFile.from_file(file_path)
|
|
||||||
status = Status.compare(local_source_file, source_file)
|
|
||||||
|
|
||||||
if force
|
case resolver[:state]
|
||||||
source_file.save(destination)
|
when :kept, :updated
|
||||||
elsif status.modified?
|
puts resolver[:message]
|
||||||
raise LocalModificationError, "#{status.message}. Use -f or --force to overwrite local version"
|
when :conflict
|
||||||
elsif status.outdated?
|
raise LocalModificationError, resolver[:message]
|
||||||
puts "Updated from #{local_source_file.version} to #{source_file.version}"
|
end
|
||||||
source_file.save(destination)
|
provider.package
|
||||||
elsif status.up_to_date?
|
|
||||||
puts status.message
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_http(uri, etag = nil)
|
||||||
|
Net::HTTP.start(uri.host, uri.port, connection_options(uri)) do |http|
|
||||||
|
request = Net::HTTP::Get.new(uri.request_uri)
|
||||||
|
request["If-None-Match"] = etag if etag
|
||||||
|
|
||||||
|
response = http.request(request)
|
||||||
|
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
|
||||||
|
when Net::HTTPSuccess
|
||||||
|
{
|
||||||
|
body: read_body(response),
|
||||||
|
etag: response["ETag"]
|
||||||
|
}
|
||||||
|
when Net::HTTPNotModified
|
||||||
|
raise NotModifiedError.new("Resource not modified", etag: response["ETag"])
|
||||||
|
when Net::HTTPRedirection
|
||||||
|
handle_redirect(response, uri)
|
||||||
else
|
else
|
||||||
source_file.save(destination)
|
raise HTTPError, "HTTP #{response.code}: #{response.message}"
|
||||||
if source_file.imported?
|
end
|
||||||
source_file.digest!
|
end
|
||||||
puts "Picopackage created for #{source_file.filename}"
|
|
||||||
|
def handle_redirect(response, uri, redirect_count = 0)
|
||||||
|
raise TooManyRedirectsError if redirect_count >= MAX_REDIRECTS
|
||||||
|
location = response["location"]
|
||||||
|
new_uri = URI(location)
|
||||||
|
# Handle both relative paths and full URLs
|
||||||
|
new_uri = uri.merge(location) if new_uri.relative?
|
||||||
|
fetch(new_uri, redirect_count: redirect_count + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_body(response)
|
||||||
|
buffer = String.new(capacity: @max_size)
|
||||||
|
response.read_body do |chunk|
|
||||||
|
raise FileTooLargeError, "Response would exceed #{@max_size} bytes" if buffer.bytesize + chunk.bytesize > @max_size
|
||||||
|
buffer << chunk
|
||||||
|
end
|
||||||
|
buffer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# States:
|
||||||
|
# - kept: local file was converted to a picopackage and kept
|
||||||
|
# - updated: local file was updated with remote picopackage
|
||||||
|
# - conflict: local and remote files differ - manually resolve or use -f to force
|
||||||
|
class Resolver
|
||||||
|
attr_reader :remote, :local, :local_path, :force
|
||||||
|
def initialize(remote_package, local_package, local_path, force: false)
|
||||||
|
@remote = remote_package
|
||||||
|
@local = local_package
|
||||||
|
@local_path = local_path
|
||||||
|
@force = force
|
||||||
|
@same_checksum = @remote.payload_checksum == @local&.payload_checksum
|
||||||
|
end
|
||||||
|
|
||||||
|
STATES = %i[kept updated conflict].freeze
|
||||||
|
|
||||||
|
def resolve
|
||||||
|
validate_state_hash(
|
||||||
|
if @force
|
||||||
|
@remote.save(local_path)
|
||||||
|
{state: :updated, message: "Force mode: overwrote local file with remote package"}
|
||||||
|
elsif @local.nil?
|
||||||
|
@remote.save(local_path)
|
||||||
|
{state: :kept, message: "Saved Package as new file"}
|
||||||
|
elsif @remote.payload_version != @local.payload_version
|
||||||
|
{state: :conflict, message: "Version conflict. Local: #{@local.payload_version}, Remote: #{@remote.payload_version}"}
|
||||||
|
elsif @remote.payload_timestamp_as_time > @local.payload_timestamp_as_time
|
||||||
|
@remote.save(local_path)
|
||||||
|
{state: :updated, message: "Updated to newer version"}
|
||||||
|
elsif !@same_checksum
|
||||||
|
handle_checksum_mismatch
|
||||||
|
elsif @local.was_bare_file
|
||||||
|
debugger
|
||||||
|
@local.save(local_path)
|
||||||
|
{state: :kept, message: "Packaged existing file as Picopackage"}
|
||||||
else
|
else
|
||||||
puts "Picopackage downloaded to #{file_path}"
|
{state: :kept, message: "Local file is up to date"}
|
||||||
end
|
|
||||||
end
|
|
||||||
provider.source_file
|
|
||||||
end
|
end
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
class Status
|
private
|
||||||
attr_reader :state, :local_comparison, :remote_comparison
|
|
||||||
|
|
||||||
def self.compare(local_source_file, remote_source_file)
|
def validate_state_hash(hash)
|
||||||
return new(:outdated) if local_source_file.metadata.nil? || remote_source_file.metadata.nil?
|
raise "Invalid state" unless STATES.include?(hash[:state])
|
||||||
|
raise "Missing message" unless hash[:message].is_a?(String)
|
||||||
|
hash
|
||||||
|
end
|
||||||
|
|
||||||
local_comparison = local_source_file.metadata["version"] || local_source_file.metadata["updated_at"]&.to_s
|
def handle_checksum_mismatch
|
||||||
remote_comparison = remote_source_file.metadata["version"] || remote_source_file.metadata["updated_at"]&.to_s
|
if @force
|
||||||
|
@remote.save(local_path) # In force mode, remote wins
|
||||||
if local_comparison == remote_comparison
|
{state: :updated, message: "Overwrote local file with remote package"}
|
||||||
if local_source_file.modified?
|
|
||||||
new(:modified, local_version: local_comparison)
|
|
||||||
else
|
else
|
||||||
new(:up_to_date, local_version: local_comparison)
|
{state: :conflict, message: "Files differ. Use --force to convert both to packages"}
|
||||||
end
|
|
||||||
else
|
|
||||||
new(:outdated,
|
|
||||||
local_version: local_comparison,
|
|
||||||
remote_version: remote_comparison,
|
|
||||||
modified: local_source_file.modified?)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize(state, local_version: nil, remote_version: nil, modified: false)
|
|
||||||
@state = state
|
|
||||||
@local_version = local_version
|
|
||||||
@remote_version = remote_version
|
|
||||||
@modified = modified
|
|
||||||
end
|
|
||||||
|
|
||||||
def modified?
|
|
||||||
@modified || @state == :modified
|
|
||||||
end
|
|
||||||
|
|
||||||
def up_to_date?
|
|
||||||
@state == :up_to_date
|
|
||||||
end
|
|
||||||
|
|
||||||
def outdated?
|
|
||||||
@state == :outdated
|
|
||||||
end
|
|
||||||
|
|
||||||
def message
|
|
||||||
case state
|
|
||||||
when :up_to_date
|
|
||||||
"Picopackage is up to date"
|
|
||||||
when :outdated
|
|
||||||
if modified?
|
|
||||||
"Local Picopackage (v#{local_comparison}) has modifications but remote version (v#{remote_comparison}) is available"
|
|
||||||
else
|
|
||||||
"Local Picopackage (v#{local_comparison}) is outdated. Remote version: v#{remote_comparison}"
|
|
||||||
end
|
|
||||||
when :modified
|
|
||||||
"Local Picopackage has been modified from original version (v#{local_version})"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,92 +1,111 @@
|
|||||||
require "yaml"
|
require "yaml"
|
||||||
require "digest"
|
require "digest"
|
||||||
|
require "forwardable"
|
||||||
|
|
||||||
module Picopackage
|
module Picopackage
|
||||||
class SourceFile
|
|
||||||
attr_reader :content, :metadata, :code, :original_path
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
def self.from_file(file_path) = new(content: File.read(file_path), original_path: file_path)
|
class Metadata < Struct.new(:url, :filename, :payload_version, :payload_timestamp, :payload_checksum, :etag, keyword_init: true)
|
||||||
|
# the #from_file method will create a new instance of Metadata from a file path, rather than read a package's metadata
|
||||||
def self.from_content(content, metadata: {})
|
def self.from_file(file_path, content: nil)
|
||||||
instance = new(content: content)
|
new(content: File.read(file_path))
|
||||||
instance.imported! if instance.metadata.empty?
|
|
||||||
|
|
||||||
updated_metadata = metadata.merge(instance.metadata)
|
|
||||||
|
|
||||||
## For new Picopackages, we should add metadata and checksum
|
|
||||||
instance.update_metadata(updated_metadata)
|
|
||||||
|
|
||||||
instance
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(content:, original_path: nil)
|
def self.from_url_response(url, response)
|
||||||
@original_path = original_path
|
end
|
||||||
|
|
||||||
|
def self.from_content(content)
|
||||||
|
return new unless content =~ METADATA_PATTERN
|
||||||
|
|
||||||
|
yaml_content = $1.each_line.map { |line| line.sub(/^\s*#\s?/, "").rstrip }.join("\n")
|
||||||
|
|
||||||
|
# Load and transform in one chain
|
||||||
|
@metadata = new(**YAML.safe_load(yaml_content)
|
||||||
|
.slice(*Metadata.members.map(&:to_s))
|
||||||
|
.transform_keys(&:to_sym))
|
||||||
|
rescue
|
||||||
|
new # Return empty hash on any YAML/transformation errors
|
||||||
|
end
|
||||||
|
|
||||||
|
def empty? = to_h.values.all?(&:nil?)
|
||||||
|
end
|
||||||
|
|
||||||
|
class Payload
|
||||||
|
def self.from_content(content) = content.sub(METADATA_PATTERN, "")
|
||||||
|
|
||||||
|
def self.normalize(payload) = payload.rstrip + "\n\n"
|
||||||
|
|
||||||
|
def self.normalized_from_content(content) = Payload.from_content(content).then { Payload.normalize(_1) }
|
||||||
|
|
||||||
|
def self.from_file(file_path) = normalized_from_content(File.read(file_path))
|
||||||
|
|
||||||
|
def self.checksum(payload) = "sha256:#{Digest::SHA256.hexdigest(payload)}"
|
||||||
|
|
||||||
|
def self.checksum_from_content(content) = checksum(from_content(content))
|
||||||
|
end
|
||||||
|
|
||||||
|
class Package
|
||||||
|
extend Forwardable
|
||||||
|
attr_reader :content, :payload, :metadata, :was_bare_file
|
||||||
|
|
||||||
|
def_delegators :@metadata,
|
||||||
|
:url, :url=,
|
||||||
|
:filename, :filename=,
|
||||||
|
:payload_version, :payload_version=,
|
||||||
|
:payload_timestamp, :payload_timestamp=,
|
||||||
|
:payload_checksum, :payload_checksum=
|
||||||
|
|
||||||
|
def self.from_file(file_path)
|
||||||
|
if File.exist?(file_path)
|
||||||
|
new(content: File.read(file_path))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(content:)
|
||||||
@content = content
|
@content = content
|
||||||
@code = extract_code
|
@payload = Payload.normalized_from_content(@content)
|
||||||
@metadata = extract_metadata
|
@metadata = Metadata.from_content(@content)
|
||||||
|
|
||||||
|
if is_bare_file?
|
||||||
|
@was_bare_file = true
|
||||||
|
init_metadata
|
||||||
|
else
|
||||||
|
@was_bare_file = false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def imported! = @imported = true
|
def is_bare_file? = @metadata.empty?
|
||||||
|
|
||||||
def imported? = @imported ||= false
|
def init_metadata
|
||||||
|
@metadata.url ||= url
|
||||||
def url = @metadata["url"]
|
@metadata.filename ||= filename
|
||||||
|
@metadata.payload_checksum ||= Payload.checksum_from_content(content)
|
||||||
def filename = @metadata["filename"]
|
@metadata.payload_timestamp ||= payload_timestamp
|
||||||
|
|
||||||
def version = @metadata["version"]
|
|
||||||
|
|
||||||
def packaged_at = @metadata["packaged_at"]
|
|
||||||
|
|
||||||
def checksum = "sha256:#{Digest::SHA256.hexdigest(code)}"
|
|
||||||
|
|
||||||
def inspect_metadata = puts JSON.pretty_generate(@metadata)
|
|
||||||
|
|
||||||
def save(destination = nil)
|
|
||||||
path = determine_save_path(destination)
|
|
||||||
File.write(path, content)
|
|
||||||
path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_code = content.sub(METADATA_PATTERN, "")
|
def save(path, filename = nil)
|
||||||
|
path = File.join(path, filename || @metadata.filename) if File.directory?(path)
|
||||||
|
|
||||||
def extract_metadata
|
File.write(path, generate_package)
|
||||||
return {} unless content =~ METADATA_PATTERN
|
|
||||||
|
|
||||||
yaml_content = $1.lines.map do |line|
|
|
||||||
line.sub(/^\s*#\s?/, "").rstrip
|
|
||||||
end.join("\n")
|
|
||||||
|
|
||||||
YAML.safe_load(yaml_content)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_metadata(metadata_hash)
|
def verify_payload
|
||||||
@metadata = metadata_hash
|
return false if metadata.payload_checksum.nil? || metadata.payload_checksum&.empty?
|
||||||
@content = generate_content
|
Payload.checksum(payload) == metadata.payload_checksum
|
||||||
end
|
end
|
||||||
|
|
||||||
def digest!
|
def payload_timestamp_as_time
|
||||||
hash = checksum
|
@metadata&.payload_timestamp ? Time.parse(@metadata.payload_timestamp) : nil
|
||||||
return puts "File already has a checksum" if metadata["content_checksum"] == hash
|
|
||||||
|
|
||||||
new_metadata = metadata.merge("content_checksum" => hash)
|
|
||||||
update_metadata(new_metadata)
|
|
||||||
save
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify
|
def modified? = !verify_payload
|
||||||
return false unless metadata.key? "content_checksum"
|
|
||||||
checksum == metadata["content_checksum"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def modified? = !verify
|
def inspect_metadata = puts JSON.pretty_generate(@metadata.to_h)
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_content
|
def generate_package
|
||||||
|
@metadata.url = url.to_s
|
||||||
metadata_block = generate_metadata
|
metadata_block = generate_metadata
|
||||||
if METADATA_PATTERN.match?(content)
|
if METADATA_PATTERN.match?(content)
|
||||||
content.sub(METADATA_PATTERN, "\n#{metadata_block}")
|
content.sub(METADATA_PATTERN, "\n#{metadata_block}")
|
||||||
@@ -97,7 +116,7 @@ module Picopackage
|
|||||||
|
|
||||||
# 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_yaml.strip
|
yaml_content = @metadata.to_h.transform_keys(&:to_s).to_yaml.strip
|
||||||
[
|
[
|
||||||
"# @PICOPACKAGE_START",
|
"# @PICOPACKAGE_START",
|
||||||
yaml_content.lines.map { |line| "# #{line}" }.join,
|
yaml_content.lines.map { |line| "# #{line}" }.join,
|
||||||
@@ -105,15 +124,5 @@ module Picopackage
|
|||||||
""
|
""
|
||||||
].join("\n")
|
].join("\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
def determine_save_path(destination)
|
|
||||||
if destination.nil?
|
|
||||||
@original_path || filename || raise("No filename available")
|
|
||||||
elsif File.directory?(destination)
|
|
||||||
File.join(destination, filename || File.basename(@original_path))
|
|
||||||
else
|
|
||||||
destination
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
require "time"
|
require "time"
|
||||||
|
require "pathname"
|
||||||
|
|
||||||
module Picopackage
|
module Picopackage
|
||||||
class Provider
|
class Provider
|
||||||
@@ -25,47 +26,38 @@ module Picopackage
|
|||||||
# The variable `metadata` will contain the metadata extracted from `package_data`
|
# The variable `metadata` will contain the metadata extracted from `package_data`
|
||||||
|
|
||||||
# Job of the Provider class is to fetch the body from the URL, and then extract the package_data
|
# 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 SourceFile class will then take the body and split it into payload and metadata
|
# 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
|
attr_reader :url, :package
|
||||||
|
|
||||||
def self.handles_url?(url) = :maybe
|
def self.handles_url?(url) = :maybe
|
||||||
|
|
||||||
def initialize(url)
|
def initialize(url, fetcher: Fetch.new(max_size: MAX_SIZE, timeout: TIMEOUT))
|
||||||
@url = transform_url(url)
|
@url = transform_url(url)
|
||||||
@uri = URI(@url)
|
@fetcher = fetcher
|
||||||
@body = nil
|
@package = Package.new(content: content)
|
||||||
@content = nil
|
populate_metadata
|
||||||
end
|
end
|
||||||
|
|
||||||
def body = @body ||= fetch
|
def transform_url(url) = URI(url)
|
||||||
|
|
||||||
def json_body = @json_body ||= JSON.parse(body)
|
def body
|
||||||
|
@body ||= @fetcher.fetch(@url)
|
||||||
def transform_url(url) = url
|
rescue Fetch::Error => e
|
||||||
|
raise FetchError, e.message
|
||||||
def fetch
|
|
||||||
Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == "https", read_timeout: TIMEOUT, open_timeout: TIMEOUT) do |http|
|
|
||||||
http.request_get(@uri.path) do |response|
|
|
||||||
raise "Unexpected response: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
||||||
|
|
||||||
@body = String.new(capacity: MAX_SIZE)
|
|
||||||
response.read_body do |chunk|
|
|
||||||
if @body.bytesize + chunk.bytesize > MAX_SIZE
|
|
||||||
raise FileTooLargeError, "Response would exceed #{MAX_SIZE} bytes"
|
|
||||||
end
|
|
||||||
@body << chunk
|
|
||||||
end
|
|
||||||
@body
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@body
|
def json_body
|
||||||
|
@json_body ||= JSON.parse(body)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
raise FetchError, "Failed to parse JSON response"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def payload_timestamp = Time.now.httpdate
|
||||||
|
|
||||||
def handles_body?
|
def handles_body?
|
||||||
true
|
true
|
||||||
rescue FileTooLargeError, Net::HTTPError, RuntimeError
|
rescue FileTooLargeError, Net::HTTPError, RuntimeError
|
||||||
@@ -73,14 +65,17 @@ module Picopackage
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Implement in subclass - this come from the `body`.
|
# Implement in subclass - this come from the `body`.
|
||||||
# Spliting content into payload and metadata is the job of the SourceFile class
|
# Spliting content into payload and metadata is the job of the Package class
|
||||||
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 subclass - this should return the filename extracted from the body - if it exists, but not from the metadata
|
||||||
def filename = File.basename @url
|
def filename = File.basename @url
|
||||||
|
|
||||||
def source_file
|
def populate_metadata
|
||||||
@source_file ||= SourceFile.from_content(content, metadata: {"filename" => filename, "url" => url, "packaged_at" => packaged_at}.compact)
|
@package.filename ||= filename
|
||||||
|
@package.url ||= @url
|
||||||
|
@package.payload_timestamp ||= payload_timestamp
|
||||||
|
@package.payload_checksum ||= Payload.checksum(content)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -96,7 +91,7 @@ module Picopackage
|
|||||||
"https://api.github.com/gists/#{gist_id}"
|
"https://api.github.com/gists/#{gist_id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def packaged_at
|
def payload_timestamp
|
||||||
Time.parse(json_body["created_at"])
|
Time.parse(json_body["created_at"])
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
nil
|
nil
|
||||||
@@ -120,7 +115,24 @@ module Picopackage
|
|||||||
# If we successfully fetch the body, and the body contains content and a filename, then we can handle the body
|
# 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
|
||||||
|
def self.handles_url?(url) = File.exist?(url)
|
||||||
|
|
||||||
|
def transform_url(url) = Pathname(url)
|
||||||
|
|
||||||
|
def content = url.read
|
||||||
|
|
||||||
|
def filename = url.basename.to_s
|
||||||
|
|
||||||
|
def payload_timestamp
|
||||||
|
url.mtime.httpdate
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
PROVIDERS = [
|
PROVIDERS = [
|
||||||
|
FileProvider,
|
||||||
GithubGistProvider,
|
GithubGistProvider,
|
||||||
OpenGistProvider,
|
OpenGistProvider,
|
||||||
DefaultProvider
|
DefaultProvider
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ module Picopackage
|
|||||||
Dir.glob(File.join(directory, pattern)).select do |file|
|
Dir.glob(File.join(directory, pattern)).select do |file|
|
||||||
next unless File.file?(file)
|
next unless File.file?(file)
|
||||||
content = File.read(file)
|
content = File.read(file)
|
||||||
content.match?(SourceFile::METADATA_PATTERN)
|
content.match?(Package::METADATA_PATTERN)
|
||||||
end.map { |file| SourceFile.new(file) }
|
end.map { |file| Package.new(file) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ class TestPicopackage < Minitest::Test
|
|||||||
end
|
end
|
||||||
|
|
||||||
def test_it_can_load_a_picopackage_file
|
def test_it_can_load_a_picopackage_file
|
||||||
sf = Picopackage::SourceFile.from_content(File.read("test/files/uniquify_array.rb"))
|
sf = Picopackage::FileProvider.new(File.read("test/files/uniquify_array_packaged.rb"))
|
||||||
assert_equal sf.metadata["filename"], "uniquify_array.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
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user