ruby_wasm_ic_installer 0.1.0

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.

Potentially problematic release.


This version of ruby_wasm_ic_installer might be problematic. Click here for more details.

@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyWasmIcInstaller
4
+ # A file's magic comment, against the twelve encodings a `minimal` canister
5
+ # knows, each by one name and no alias (W26; ADR-0053's amendment of
6
+ # 28 October 2026). The comment is read in C before any Ruby runs, so no
7
+ # stand-in can help: an alias of one of the twelve is rewritten to its
8
+ # canonical name, found with MRI's own Encoding.find; any other encoding
9
+ # refuses the gem (W29, Q9). A change to the table is a release of this gem.
10
+ module MagicComment
11
+ CANISTER_ENCODINGS = %w[
12
+ ASCII-8BIT UTF-8 US-ASCII UTF-16BE UTF-16LE UTF-32BE UTF-32LE UTF-16 UTF-32 UTF8-MAC EUC-JP Windows-31J
13
+ ].freeze
14
+
15
+ # Ruby reads a magic comment on the first line, or the second after a
16
+ # shebang. `coding: x`, `coding=x`, `-*- coding: x -*-` and
17
+ # `encoding: x` all match.
18
+ PATTERN = /\A(#.*?coding[:=][ \t]*)([\w.\-]+)/
19
+
20
+ Rewrite = Struct.new(:file, :line, :from, :to, keyword_init: true)
21
+
22
+ class Refused < Error; end
23
+
24
+ module_function
25
+
26
+ # [new_body, rewrite or nil]; raises Refused for an encoding a canister
27
+ # lacks or one MRI does not know.
28
+ def fix(file, body)
29
+ lines = body.b.lines
30
+ idx = comment_line(lines) or return [body, nil]
31
+ m = lines[idx].match(PATTERN)
32
+ name = m[2]
33
+ canonical = canonical!(file, name)
34
+ return [body, nil] if canonical.casecmp?(name)
35
+
36
+ lines[idx] = lines[idx].sub(PATTERN) { "#{m[1]}#{canonical.downcase}" }
37
+ [lines.join, Rewrite.new(file: file, line: idx + 1, from: name, to: canonical.downcase)]
38
+ end
39
+
40
+ def comment_line(lines)
41
+ return 0 if lines[0]&.match?(PATTERN)
42
+ return 1 if lines[0]&.start_with?("#!") && lines[1]&.match?(PATTERN)
43
+
44
+ nil
45
+ end
46
+
47
+ def canonical!(file, name)
48
+ enc = begin
49
+ Encoding.find(name)
50
+ rescue ArgumentError
51
+ raise Refused, "#{file} names the encoding #{name.inspect}, which Ruby does not know"
52
+ end
53
+ known = CANISTER_ENCODINGS.find { _1.casecmp?(enc.name) }
54
+ return known if known
55
+
56
+ raise Refused, "#{file} is written in #{enc.name} (its magic comment says #{name.inspect}), which a " \
57
+ "minimal canister does not have: it knows #{CANISTER_ENCODINGS.join(', ')}, and transcodes nothing"
58
+ end
59
+ end
60
+ 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyWasmIcInstaller
4
+ VERSION = "0.1.0"
5
+ 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.0
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: []