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.

checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f2034d8defd5e242028c4055f48b77e5f08207eec658342d200923fe0b4257bb
4
+ data.tar.gz: 6e0ecb432de1e379a7ef8249737b578c5707048964abaeb161e68fab89ce4a32
5
+ SHA512:
6
+ metadata.gz: ff062dcf67d25bd281b022120753aec1e94d126ba1e35bd5bf4028b676df59ddc70779057ba8e70a523d0a936ca3ae3e0acaad893807b7d7bf605b4bf92060e1
7
+ data.tar.gz: 11bd41e69ac06831ea4ab68e25bc6dcf40e4527a16c1f269c2f74b876ea3c4b78959e59722cc038bfd44b842980496887b10f7fa449c9c52a4e18ac459db90a9
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the ruby_wasm_builder authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # ruby_wasm_ic_installer
2
+
3
+ Install pure-Ruby gems into a Ruby canister on the Internet Computer, through
4
+ calls, after it is deployed: the **Installer** for canisters packed by
5
+ [ruby_wasm_builder](https://gitlab.com/phrug/ruby_wasm_builder).
6
+
7
+ A packed Application carries its code in a 1 MiB buffer. Gems installed with
8
+ this gem live in the canister's `/data` instead, so neither the buffer nor the
9
+ module's size bounds them: Rack and Roda whole are 224 files and 1.1 MB, sent
10
+ in one or a few calls, and the canister boots them within 2% of the same
11
+ Application packed (measured on PocketIC; ruby_wasm_builder's W28).
12
+
13
+ ```bash
14
+ ruby_wasm_ic_installer setup my_app # once: wire the Installer into the Application
15
+ # pack and deploy my_app as usual (pack.rb, icp deploy)
16
+ ruby_wasm_ic_installer install my_app # send the Gemfile.lock's gems the canister lacks
17
+ icp deploy --mode upgrade # they load at the next boot
18
+ ```
19
+
20
+ ## What it does
21
+
22
+ **Off chain,** `install` reads the Application's `Gemfile.lock` with Bundler's
23
+ own parser (it resolves nothing: the lock is yours) and, for each locked gem:
24
+
25
+ - fetches its `.gem` for platform `ruby` with `gem fetch` into a cache
26
+ (`~/.cache/ruby_wasm_ic_installer`), checking it against the lock's
27
+ `CHECKSUMS` when the lock has them;
28
+ - **refuses** it if it has a C extension, or is locked only for a native
29
+ platform: a canister cannot load compiled code, so only pure Ruby can be
30
+ installed;
31
+ - **skips** it, saying so, if `ruby_wasm_ic_minimal` is in the lock and stands
32
+ in for it (`stringio`, `strscan`, `date`), since the real ones are C
33
+ extensions;
34
+ - opens only its `require_paths` (normally `lib/`);
35
+ - **rewrites magic comments** naming an alias of an encoding a `minimal`
36
+ canister has (`# encoding: binary` to `ascii-8bit`, `ascii` to `us-ascii`),
37
+ and **refuses** a file in an encoding it lacks (`ISO-8859-1`, `Shift_JIS`).
38
+ A canister knows twelve encodings, each by one name and no alias, and reads
39
+ the comment before any Ruby runs. Every rewrite is in the report.
40
+
41
+ It asks the canister what it has (`installed`), and sends **only the gem
42
+ versions it lacks**, packed into calls of up to 2,000,000 bytes (the IC's
43
+ ingress limit is 2 MiB). Then it writes the canister's **setup file**, last.
44
+ Nothing changes what the canister runs until the **next upgrade**, which loads
45
+ the new setup file; `install` says so and does not upgrade for you.
46
+
47
+ **On the canister,** the canister half keeps one directory per gem version:
48
+
49
+ ```
50
+ /data/gems/<name>-<version>/lib/... a complete version
51
+ /data/gems/.partial/<name>-<version>/ a version still arriving
52
+ /data/setup.rb the setup file: a generation number and the load paths
53
+ ```
54
+
55
+ A version arrives into `.partial` and is renamed into place when complete, so
56
+ a run that stops half way leaves nothing half-installed (the next run clears
57
+ it and sends the version again). The setup file puts each gem's load path on
58
+ `$LOAD_PATH` at boot, in the lock's order, after `/app` (so anything you pack
59
+ still wins) and before Ruby's own library.
60
+
61
+ **Removing** a version is never automatic. `prune` removes every version that
62
+ neither the running boot nor the pending setup file names; the canister
63
+ refuses to remove one that either needs (Rack loads files lazily, through
64
+ `autoload`, long after boot). Until you prune, you can go back by installing
65
+ the previous lock again.
66
+
67
+ **Only the canister's controllers** may install, commit or remove: the
68
+ canister asks the IC whether the caller is one. `--identity` chooses who signs;
69
+ whoever can upgrade the canister can install code into it, and no one else.
70
+ `installed` is a public query: anyone can read which generation runs and which
71
+ gem versions are there.
72
+
73
+ ## Wiring it into an Application: `setup`
74
+
75
+ ```bash
76
+ ruby_wasm_ic_installer setup my_app [--yes | --check]
77
+ ```
78
+
79
+ `setup` makes three changes, each between marker comments
80
+ (`# >>> ruby_wasm_ic_installer` … `# <<< ruby_wasm_ic_installer`):
81
+
82
+ - in **`ic-interface.rb`**, the Installer's four Declarations and their types:
83
+
84
+ ```ruby
85
+ type :InstallFile, record(path: Text, body: Blob)
86
+ type :InstallSetup, record(generation: Nat64, load_paths: Vec[Text])
87
+ type :InstallCommit, record(gems: Vec[Text], setup: Opt[:InstallSetup])
88
+ type :Installed, record(running: Opt[Nat64], pending: Opt[Nat64], gems: Vec[Text], partial: Vec[Text],
89
+ load_paths: Vec[Text], running_paths: Vec[Text])
90
+ update :install_put, takes: [Vec[:InstallFile]], returns: Text
91
+ update :install_commit, takes: [:InstallCommit], returns: Text
92
+ update :install_remove, takes: [Text], returns: Text
93
+ query :installed, takes: [], returns: :Installed
94
+ ```
95
+
96
+ - at the top of **`app.rb`** (after any shebang and magic comments), the
97
+ `require` of the canister half, which defines those four methods and loads
98
+ `/data/setup.rb` if there is one;
99
+ - the canister half itself, copied to **`vendor/ruby_wasm_ic_installer/`**.
100
+ It uses only core Ruby and ruby_wasm_builder's `IC`, and loads on `minimal`.
101
+
102
+ Running it again changes nothing. If the lines between the markers, or the
103
+ vendored file, differ from what `setup` would write (your edit, or an older
104
+ version's), it shows the diff and asks before writing; with no terminal it
105
+ aborts unless `--yes`. If the Installer's names are already declared or
106
+ defined outside the markers, it stops and shows where. `--check` prints the
107
+ diffs and exits 1 if anything would change (for CI).
108
+
109
+ The Application needs an `ic-interface.rb` already (one with no Interface file
110
+ is served `run` alone). Pack and deploy it again after `setup`.
111
+
112
+ Then require your installed gems in `app.rb` as usual, after the Installer's
113
+ block. Before the first install and upgrade they are not there yet, so an
114
+ Application that should boot either way rescues `LoadError`:
115
+
116
+ ```ruby
117
+ APP = begin
118
+ require "ruby_wasm_ic_minimal" # first, on minimal: the Encoding constants and stand-ins
119
+ require "ruby_wasm_ic_rack"
120
+ require "roda"
121
+ # ... your Roda app ...
122
+ rescue LoadError
123
+ nil
124
+ end
125
+ ```
126
+
127
+ ## Commands
128
+
129
+ ```
130
+ ruby_wasm_ic_installer install CANISTER [--lock Gemfile.lock] [--dry-run] [icp options]
131
+ ruby_wasm_ic_installer status CANISTER [icp options]
132
+ ruby_wasm_ic_installer prune CANISTER [--dry-run] [icp options]
133
+ ruby_wasm_ic_installer setup APP_DIR [--yes | --check]
134
+ ```
135
+
136
+ `CANISTER` is the canister's name in your `icp.yaml`, or its principal. icp
137
+ options pass through to `icp canister call`: `-e/--environment`,
138
+ `-n/--network`, `--identity`, `--project-root` (icp's
139
+ `--project-root-override`) and `--icp PATH`. Other options: `--cache DIR`,
140
+ `--budget BYTES`.
141
+
142
+ Every call goes through `icp canister call <canister> <method> --args-file
143
+ <file> --args-format bin -o hex`, so it needs [icp-cli](https://cli.internetcomputer.org)
144
+ 1.6 or later. The gem encodes and decodes its few Candid types itself; it
145
+ holds no keys and picks no network.
146
+
147
+ Exit status: 0 done, 1 refused (by the canister, or by `setup`'s questions),
148
+ 2 a usage error or a gem it cannot install.
149
+
150
+ ## Limits
151
+
152
+ - **Pure Ruby only.** A gem with a C extension reaches a canister only linked
153
+ into its Base Module.
154
+ - **Gems from a gem server only;** a `git` or `path` source in the lock is
155
+ refused.
156
+ - **A single file larger than one call** (about 2 MB) cannot be sent.
157
+ - **The twelve encodings** are those a `minimal` canister was observed to have
158
+ (ruby_wasm_builder 0.6.0 to 0.8.0); a Build with more is a release of this gem.
159
+ - The canister keeps no content hash of what it holds yet: `installed` reports
160
+ versions, not digests.
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ bundle install
166
+ bundle exec rake # every spec on MRI, no network; fails below 95% line coverage
167
+ bundle exec rake e2e # the end-to-end spec on a local icp-cli network
168
+ ```
169
+
170
+ The specs run the real canister half on MRI over a temporary `/data`, behind
171
+ the same seam the command uses to call icp-cli, with real `.gem` files built
172
+ by the specs and a stand-in for ruby_wasm_builder's `IC`. The Candid encoder
173
+ is held byte for byte to what `@dfinity/didc` encodes for the same values
174
+ (`spec/fixtures/didc-vectors.json`).
175
+
176
+ `rake e2e` needs `E2E_BASE` (a published Base Module, 0.8.0 or later) and
177
+ `E2E_PACKER` (that Release's unpacked packer), icp-cli as `icp` or `E2E_ICP`,
178
+ and rubygems.org. It runs `setup` on a Roda Application, packs and deploys it,
179
+ installs Rack, Roda and the two `ruby_wasm_ic` gems from a real lock, upgrades,
180
+ serves a page through them, and checks that a second install sends nothing.
181
+ If icp-cli cannot fetch its network launcher, download it by tag from
182
+ `dfinity/icp-cli-network-launcher`'s releases and set
183
+ `ICP_CLI_NETWORK_LAUNCHER_PATH`.
184
+
185
+ ## Versions
186
+
187
+ - **0.1.0:** the first release.
188
+
189
+ MIT licensed.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/ruby_wasm_ic_installer"
5
+
6
+ exit RubyWasmIcInstaller::CLI.start(ARGV)
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module RubyWasmIcInstaller
6
+ # `ruby_wasm_ic_installer setup <app_dir>` (W29, Q16 and Q17): wires an
7
+ # Application in. It writes a marked block of Declarations into its
8
+ # ic-interface.rb, a marked block at the top of its app.rb that requires the
9
+ # canister half, and the canister half itself under
10
+ # vendor/ruby_wasm_ic_installer/. Idempotent: run twice, it changes nothing
11
+ # the second time.
12
+ #
13
+ # When what is there differs from what it would write (the author's edit,
14
+ # or an older version's block), it shows the diff and asks before writing;
15
+ # with no terminal it aborts unless told `yes`. The Installer's names
16
+ # declared or defined outside the markers abort it, since it cannot merge
17
+ # into the author's own lines. `check` only reports. All files are written,
18
+ # or none.
19
+ class AppSetup
20
+ OPEN = "# >>> ruby_wasm_ic_installer"
21
+ CLOSE = "# <<< ruby_wasm_ic_installer"
22
+ NOTE = " (written by `ruby_wasm_ic_installer setup`; edit outside these lines)"
23
+ BLOCK = /^#{Regexp.escape(OPEN)}.*?^#{Regexp.escape(CLOSE)}\n?/m
24
+ NAMES = %w[install_put install_commit install_remove installed].freeze
25
+ TYPES = %w[InstallFile InstallSetup InstallCommit Installed].freeze
26
+ VENDOR = File.join("vendor", "ruby_wasm_ic_installer")
27
+ CANISTER_SOURCE = File.expand_path("canister.rb", __dir__)
28
+
29
+ INTERFACE = <<~RUBY
30
+ #{OPEN}#{NOTE}
31
+ type :InstallFile, record(path: Text, body: Blob)
32
+ type :InstallSetup, record(generation: Nat64, load_paths: Vec[Text])
33
+ type :InstallCommit, record(gems: Vec[Text], setup: Opt[:InstallSetup])
34
+ type :Installed, record(running: Opt[Nat64], pending: Opt[Nat64], gems: Vec[Text], partial: Vec[Text],
35
+ load_paths: Vec[Text], running_paths: Vec[Text])
36
+ update :install_put, takes: [Vec[:InstallFile]], returns: Text
37
+ update :install_commit, takes: [:InstallCommit], returns: Text
38
+ update :install_remove, takes: [Text], returns: Text
39
+ query :installed, takes: [], returns: :Installed
40
+ #{CLOSE}
41
+ RUBY
42
+
43
+ APP = <<~RUBY
44
+ #{OPEN}#{NOTE}
45
+ $LOAD_PATH.unshift(File.join(__dir__, "#{VENDOR}"))
46
+ require "ruby_wasm_ic_installer/canister"
47
+ #{CLOSE}
48
+ RUBY
49
+
50
+ Change = Struct.new(:path, :old, :new, :edited, keyword_init: true) do
51
+ def diff(root)
52
+ rel = path.delete_prefix("#{root}/")
53
+ Diff.unified(old.to_s, new, from: old ? "a/#{rel}" : "/dev/null", to: "b/#{rel}")
54
+ end
55
+ end
56
+
57
+ class Aborted < Error; end
58
+
59
+ # `io`: where it writes; `input`: where it reads an answer; `tty`: whether
60
+ # it may ask.
61
+ def initialize(app_dir, io: $stdout, input: $stdin, tty: nil)
62
+ @root = File.expand_path(app_dir)
63
+ @io = io
64
+ @input = input
65
+ @tty = tty.nil? ? input.respond_to?(:tty?) && input.tty? : tty
66
+ end
67
+
68
+ def changes
69
+ @changes ||= begin
70
+ check_application!
71
+ [interface_change, app_change, vendor_change].compact
72
+ end
73
+ end
74
+
75
+ # Exit status for --check: 0 when nothing would change.
76
+ def check
77
+ changes.each { @io.puts(_1.diff(@root)) }
78
+ @io.puts(changes.empty? ? "setup is current in #{@root}" : "setup would change #{changes.size} file(s)")
79
+ changes.empty? ? 0 : 1
80
+ end
81
+
82
+ def run(yes: false)
83
+ if changes.empty?
84
+ @io.puts("nothing to change: #{@root} is set up")
85
+ return []
86
+ end
87
+ edited = changes.select(&:edited)
88
+ unless edited.empty?
89
+ @io.puts("These lines differ from what setup writes (your edit, or an older setup's):")
90
+ edited.each { @io.puts(_1.diff(@root)) }
91
+ confirm!(yes)
92
+ end
93
+ changes.each do |c|
94
+ FileUtils.mkdir_p(File.dirname(c.path))
95
+ File.write(c.path, c.new)
96
+ @io.puts("wrote #{c.path.delete_prefix("#{@root}/")}")
97
+ end
98
+ @io.puts("pack the Application again, and deploy it, for the Installer's methods to exist")
99
+ changes
100
+ end
101
+
102
+ private
103
+
104
+ def check_application!
105
+ raise Error, "#{@root} has no app.rb: it is not an Application" unless File.file?(file("app.rb"))
106
+ return if File.file?(file("ic-interface.rb"))
107
+
108
+ raise Error, "#{@root} has no ic-interface.rb: the Installer's methods are Declarations, and an " \
109
+ "Application without an Interface file is served `run` alone (ADR-0056); write one first"
110
+ end
111
+
112
+ def file(name) = File.join(@root, name)
113
+
114
+ def interface_change
115
+ path = file("ic-interface.rb")
116
+ old = File.read(path)
117
+ conflicts!(path, old, /^\s*(?:update|query)\s+:(#{NAMES.join('|')})\b|^\s*type\s+:(#{TYPES.join('|')})\b/)
118
+ replace_or_add(path, old, INTERFACE, at_top: false)
119
+ end
120
+
121
+ def app_change
122
+ path = file("app.rb")
123
+ old = File.read(path)
124
+ conflicts!(path, old, /^\s*def\s+(?:self\.)?(#{NAMES.join('|')})\b/)
125
+ replace_or_add(path, old, APP, at_top: true)
126
+ end
127
+
128
+ def vendor_change
129
+ path = File.join(@root, VENDOR, "ruby_wasm_ic_installer", "canister.rb")
130
+ new = File.read(CANISTER_SOURCE)
131
+ old = File.file?(path) ? File.read(path) : nil
132
+ return nil if old == new
133
+
134
+ Change.new(path: path, old: old, new: new, edited: !old.nil?)
135
+ end
136
+
137
+ # The Installer's names outside the markers: abort, with where they are.
138
+ def conflicts!(path, text, pattern)
139
+ outside = text.gsub(BLOCK) { |b| "\n" * b.count("\n") }
140
+ hits = outside.lines.each_with_index.select { |l, _| l.match?(pattern) }
141
+ return if hits.empty?
142
+
143
+ where = hits.map { |l, i| " #{path.delete_prefix("#{@root}/")}:#{i + 1}: #{l.strip}" }.join("\n")
144
+ raise Aborted, "the Installer's names are already declared or defined outside its markers, and setup cannot " \
145
+ "merge into your own lines; remove them, or move them inside the markers:\n#{where}"
146
+ end
147
+
148
+ def replace_or_add(path, old, block, at_top:)
149
+ if old.match?(BLOCK)
150
+ new = old.sub(BLOCK, block)
151
+ return nil if new == old
152
+
153
+ return Change.new(path: path, old: old, new: new, edited: true)
154
+ end
155
+ new = at_top ? insert_at_top(old, block) : "#{old}#{old.end_with?("\n") || old.empty? ? '' : "\n"}\n#{block}"
156
+ Change.new(path: path, old: old, new: new, edited: false)
157
+ end
158
+
159
+ # After a shebang and any magic comments, which must stay first.
160
+ def insert_at_top(old, block)
161
+ lines = old.lines
162
+ n = 0
163
+ n += 1 while n < lines.size && (lines[n].start_with?("#!") || lines[n].match?(/\A#.*(?:coding[:=]|frozen_string_literal|warn_indent|shareable_constant_value)/))
164
+ (lines[0, n] + [block] + lines[n..]).join
165
+ end
166
+
167
+ def confirm!(yes)
168
+ return if yes
169
+ raise Aborted, "aborted: no terminal to ask on; run again with --yes to write these changes" unless @tty
170
+
171
+ @io.print("Apply? [y/N] ")
172
+ answer = @input.gets.to_s.strip.downcase
173
+ raise Aborted, "aborted: nothing was written" unless %w[y yes].include?(answer)
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyWasmIcInstaller
4
+ # The off-chain half's Candid: only the few fixed types the Installer's own
5
+ # Declarations use, encoded and decoded by hand, so the gem needs no Candid
6
+ # library. Arguments go to `icp canister call --args-format bin` as these
7
+ # bytes, and replies come back from `-o hex`.
8
+ #
9
+ # Encoding writes exactly the types below. Decoding reads any well-formed
10
+ # message whose values are records, opts, vecs, text, blob, bool and the
11
+ # unsigned integers, and gives records as Hashes keyed by field hash (named
12
+ # through #record, below), which is all the canister half replies with.
13
+ module Candid
14
+ class Error < StandardError; end
15
+
16
+ MAGIC = "DIDL".b
17
+
18
+ # Type opcodes (the Candid spec's negative type codes, as SLEB128).
19
+ NULL = -1
20
+ BOOL = -2
21
+ NAT = -3
22
+ NAT8 = -5
23
+ NAT16 = -6
24
+ NAT32 = -7
25
+ NAT64 = -8
26
+ TEXT = -15
27
+ OPT = -18
28
+ VEC = -19
29
+ RECORD = -20
30
+
31
+ module_function
32
+
33
+ def leb(n)
34
+ raise Error, "cannot encode a negative number as LEB128: #{n}" if n.negative?
35
+
36
+ out = +"".b
37
+ loop do
38
+ byte = n & 0x7f
39
+ n >>= 7
40
+ out << (n.zero? ? byte : byte | 0x80).chr
41
+ return out if n.zero?
42
+ end
43
+ end
44
+
45
+ def sleb(n)
46
+ out = +"".b
47
+ loop do
48
+ byte = n & 0x7f
49
+ n >>= 7
50
+ done = (n.zero? && byte & 0x40 == 0) || (n == -1 && byte & 0x40 != 0)
51
+ out << (done ? byte : byte | 0x80).chr
52
+ return out if done
53
+ end
54
+ end
55
+
56
+ # The IDL hash of a field name.
57
+ def idl_hash(name)
58
+ name.to_s.bytes.reduce(0) { |h, b| (h * 223 + b) % (2**32) }
59
+ end
60
+
61
+ def text_value(s)
62
+ s = s.to_s
63
+ raise Error, "text must be valid UTF-8: #{s.inspect[0, 60]}" unless s.dup.force_encoding("UTF-8").valid_encoding?
64
+
65
+ leb(s.bytesize) + s.b
66
+ end
67
+
68
+ def blob_value(bytes) = leb(bytes.bytesize) + bytes.b
69
+
70
+ # (vec record { path : text; body : blob }): one install_put batch.
71
+ def install_files(files)
72
+ # The table in didc's order, depth first from the argument, so the
73
+ # bytes equal @dfinity/didc's for the same value (spec/fixtures).
74
+ table = types(
75
+ "\x6d".b + sleb(1), # 0: vec InstallFile
76
+ record_type([["path", sleb(TEXT)], ["body", sleb(2)]]), # 1: InstallFile
77
+ "\x6d".b + sleb(NAT8) # 2: blob
78
+ )
79
+ values = leb(files.size) + files.map do |f|
80
+ record_value("path" => text_value(f.fetch(:path)), "body" => blob_value(f.fetch(:body)))
81
+ end.join.b
82
+ MAGIC + table + leb(1) + sleb(0) + values
83
+ end
84
+
85
+ # The size install_files would give one file inside a batch: what the
86
+ # batching counts against a call's budget.
87
+ def install_file_size(path, body)
88
+ leb(path.bytesize).bytesize + path.bytesize + leb(body.bytesize).bytesize + body.bytesize
89
+ end
90
+
91
+ # The fixed bytes of an install_files message holding `count` files.
92
+ def install_files_overhead(count) = install_files([]).bytesize - 1 + leb(count).bytesize
93
+
94
+ # (text)
95
+ def text_arg(s) = MAGIC + leb(0) + leb(1) + sleb(TEXT) + text_value(s)
96
+
97
+ # ()
98
+ def empty_args = MAGIC + leb(0) + leb(0)
99
+
100
+ # (record { gems : vec text; setup : opt record { generation : nat64;
101
+ # load_paths : vec text } }): one install_commit.
102
+ def install_commit(gems:, setup: nil)
103
+ table = types(
104
+ record_type([["gems", sleb(1)], ["setup", sleb(2)]]), # 0: InstallCommit
105
+ "\x6d".b + sleb(TEXT), # 1: vec text
106
+ "\x6e".b + sleb(3), # 2: opt InstallSetup
107
+ record_type([["generation", sleb(NAT64)], ["load_paths", sleb(1)]]) # 3: InstallSetup
108
+ )
109
+ setup_value =
110
+ if setup
111
+ "\x01".b + record_value("generation" => nat64_value(setup.fetch(:generation)),
112
+ "load_paths" => texts_value(setup.fetch(:load_paths)))
113
+ else
114
+ "\x00".b
115
+ end
116
+ MAGIC + table + leb(1) + sleb(0) + record_value("gems" => texts_value(gems), "setup" => setup_value)
117
+ end
118
+
119
+ def texts_value(list) = leb(list.size) + list.map { text_value(_1) }.join.b
120
+
121
+ def nat64_value(n)
122
+ raise Error, "nat64 out of range: #{n}" unless n.is_a?(Integer) && n.between?(0, 2**64 - 1)
123
+
124
+ [n].pack("Q<")
125
+ end
126
+
127
+ def types(*entries) = leb(entries.size) + entries.join.b
128
+
129
+ # A record type entry, its fields sorted by hash.
130
+ def record_type(fields)
131
+ sorted = fields.sort_by { |name, _| idl_hash(name) }
132
+ "\x6c".b + leb(sorted.size) + sorted.map { |name, t| leb(idl_hash(name)) + t }.join.b
133
+ end
134
+
135
+ # A record value: its fields' bytes in hash order.
136
+ def record_value(fields) = fields.sort_by { |name, _| idl_hash(name) }.map(&:last).join.b
137
+
138
+ # Decodes a reply of one value; records come back keyed by field hash.
139
+ def decode(bytes) = Reader.new(bytes).message
140
+
141
+ # A decoded record's fields by name.
142
+ def record(value, *names)
143
+ raise Error, "expected a record, got #{value.class}" unless value.is_a?(Hash)
144
+
145
+ names.to_h do |name|
146
+ h = idl_hash(name)
147
+ raise Error, "the record has no field #{name}" unless value.key?(h)
148
+
149
+ [name.to_sym, value[h]]
150
+ end
151
+ end
152
+
153
+ # The one-message reader. Types are resolved from the type table; values
154
+ # are read by them. Recursion is bounded, so a hostile reply cannot run
155
+ # it away.
156
+ class Reader
157
+ MAX_DEPTH = 32
158
+
159
+ def initialize(bytes)
160
+ @b = bytes.b
161
+ @i = 0
162
+ end
163
+
164
+ def message
165
+ raise Error, "not a Candid message (no DIDL header)" unless take(4) == MAGIC
166
+
167
+ @table = Array.new(uleb) { entry }
168
+ args = Array.new(uleb) { sleb_read }
169
+ values = args.map { |t| value(t, 0) }
170
+ raise Error, "#{@b.bytesize - @i} trailing bytes after the message" unless @i == @b.bytesize
171
+
172
+ values
173
+ end
174
+
175
+ private
176
+
177
+ def take(n)
178
+ raise Error, "the message ends early" if @i + n > @b.bytesize
179
+
180
+ @b.byteslice(@i, n).tap { @i += n }
181
+ end
182
+
183
+ def byte = take(1).ord
184
+
185
+ def uleb
186
+ n = 0
187
+ shift = 0
188
+ loop do
189
+ b = byte
190
+ n |= (b & 0x7f) << shift
191
+ return n if b < 0x80
192
+
193
+ shift += 7
194
+ raise Error, "a LEB128 number longer than 10 bytes" if shift > 63
195
+ end
196
+ end
197
+
198
+ def sleb_read
199
+ n = 0
200
+ shift = 0
201
+ loop do
202
+ b = byte
203
+ n |= (b & 0x7f) << shift
204
+ shift += 7
205
+ if b < 0x80
206
+ n -= (1 << shift) if b & 0x40 != 0
207
+ return n
208
+ end
209
+ raise Error, "a LEB128 number longer than 10 bytes" if shift > 63
210
+ end
211
+ end
212
+
213
+ def entry
214
+ case (op = sleb_read)
215
+ when OPT, VEC then [op, sleb_read]
216
+ when RECORD then [op, Array.new(uleb) { [uleb, sleb_read] }]
217
+ else raise Error, "type #{op} is not one the Installer reads"
218
+ end
219
+ end
220
+
221
+ def value(t, depth)
222
+ raise Error, "nested past #{MAX_DEPTH}" if depth > MAX_DEPTH
223
+ return primitive(t) if t.negative?
224
+
225
+ op, inner = @table.fetch(t) { raise Error, "type index #{t} is not in the table" }
226
+ case op
227
+ when OPT
228
+ case byte
229
+ when 0 then nil
230
+ when 1 then value(inner, depth + 1)
231
+ else raise Error, "an opt tag that is neither 0 nor 1"
232
+ end
233
+ when VEC
234
+ n = uleb
235
+ return take(n) if inner == NAT8
236
+
237
+ Array.new(n) { value(inner, depth + 1) }
238
+ when RECORD
239
+ inner.to_h { |h, ft| [h, value(ft, depth + 1)] }
240
+ end
241
+ end
242
+
243
+ def primitive(t)
244
+ case t
245
+ when NULL then nil
246
+ when BOOL then byte == 1
247
+ when NAT then uleb
248
+ when NAT8 then byte
249
+ when NAT16 then take(2).unpack1("S<")
250
+ when NAT32 then take(4).unpack1("L<")
251
+ when NAT64 then take(8).unpack1("Q<")
252
+ when TEXT
253
+ s = take(uleb).force_encoding("UTF-8")
254
+ raise Error, "text that is not UTF-8" unless s.valid_encoding?
255
+
256
+ s
257
+ else raise Error, "type #{t} is not one the Installer reads"
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end