ruby_wasm_ic_installer 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +195 -0
- data/exe/ruby_wasm_ic_installer +6 -0
- data/lib/ruby_wasm_ic_installer/app_setup.rb +176 -0
- data/lib/ruby_wasm_ic_installer/candid.rb +262 -0
- data/lib/ruby_wasm_ic_installer/canister.rb +208 -0
- data/lib/ruby_wasm_ic_installer/cli.rb +108 -0
- data/lib/ruby_wasm_ic_installer/diff.rb +73 -0
- data/lib/ruby_wasm_ic_installer/gem_files.rb +125 -0
- data/lib/ruby_wasm_ic_installer/installer.rb +94 -0
- data/lib/ruby_wasm_ic_installer/lockfile.rb +63 -0
- data/lib/ruby_wasm_ic_installer/magic_comment.rb +72 -0
- data/lib/ruby_wasm_ic_installer/plan.rb +84 -0
- data/lib/ruby_wasm_ic_installer/runner.rb +59 -0
- data/lib/ruby_wasm_ic_installer/version.rb +5 -0
- data/lib/ruby_wasm_ic_installer.rb +21 -0
- metadata +65 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyWasmIcInstaller
|
|
4
|
+
# A file's magic comment, against the three encodings a `minimal` canister
|
|
5
|
+
# claims: UTF-8, US-ASCII and ASCII-8BIT (ADR-0053). The comment is read in
|
|
6
|
+
# C before any Ruby runs, so no stand-in can help: an alias of one of the
|
|
7
|
+
# three is rewritten to its canonical name, found with MRI's own
|
|
8
|
+
# Encoding.find (W29, Q9), and any other encoding refuses the gem.
|
|
9
|
+
#
|
|
10
|
+
# That includes the nine other names a canister lists (UTF-16 and UTF-32
|
|
11
|
+
# and their byte orders, UTF8-MAC, EUC-JP, Windows-31J): they are names
|
|
12
|
+
# with no encoding behind them, so a file whose comment names one loads
|
|
13
|
+
# with no error and is read as ASCII-8BIT, its literals wrong (W30, 28
|
|
14
|
+
# October 2026). A change to the table is a release of this gem.
|
|
15
|
+
module MagicComment
|
|
16
|
+
CLAIMED_ENCODINGS = %w[ASCII-8BIT UTF-8 US-ASCII].freeze
|
|
17
|
+
|
|
18
|
+
# Listed by a `minimal` canister and not claimed: refused with their own
|
|
19
|
+
# reason, since an author may expect them to work.
|
|
20
|
+
UNCLAIMED_ENCODINGS = %w[UTF-16BE UTF-16LE UTF-32BE UTF-32LE UTF-16 UTF-32 UTF8-MAC EUC-JP Windows-31J].freeze
|
|
21
|
+
|
|
22
|
+
# Ruby reads a magic comment on the first line, or the second after a
|
|
23
|
+
# shebang. `coding: x`, `coding=x`, `-*- coding: x -*-` and
|
|
24
|
+
# `encoding: x` all match.
|
|
25
|
+
PATTERN = /\A(#.*?coding[:=][ \t]*)([\w.\-]+)/
|
|
26
|
+
|
|
27
|
+
Rewrite = Struct.new(:file, :line, :from, :to, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
class Refused < Error; end
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# [new_body, rewrite or nil]; raises Refused for an encoding a canister
|
|
34
|
+
# lacks or one MRI does not know.
|
|
35
|
+
def fix(file, body)
|
|
36
|
+
lines = body.b.lines
|
|
37
|
+
idx = comment_line(lines) or return [body, nil]
|
|
38
|
+
m = lines[idx].match(PATTERN)
|
|
39
|
+
name = m[2]
|
|
40
|
+
canonical = canonical!(file, name)
|
|
41
|
+
return [body, nil] if canonical.casecmp?(name)
|
|
42
|
+
|
|
43
|
+
lines[idx] = lines[idx].sub(PATTERN) { "#{m[1]}#{canonical.downcase}" }
|
|
44
|
+
[lines.join, Rewrite.new(file: file, line: idx + 1, from: name, to: canonical.downcase)]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def comment_line(lines)
|
|
48
|
+
return 0 if lines[0]&.match?(PATTERN)
|
|
49
|
+
return 1 if lines[0]&.start_with?("#!") && lines[1]&.match?(PATTERN)
|
|
50
|
+
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def canonical!(file, name)
|
|
55
|
+
enc = begin
|
|
56
|
+
Encoding.find(name)
|
|
57
|
+
rescue ArgumentError
|
|
58
|
+
raise Refused, "#{file} names the encoding #{name.inspect}, which Ruby does not know"
|
|
59
|
+
end
|
|
60
|
+
known = CLAIMED_ENCODINGS.find { _1.casecmp?(enc.name) }
|
|
61
|
+
return known if known
|
|
62
|
+
|
|
63
|
+
said = "#{file} is written in #{enc.name} (its magic comment says #{name.inspect})"
|
|
64
|
+
if UNCLAIMED_ENCODINGS.any? { _1.casecmp?(enc.name) }
|
|
65
|
+
raise Refused, "#{said}: a minimal canister lists that name but has no #{enc.name} behind it, so the file " \
|
|
66
|
+
"would load as ASCII-8BIT; only #{CLAIMED_ENCODINGS.join(', ')} are claimed"
|
|
67
|
+
end
|
|
68
|
+
raise Refused, "#{said}, which a minimal canister does not have: it has #{CLAIMED_ENCODINGS.join(', ')}, " \
|
|
69
|
+
"and transcodes nothing"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyWasmIcInstaller
|
|
4
|
+
# What one install run sends (W29, Q10): every prepared gem the canister
|
|
5
|
+
# lacks, whole, packed into install_put batches by bytes, each followed by
|
|
6
|
+
# an install_commit for the gems it completed; then one install_commit that
|
|
7
|
+
# writes the setup file, unless the pending one already says the same.
|
|
8
|
+
#
|
|
9
|
+
# A batch is at most `budget` bytes of Candid (2 MiB is the ingress limit;
|
|
10
|
+
# W28 saw 2,093,069 bytes pass and 2,101,455 refused at submission, so the
|
|
11
|
+
# default leaves room for the envelope icp-cli adds). A gem bigger than a
|
|
12
|
+
# batch spans several; .partial holds it until its commit.
|
|
13
|
+
class Plan
|
|
14
|
+
DEFAULT_BUDGET = 2_000_000
|
|
15
|
+
|
|
16
|
+
Step = Struct.new(:method, :args, :note, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
attr_reader :steps, :sent, :generation
|
|
19
|
+
|
|
20
|
+
# `prepared`: GemFiles::Prepared in the lock's order (stand-ins already
|
|
21
|
+
# left out). `installed`: the canister's installed() record.
|
|
22
|
+
def initialize(prepared, installed, budget: DEFAULT_BUDGET)
|
|
23
|
+
@budget = budget
|
|
24
|
+
have = installed[:gems]
|
|
25
|
+
@sent = prepared.reject { have.include?(_1.name_version) }
|
|
26
|
+
@steps = []
|
|
27
|
+
# A version half-sent by an earlier run is cleared first: its files are
|
|
28
|
+
# resent whole, so nothing stale is committed with them.
|
|
29
|
+
(installed[:partial] & @sent.map(&:name_version)).each do |nv|
|
|
30
|
+
@steps << Step.new(method: "install_remove", args: Candid.text_arg(nv), note: "clear #{nv} from .partial")
|
|
31
|
+
end
|
|
32
|
+
batch!
|
|
33
|
+
setup!(prepared, installed)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def empty? = @steps.empty?
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def batch!
|
|
41
|
+
files = []
|
|
42
|
+
size = 0
|
|
43
|
+
done = []
|
|
44
|
+
flush = lambda do
|
|
45
|
+
next if files.empty?
|
|
46
|
+
|
|
47
|
+
@steps << Step.new(method: "install_put", args: Candid.install_files(files),
|
|
48
|
+
note: "#{files.size} files, #{files.sum { _1[:body].bytesize }} bytes")
|
|
49
|
+
unless done.empty?
|
|
50
|
+
@steps << Step.new(method: "install_commit", args: Candid.install_commit(gems: done),
|
|
51
|
+
note: "commit #{done.join(', ')}")
|
|
52
|
+
end
|
|
53
|
+
files = []
|
|
54
|
+
size = 0
|
|
55
|
+
done = []
|
|
56
|
+
end
|
|
57
|
+
@sent.each do |gem|
|
|
58
|
+
gem.files.each do |rel, body|
|
|
59
|
+
path = "#{gem.name_version}/#{rel}"
|
|
60
|
+
one = Candid.install_file_size(path, body)
|
|
61
|
+
if one + Candid.install_files_overhead(1) > @budget
|
|
62
|
+
raise Error, "#{path} is #{body.bytesize} bytes, more than one call can carry (#{@budget})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
flush.call if size + one + Candid.install_files_overhead(files.size + 1) > @budget
|
|
66
|
+
files << { path: path, body: body }
|
|
67
|
+
size += one
|
|
68
|
+
end
|
|
69
|
+
done << gem.name_version
|
|
70
|
+
end
|
|
71
|
+
flush.call
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def setup!(prepared, installed)
|
|
75
|
+
paths = prepared.flat_map { |g| g.require_paths.map { "#{g.name_version}/#{_1}" } }
|
|
76
|
+
return if installed[:pending] && installed[:load_paths] == paths
|
|
77
|
+
|
|
78
|
+
@generation = [installed[:pending], installed[:running]].compact.max.to_i + 1
|
|
79
|
+
@steps << Step.new(method: "install_commit",
|
|
80
|
+
args: Candid.install_commit(gems: [], setup: { generation: @generation, load_paths: paths }),
|
|
81
|
+
note: "write setup generation #{@generation} (#{paths.size} load paths)")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module RubyWasmIcInstaller
|
|
7
|
+
# The one way the off-chain half reaches a canister (W29, Q13):
|
|
8
|
+
# `icp canister call <canister> <method> --args-file <file> --args-format
|
|
9
|
+
# bin -o hex`, with `--query` for a query. The author's icp.yaml,
|
|
10
|
+
# environment, network and identity pass through as `icp_args`; the
|
|
11
|
+
# Installer holds no keys and picks no network. A file carries the
|
|
12
|
+
# arguments because one command-line argument is capped at 128 KiB and a
|
|
13
|
+
# batch is up to 2 MiB.
|
|
14
|
+
#
|
|
15
|
+
# It is also the test seam: the specs give the Installer a runner whose
|
|
16
|
+
# #call answers from a canister held in memory.
|
|
17
|
+
class Runner
|
|
18
|
+
class CallError < Error
|
|
19
|
+
attr_reader :refusal
|
|
20
|
+
|
|
21
|
+
def initialize(message, refusal: nil)
|
|
22
|
+
super(message)
|
|
23
|
+
@refusal = refusal
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# `spawn` is called as spawn.(*argv) and returns [stdout, stderr, status];
|
|
28
|
+
# the default is Open3.capture3.
|
|
29
|
+
def initialize(canister:, icp: "icp", icp_args: [], spawn: nil)
|
|
30
|
+
@canister = canister
|
|
31
|
+
@icp = icp
|
|
32
|
+
@icp_args = icp_args
|
|
33
|
+
@spawn = spawn || Open3.method(:capture3)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The reply's bytes.
|
|
37
|
+
def call(method, args, query: false)
|
|
38
|
+
Tempfile.create(["ruby_wasm_ic_installer", ".bin"], binmode: true) do |f|
|
|
39
|
+
f.write(args)
|
|
40
|
+
f.flush
|
|
41
|
+
argv = [@icp, "canister", "call", @canister, method, "--args-file", f.path, "--args-format", "bin",
|
|
42
|
+
"-o", "hex", *(query ? ["--query"] : []), *@icp_args]
|
|
43
|
+
out, err, status = @spawn.call(*argv)
|
|
44
|
+
raise CallError.new("#{method}: #{err.strip.empty? ? out.strip : err.strip}", refusal: refusal(err)) \
|
|
45
|
+
unless status.success?
|
|
46
|
+
|
|
47
|
+
hex = out.strip
|
|
48
|
+
raise CallError, "#{method}: icp replied #{out.inspect[0, 200]}, not hex" unless hex.match?(/\A(?:[0-9a-fA-F]{2})+\z/)
|
|
49
|
+
|
|
50
|
+
[hex].pack("H*")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# The text after `refused: ` in a trap message, if the canister refused.
|
|
57
|
+
def refusal(err) = err[/refused: (.*?)(?:'|"|$)/, 1]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ruby_wasm_ic_installer: the Installer (W29). Its off-chain half, loaded
|
|
4
|
+
# here, sends an Application's pure-Ruby gems into a Ruby canister packed by
|
|
5
|
+
# ruby_wasm_builder; its canister half, ruby_wasm_ic_installer/canister.rb,
|
|
6
|
+
# is carried by the Application and is never loaded by this file.
|
|
7
|
+
module RubyWasmIcInstaller
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
require_relative "ruby_wasm_ic_installer/version"
|
|
12
|
+
require_relative "ruby_wasm_ic_installer/candid"
|
|
13
|
+
require_relative "ruby_wasm_ic_installer/lockfile"
|
|
14
|
+
require_relative "ruby_wasm_ic_installer/magic_comment"
|
|
15
|
+
require_relative "ruby_wasm_ic_installer/gem_files"
|
|
16
|
+
require_relative "ruby_wasm_ic_installer/plan"
|
|
17
|
+
require_relative "ruby_wasm_ic_installer/runner"
|
|
18
|
+
require_relative "ruby_wasm_ic_installer/installer"
|
|
19
|
+
require_relative "ruby_wasm_ic_installer/diff"
|
|
20
|
+
require_relative "ruby_wasm_ic_installer/app_setup"
|
|
21
|
+
require_relative "ruby_wasm_ic_installer/cli"
|
metadata
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby_wasm_ic_installer
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- the ruby_wasm_builder authors
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-28 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: 'The Installer for canisters packed by ruby_wasm_builder: reads an Application''s
|
|
14
|
+
Gemfile.lock, fetches its pure-Ruby gems and sends the ones a canister lacks through
|
|
15
|
+
`icp canister call`, into /data, where a carried canister half loads them at the
|
|
16
|
+
next boot. `setup` wires the canister half into an Application.'
|
|
17
|
+
email:
|
|
18
|
+
executables:
|
|
19
|
+
- ruby_wasm_ic_installer
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- exe/ruby_wasm_ic_installer
|
|
26
|
+
- lib/ruby_wasm_ic_installer.rb
|
|
27
|
+
- lib/ruby_wasm_ic_installer/app_setup.rb
|
|
28
|
+
- lib/ruby_wasm_ic_installer/candid.rb
|
|
29
|
+
- lib/ruby_wasm_ic_installer/canister.rb
|
|
30
|
+
- lib/ruby_wasm_ic_installer/cli.rb
|
|
31
|
+
- lib/ruby_wasm_ic_installer/diff.rb
|
|
32
|
+
- lib/ruby_wasm_ic_installer/gem_files.rb
|
|
33
|
+
- lib/ruby_wasm_ic_installer/installer.rb
|
|
34
|
+
- lib/ruby_wasm_ic_installer/lockfile.rb
|
|
35
|
+
- lib/ruby_wasm_ic_installer/magic_comment.rb
|
|
36
|
+
- lib/ruby_wasm_ic_installer/plan.rb
|
|
37
|
+
- lib/ruby_wasm_ic_installer/runner.rb
|
|
38
|
+
- lib/ruby_wasm_ic_installer/version.rb
|
|
39
|
+
homepage: https://github.com/graial/ruby_wasm_ic_installer
|
|
40
|
+
licenses:
|
|
41
|
+
- MIT
|
|
42
|
+
metadata:
|
|
43
|
+
source_code_uri: https://github.com/graial/ruby_wasm_ic_installer
|
|
44
|
+
rubygems_mfa_required: 'true'
|
|
45
|
+
post_install_message:
|
|
46
|
+
rdoc_options: []
|
|
47
|
+
require_paths:
|
|
48
|
+
- lib
|
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.2'
|
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
requirements: []
|
|
60
|
+
rubygems_version: 3.5.11
|
|
61
|
+
signing_key:
|
|
62
|
+
specification_version: 4
|
|
63
|
+
summary: Install pure-Ruby gems into a Ruby canister on the Internet Computer, through
|
|
64
|
+
calls
|
|
65
|
+
test_files: []
|