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 +7 -0
- data/LICENSE +21 -0
- data/README.md +189 -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 +60 -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
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,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
|