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,208 @@
1
+ # ruby_wasm_ic_installer/canister.rb: the Installer's canister half, carried
2
+ # by an Application (packed under /app, so it is there before anything is
3
+ # installed) and required at the top of its app.rb. `ruby_wasm_ic_installer
4
+ # setup <app_dir>` writes that require and the Declarations it serves.
5
+ #
6
+ # Core Ruby and the builder's IC only (IC.caller, IC.refuse, IC::DATA_DIR):
7
+ # it loads on a `minimal` canister, so it names no Encoding constant and
8
+ # requires nothing.
9
+ #
10
+ # The layout it keeps (W29, Q7 to Q11):
11
+ # /data/gems/<name>-<version>/... one directory per gem version, complete
12
+ # /data/gems/.partial/<name>-<version>/ a version still arriving
13
+ # /data/setup.rb the setup file: its generation and the
14
+ # load paths, required at boot
15
+ # A version arrives into .partial by install_put and is renamed into place by
16
+ # install_commit, which also writes the setup file, last, once every gem it
17
+ # names is complete. A new setup file takes effect at the next boot. A version
18
+ # is removed only by install_remove, and never while the running boot or the
19
+ # pending one names it. Every method is for the canister's controllers only.
20
+ module RubyWasmIcInstaller
21
+ module Canister
22
+ # A gem version's directory: a gem name, a dash, a version.
23
+ NAME_VERSION = /\A[A-Za-z0-9][A-Za-z0-9_.\-]*-[0-9][A-Za-z0-9_.\-]*\z/
24
+ SETUP_HEAD = "# generated by ruby_wasm_ic_installer: do not edit, it is rewritten by install_commit\n"
25
+ GENERATION_LINE = /\A# generation: (\d+)$/
26
+
27
+ class << self
28
+ # The running generation (the setup file this boot loaded), or nil.
29
+ attr_reader :running_generation
30
+
31
+ def data_dir = @data_dir || IC::DATA_DIR
32
+ attr_writer :data_dir
33
+
34
+ def gems_dir = File.join(data_dir, "gems")
35
+ def partial_dir = File.join(gems_dir, ".partial")
36
+ def setup_file = File.join(data_dir, "setup.rb")
37
+
38
+ # At boot: load the setup file if there is one. It calls #setup below.
39
+ def boot!
40
+ @running_generation = nil
41
+ @running_paths = []
42
+ load(setup_file) if File.file?(setup_file)
43
+ self
44
+ end
45
+
46
+ # Called by the setup file: puts its load paths on $LOAD_PATH, after
47
+ # /app (so a Carried library still shadows an installed one) and before
48
+ # the Shipped library, in the lock's order.
49
+ def setup(generation, load_paths)
50
+ @running_generation = generation
51
+ @running_paths = load_paths.map { |p| File.join(gems_dir, p) }
52
+ app = $LOAD_PATH.rindex { |p| p == "/app" || p.start_with?("/app/") }
53
+ $LOAD_PATH.insert(app ? app + 1 : 0, *(@running_paths - $LOAD_PATH))
54
+ nil
55
+ end
56
+
57
+ # install_put: files into .partial/<name-version>/, each path
58
+ # "<name-version>/<path inside the gem>".
59
+ def put(files)
60
+ controller!
61
+ files.each do |f|
62
+ nv, rest = split!(f[:path])
63
+ full = File.join(partial_dir, nv, rest)
64
+ mkdir_p(File.dirname(full))
65
+ File.binwrite(full, f[:body])
66
+ end
67
+ "wrote #{files.size} files, #{files.sum { _1[:body].bytesize }} bytes"
68
+ end
69
+
70
+ # install_commit: rename each named .partial directory into place, then
71
+ # write the setup file if one is given, whose every load path must be
72
+ # inside a complete version.
73
+ def commit(request)
74
+ controller!
75
+ done = request[:gems].map do |nv|
76
+ name_version!(nv)
77
+ from = File.join(partial_dir, nv)
78
+ to = File.join(gems_dir, nv)
79
+ IC.refuse("#{nv} has nothing in .partial to commit") unless File.directory?(from)
80
+ IC.refuse("#{nv} is already installed") if File.exist?(to)
81
+ File.rename(from, to)
82
+ nv
83
+ end
84
+ msg = "committed #{done.size} gems"
85
+ setup = request[:setup]
86
+ return msg unless setup
87
+
88
+ write_setup!(setup[:generation], setup[:load_paths])
89
+ "#{msg}; wrote setup generation #{setup[:generation]}"
90
+ end
91
+
92
+ # install_remove: delete one complete version that neither the running
93
+ # boot nor the pending setup file names, or a leftover in .partial.
94
+ def remove(nv)
95
+ controller!
96
+ name_version!(nv)
97
+ if File.directory?(File.join(partial_dir, nv)) && !File.exist?(File.join(gems_dir, nv))
98
+ rm_r(File.join(partial_dir, nv))
99
+ return "removed #{nv} from .partial"
100
+ end
101
+ dir = File.join(gems_dir, nv)
102
+ IC.refuse("#{nv} is not installed") unless File.directory?(dir)
103
+ IC.refuse("#{nv} is named by the running boot (generation #{running_generation})") if named?(running_names, nv)
104
+ IC.refuse("#{nv} is named by the pending setup (generation #{pending_generation})") if named?(pending_names, nv)
105
+ rm_r(dir)
106
+ "removed #{nv}"
107
+ end
108
+
109
+ # installed: public, a query. What runs and what is waiting.
110
+ def installed
111
+ {
112
+ running: running_generation,
113
+ pending: pending_generation,
114
+ gems: list(gems_dir),
115
+ partial: list(partial_dir),
116
+ load_paths: pending_paths,
117
+ running_paths: (@running_paths || []).map { _1.delete_prefix("#{gems_dir}/") }
118
+ }
119
+ end
120
+
121
+ def pending_generation
122
+ return nil unless File.file?(setup_file)
123
+
124
+ File.foreach(setup_file) { |l| return Regexp.last_match(1).to_i if l =~ GENERATION_LINE }
125
+ nil
126
+ end
127
+
128
+ private
129
+
130
+ def controller!
131
+ IC.caller.controller? || IC.refuse("only a controller may install code")
132
+ end
133
+
134
+ def name_version!(nv)
135
+ IC.refuse("not a gem version directory: #{nv.inspect}") unless nv.is_a?(String) && nv =~ NAME_VERSION
136
+ nv
137
+ end
138
+
139
+ # "<name-version>/<rest>", with no absolute path, no "..", no "." and
140
+ # no empty segment.
141
+ def split!(path)
142
+ IC.refuse("path must be relative: #{path}") if path.start_with?("/")
143
+ parts = path.split("/", -1)
144
+ IC.refuse("path must not hold . or .. or an empty segment: #{path}") if parts.any? { ["", ".", ".."].include?(_1) }
145
+ IC.refuse("path must be <name-version>/<file>: #{path}") if parts.size < 2
146
+ [name_version!(parts.first), parts.drop(1).join("/")]
147
+ end
148
+
149
+ def write_setup!(generation, load_paths)
150
+ unless generation.is_a?(Integer) && generation > (pending_generation || 0) && generation > (running_generation || 0)
151
+ IC.refuse("setup generation #{generation} must be above the pending (#{pending_generation.inspect}) " \
152
+ "and running (#{running_generation.inspect}) ones")
153
+ end
154
+ load_paths.each do |p|
155
+ nv, = split!(p)
156
+ IC.refuse("load path #{p} is not inside an installed gem") unless File.directory?(File.join(gems_dir, p)) &&
157
+ File.directory?(File.join(gems_dir, nv))
158
+ end
159
+ body = SETUP_HEAD + "# generation: #{generation}\n" \
160
+ "RubyWasmIcInstaller::Canister.setup(#{generation}, #{load_paths.map(&:to_s).inspect})\n"
161
+ File.write(setup_file, body)
162
+ end
163
+
164
+ def pending_paths
165
+ return [] unless File.file?(setup_file)
166
+
167
+ line = File.foreach(setup_file).find { _1.start_with?("RubyWasmIcInstaller::Canister.setup(") }
168
+ return [] unless line
169
+
170
+ line.scan(/"((?:[^"\\]|\\.)*)"/).map(&:first)
171
+ end
172
+
173
+ def running_names = (@running_paths || []).map { |p| p.delete_prefix("#{gems_dir}/").split("/").first }
174
+ def pending_names = pending_paths.map { _1.split("/").first }
175
+ def named?(names, nv) = names.include?(nv)
176
+
177
+ def list(dir)
178
+ return [] unless File.directory?(dir)
179
+
180
+ Dir.children(dir).reject { _1.start_with?(".") }.select { File.directory?(File.join(dir, _1)) }.sort
181
+ end
182
+
183
+ def mkdir_p(dir)
184
+ return if File.directory?(dir)
185
+
186
+ mkdir_p(File.dirname(dir))
187
+ Dir.mkdir(dir)
188
+ end
189
+
190
+ def rm_r(path)
191
+ if File.directory?(path) && !File.symlink?(path)
192
+ Dir.children(path).each { rm_r(File.join(path, _1)) }
193
+ Dir.rmdir(path)
194
+ else
195
+ File.delete(path)
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+
202
+ # The Declarations `setup` writes into ic-interface.rb are served by these.
203
+ def install_put(files) = RubyWasmIcInstaller::Canister.put(files)
204
+ def install_commit(request) = RubyWasmIcInstaller::Canister.commit(request)
205
+ def install_remove(name_version) = RubyWasmIcInstaller::Canister.remove(name_version)
206
+ def installed = RubyWasmIcInstaller::Canister.installed
207
+
208
+ RubyWasmIcInstaller::Canister.boot! if defined?(IC::DATA_DIR)
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module RubyWasmIcInstaller
6
+ # The command: `ruby_wasm_ic_installer <install|status|prune|setup> ...`.
7
+ class CLI
8
+ USAGE = <<~TEXT
9
+ Usage:
10
+ ruby_wasm_ic_installer install CANISTER [--lock Gemfile.lock] [--dry-run] [icp options]
11
+ ruby_wasm_ic_installer status CANISTER [icp options]
12
+ ruby_wasm_ic_installer prune CANISTER [--dry-run] [icp options]
13
+ ruby_wasm_ic_installer setup APP_DIR [--yes | --check]
14
+
15
+ install sends the lock's pure-Ruby gems the canister lacks, then the setup
16
+ file that loads them; it takes effect at the next upgrade
17
+ status what the canister has installed, running and pending
18
+ prune removes every version neither the running nor the pending setup names
19
+ setup wires the Installer's methods into an Application (ic-interface.rb,
20
+ app.rb, vendor/ruby_wasm_ic_installer/)
21
+
22
+ icp options, passed to `icp canister call`:
23
+ -e, --environment ENV -n, --network NETWORK --identity NAME
24
+ --project-root DIR the icp project (icp's --project-root-override)
25
+ --icp PATH the icp command (default: icp)
26
+ Other options:
27
+ --cache DIR where fetched .gem files are kept
28
+ (default: ~/.cache/ruby_wasm_ic_installer)
29
+ --budget BYTES the largest batch, in bytes of Candid (default: #{Plan::DEFAULT_BUDGET})
30
+ -h, --help, --version
31
+ TEXT
32
+
33
+ def self.start(argv, out: $stdout, err: $stderr, input: $stdin, runner: nil, gem_files: nil)
34
+ new(out: out, err: err, input: input, runner: runner, gem_files: gem_files).run(argv)
35
+ end
36
+
37
+ def initialize(out:, err:, input:, runner:, gem_files:)
38
+ @out = out
39
+ @err = err
40
+ @input = input
41
+ @runner = runner
42
+ @gem_files = gem_files
43
+ end
44
+
45
+ # The exit status.
46
+ def run(argv)
47
+ opts = { lock: "Gemfile.lock", icp: "icp", icp_args: [], dry_run: false, yes: false, check: false,
48
+ cache: File.join(Dir.home, ".cache", "ruby_wasm_ic_installer"), budget: Plan::DEFAULT_BUDGET }
49
+ args = parser(opts).parse(argv)
50
+ return 0 if opts[:done]
51
+
52
+ command = args.shift
53
+ target = args.shift
54
+ raise Error, "#{USAGE}\nunexpected: #{args.join(' ')}" unless args.empty?
55
+ raise Error, USAGE if command.nil? || target.nil?
56
+
57
+ case command
58
+ when "setup" then setup(target, opts)
59
+ when "install" then report(installer(target, opts).install(Lockfile.read(opts[:lock]), dry_run: opts[:dry_run]))
60
+ when "status" then report(installer(target, opts).status)
61
+ when "prune" then report(installer(target, opts).prune(dry_run: opts[:dry_run]))
62
+ else raise Error, "#{USAGE}\nunknown command: #{command}"
63
+ end
64
+ rescue Error, OptionParser::ParseError => e
65
+ @err.puts(e.message)
66
+ e.is_a?(Runner::CallError) || e.is_a?(AppSetup::Aborted) ? 1 : 2
67
+ end
68
+
69
+ private
70
+
71
+ def parser(opts)
72
+ OptionParser.new do |o|
73
+ o.banner = USAGE
74
+ o.on("--lock PATH") { opts[:lock] = _1 }
75
+ o.on("--dry-run") { opts[:dry_run] = true }
76
+ o.on("--yes") { opts[:yes] = true }
77
+ o.on("--check") { opts[:check] = true }
78
+ o.on("--icp PATH") { opts[:icp] = _1 }
79
+ o.on("--cache DIR") { opts[:cache] = _1 }
80
+ o.on("--budget BYTES", Integer) { opts[:budget] = _1 }
81
+ o.on("-e", "--environment ENV") { opts[:icp_args] += ["-e", _1] }
82
+ o.on("-n", "--network NETWORK") { opts[:icp_args] += ["-n", _1] }
83
+ o.on("--identity NAME") { opts[:icp_args] += ["--identity", _1] }
84
+ o.on("--project-root DIR") { opts[:icp_args] += ["--project-root-override", _1] }
85
+ o.on("-h", "--help") { @out.puts(USAGE) || opts[:done] = true }
86
+ o.on("--version") { @out.puts(VERSION) || opts[:done] = true }
87
+ end
88
+ end
89
+
90
+ def installer(canister, opts)
91
+ runner = @runner || Runner.new(canister: canister, icp: opts[:icp], icp_args: opts[:icp_args])
92
+ Installer.new(runner: runner, gem_files: @gem_files || GemFiles.new(cache: opts[:cache]), budget: opts[:budget])
93
+ end
94
+
95
+ def setup(dir, opts)
96
+ s = AppSetup.new(dir, io: @out, input: @input)
97
+ return s.check if opts[:check]
98
+
99
+ s.run(yes: opts[:yes])
100
+ 0
101
+ end
102
+
103
+ def report(lines)
104
+ lines.each { @out.puts(_1) }
105
+ 0
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyWasmIcInstaller
4
+ # A unified diff of two texts by lines, enough for `setup` to show what it
5
+ # would change (W29, Q17). Longest common subsequence; three lines of
6
+ # context; no dependency.
7
+ module Diff
8
+ module_function
9
+
10
+ def unified(old_text, new_text, from:, to:, context: 3)
11
+ a = old_text.lines
12
+ b = new_text.lines
13
+ ops = edits(a, b)
14
+ return "" if ops.all? { _1.first == :same }
15
+
16
+ out = +"--- #{from}\n+++ #{to}\n"
17
+ hunks(ops, context).each do |h|
18
+ a_start = h.first[2] + 1
19
+ b_start = h.first[3] + 1
20
+ a_len = h.count { _1.first != :add }
21
+ b_len = h.count { _1.first != :del }
22
+ out << "@@ -#{a_len.zero? ? a_start - 1 : a_start},#{a_len} +#{b_len.zero? ? b_start - 1 : b_start},#{b_len} @@\n"
23
+ h.each do |op, line, *|
24
+ mark = { same: " ", del: "-", add: "+" }.fetch(op)
25
+ out << mark << line
26
+ out << "\n\\n" unless line.end_with?("\n")
27
+ end
28
+ end
29
+ out
30
+ end
31
+
32
+ # [[op, line, index in a, index in b], ...] in order.
33
+ def edits(a, b)
34
+ lcs = Array.new(a.size + 1) { Array.new(b.size + 1, 0) }
35
+ (a.size - 1).downto(0) do |i|
36
+ (b.size - 1).downto(0) do |j|
37
+ lcs[i][j] = a[i] == b[j] ? lcs[i + 1][j + 1] + 1 : [lcs[i + 1][j], lcs[i][j + 1]].max
38
+ end
39
+ end
40
+ i = j = 0
41
+ ops = []
42
+ while i < a.size || j < b.size
43
+ if i < a.size && j < b.size && a[i] == b[j]
44
+ ops << [:same, a[i], i, j]
45
+ i += 1
46
+ j += 1
47
+ elsif i < a.size && (j == b.size || lcs[i + 1][j] >= lcs[i][j + 1])
48
+ ops << [:del, a[i], i, j] # a deletion first, as diff(1) prints it
49
+ i += 1
50
+ else
51
+ ops << [:add, b[j], i, j]
52
+ j += 1
53
+ end
54
+ end
55
+ ops
56
+ end
57
+
58
+ def hunks(ops, context)
59
+ changed = ops.each_index.reject { ops[_1].first == :same }
60
+ groups = []
61
+ changed.each do |k|
62
+ lo = [k - context, 0].max
63
+ hi = [k + context, ops.size - 1].min
64
+ if groups.any? && lo <= groups.last.last + 1
65
+ groups.last[1] = hi
66
+ else
67
+ groups << [lo, hi]
68
+ end
69
+ end
70
+ groups.map { |lo, hi| ops[lo..hi] }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "open3"
6
+ require "rubygems/package"
7
+ require "tmpdir"
8
+
9
+ module RubyWasmIcInstaller
10
+ # One locked gem made ready to send (W29, Q8 and Q9): its `.gem` fetched
11
+ # for platform `ruby` (or found in the cache), checked against the lock's
12
+ # sha256 when the lock has one, refused if it has extensions, and opened
13
+ # for its require_paths alone, with magic comments fixed.
14
+ class GemFiles
15
+ Prepared = Struct.new(:name_version, :require_paths, :files, :rewrites, keyword_init: true) do
16
+ def bytes = files.sum { _2.bytesize }
17
+ end
18
+
19
+ class Refused < Error; end
20
+
21
+ # `fetcher` is called as fetcher.(name, version, dir) and must leave
22
+ # "<name>-<version>.gem" in dir; the default runs `gem fetch`.
23
+ def initialize(cache:, fetcher: nil)
24
+ @cache = cache
25
+ @fetcher = fetcher || method(:gem_fetch)
26
+ end
27
+
28
+ def prepare(locked)
29
+ path = fetch(locked)
30
+ check_sum!(locked, path)
31
+ package = ::Gem::Package.new(path)
32
+ spec = package.spec
33
+ check_spec!(locked, spec)
34
+ files, rewrites = read_files(locked, package, spec)
35
+ paths = spec.require_paths.select { |rp| files.keys.any? { _1.start_with?("#{rp}/") } }
36
+ raise Refused, "#{locked.name_version} has no files under #{spec.require_paths.join(', ')}" if paths.empty?
37
+
38
+ Prepared.new(name_version: locked.name_version, require_paths: paths, files: files, rewrites: rewrites)
39
+ end
40
+
41
+ # The stand-ins a fetched ruby_wasm_ic_minimal carries: the libraries it
42
+ # stands in for, so the locked gems of those names are skipped.
43
+ def standins(locked)
44
+ package = ::Gem::Package.new(fetch(locked))
45
+ Dir.mktmpdir do |dir|
46
+ package.extract_files(dir, "lib/ruby_wasm_ic_minimal/standins/*.rb")
47
+ Dir.children(File.join(dir, "lib/ruby_wasm_ic_minimal/standins")).map { File.basename(_1, ".rb") }
48
+ .reject { _1 == "encoding_constants" }.sort
49
+ end
50
+ rescue Errno::ENOENT
51
+ []
52
+ end
53
+
54
+ private
55
+
56
+ def fetch(locked)
57
+ FileUtils.mkdir_p(@cache)
58
+ path = File.join(@cache, "#{locked.name_version}.gem")
59
+ return path if File.file?(path)
60
+
61
+ Dir.mktmpdir do |dir|
62
+ @fetcher.call(locked.name, locked.version, dir)
63
+ got = File.join(dir, "#{locked.name_version}.gem")
64
+ raise Refused, "fetching #{locked.name_version} left no #{File.basename(got)}" unless File.file?(got)
65
+
66
+ FileUtils.mv(got, path)
67
+ end
68
+ path
69
+ end
70
+
71
+ def gem_fetch(name, version, dir)
72
+ out, status = Open3.capture2e("gem", "fetch", name, "--version", version, "--platform", "ruby", chdir: dir)
73
+ raise Refused, "gem fetch #{name} #{version} failed:\n#{out}" unless status.success?
74
+ end
75
+
76
+ def check_sum!(locked, path)
77
+ return unless locked.sha256
78
+
79
+ have = Digest::SHA256.file(path).hexdigest
80
+ return if have == locked.sha256
81
+
82
+ raise Refused, "#{locked.name_version}.gem has sha256 #{have}, and the lock says #{locked.sha256}"
83
+ end
84
+
85
+ def check_spec!(locked, spec)
86
+ unless spec.platform.to_s == "ruby"
87
+ raise Refused, "#{locked.name_version} was fetched for #{spec.platform}, not ruby: only pure-Ruby gems " \
88
+ "can be installed into a canister"
89
+ end
90
+ return if spec.extensions.empty?
91
+
92
+ raise Refused, "#{locked.name_version} has a C extension (#{spec.extensions.join(', ')}): only pure-Ruby " \
93
+ "gems can be installed into a canister, which cannot load compiled code"
94
+ end
95
+
96
+ def read_files(locked, package, spec)
97
+ files = {}
98
+ rewrites = []
99
+ Dir.mktmpdir do |dir|
100
+ # extract_files matches without FNM_PATHNAME, so "*" crosses "/":
101
+ # "lib/*" is every file at every depth under lib/ ("lib/**/*" would
102
+ # miss lib/rack.rb itself).
103
+ spec.require_paths.each { |rp| package.extract_files(dir, "#{rp}/*") }
104
+ spec.require_paths.each do |rp|
105
+ base = File.join(dir, rp)
106
+ next unless File.directory?(base)
107
+
108
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: base).sort.each do |rel|
109
+ full = File.join(base, rel)
110
+ next unless File.file?(full)
111
+
112
+ body = File.binread(full)
113
+ path = "#{rp}/#{rel}"
114
+ if rel.end_with?(".rb")
115
+ body, rewrite = MagicComment.fix("#{locked.name_version}/#{path}", body)
116
+ rewrites << rewrite if rewrite
117
+ end
118
+ files[path] = body
119
+ end
120
+ end
121
+ end
122
+ [files, rewrites]
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyWasmIcInstaller
4
+ # The off-chain half's three acts against one canister, through a Runner:
5
+ # `install` (the lock's gems the canister lacks, then the setup file),
6
+ # `status` (what installed() says) and `prune` (every version neither the
7
+ # running boot nor the pending setup names). Each returns the lines of its
8
+ # report; nothing here prints.
9
+ class Installer
10
+ MINIMAL = "ruby_wasm_ic_minimal"
11
+
12
+ def initialize(runner:, gem_files:, budget: Plan::DEFAULT_BUDGET)
13
+ @runner = runner
14
+ @gem_files = gem_files
15
+ @budget = budget
16
+ end
17
+
18
+ def status
19
+ i = installed
20
+ [
21
+ "running generation: #{i[:running] || 'none'}",
22
+ "pending generation: #{i[:pending] || 'none'}#{pending_note(i)}",
23
+ "installed: #{i[:gems].empty? ? 'none' : i[:gems].join(', ')}",
24
+ *(i[:partial].empty? ? [] : ["half-sent (.partial): #{i[:partial].join(', ')}"])
25
+ ]
26
+ end
27
+
28
+ # `dry_run`: plan and report, call nothing that writes.
29
+ def install(lockfile, dry_run: false)
30
+ report = []
31
+ locked = lockfile.gems
32
+ skip = standins(locked)
33
+ skipped, wanted = locked.partition { skip.include?(_1.name) }
34
+ skipped.each { report << "skip #{_1.name_version}: #{MINIMAL} stands in for #{_1.name} on a minimal canister" }
35
+ prepared = wanted.map { @gem_files.prepare(_1) }
36
+ prepared.flat_map(&:rewrites).each do |r|
37
+ report << "rewrite #{r.file}:#{r.line}: magic comment #{r.from} -> #{r.to}"
38
+ end
39
+
40
+ plan = Plan.new(prepared, installed, budget: @budget)
41
+ plan.sent.each { report << "send #{_1.name_version} (#{_1.files.size} files, #{_1.bytes} bytes)" }
42
+ if plan.empty?
43
+ report << "nothing to send: the canister has every locked gem and the setup file already names them"
44
+ return report
45
+ end
46
+
47
+ plan.steps.each do |step|
48
+ report << "#{dry_run ? 'would call' : 'call'} #{step.method}: #{step.note}"
49
+ next if dry_run
50
+
51
+ reply = Candid.decode(@runner.call(step.method, step.args)).first
52
+ report << " #{reply}"
53
+ end
54
+ report << (plan.generation ? "setup generation #{plan.generation} takes effect at the next upgrade" \
55
+ : "the setup file is unchanged")
56
+ report << "upgrade the canister to load it (icp deploy --mode upgrade)" if plan.generation && !dry_run
57
+ report
58
+ end
59
+
60
+ def prune(dry_run: false)
61
+ i = installed
62
+ keep = (names_of(i[:load_paths]) + names_of(i[:running_paths])).uniq
63
+ gone = i[:gems] - keep
64
+ return ["nothing to prune: every installed version is named by the running or pending setup"] if gone.empty?
65
+
66
+ gone.map do |nv|
67
+ next "would remove #{nv}" if dry_run
68
+
69
+ reply = Candid.decode(@runner.call("install_remove", Candid.text_arg(nv))).first
70
+ "remove #{nv}: #{reply}"
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def installed
77
+ v = Candid.decode(@runner.call("installed", Candid.empty_args, query: true)).first
78
+ Candid.record(v, :running, :pending, :gems, :partial, :load_paths, :running_paths)
79
+ end
80
+
81
+ def names_of(paths) = paths.map { _1.split("/").first }.uniq
82
+
83
+ def pending_note(i)
84
+ return "" if i[:pending].nil? || i[:pending] == i[:running]
85
+
86
+ " (takes effect at the next upgrade)"
87
+ end
88
+
89
+ def standins(locked)
90
+ minimal = locked.find { _1.name == MINIMAL }
91
+ minimal ? @gem_files.standins(minimal) : []
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+
5
+ module RubyWasmIcInstaller
6
+ class Error < StandardError; end
7
+
8
+ # An Application's Gemfile.lock, read with Bundler's own parser (W29, Q8):
9
+ # each locked gem as a name and version, in the lock's order, with the
10
+ # sha256 the lock's CHECKSUMS section gives for its `ruby` platform, if any.
11
+ # Nothing is resolved or installed: the lock is the author's decision.
12
+ class Lockfile
13
+ Gem = Struct.new(:name, :version, :sha256, keyword_init: true) do
14
+ def name_version = "#{name}-#{version}"
15
+ end
16
+
17
+ attr_reader :gems
18
+
19
+ def self.read(path)
20
+ raise Error, "no lockfile at #{path}: run `bundle lock` first" unless File.file?(path)
21
+
22
+ new(File.read(path), path: path)
23
+ end
24
+
25
+ def initialize(text, path: "Gemfile.lock")
26
+ @path = path
27
+ parser = Bundler::LockfileParser.new(text)
28
+ @gems = to_gems(parser.specs, checksums(text))
29
+ end
30
+
31
+ private
32
+
33
+ # One entry per name: its `ruby` platform spec. A gem locked only for a
34
+ # native platform cannot be installed, which pure Ruby only means.
35
+ def to_gems(specs, sums)
36
+ by_name = specs.group_by(&:name)
37
+ by_name.map do |name, list|
38
+ spec = list.find { |s| s.platform.to_s == "ruby" }
39
+ unless spec
40
+ raise Error, "#{name} is locked only for #{list.map { _1.platform.to_s }.uniq.join(', ')} in #{@path}: " \
41
+ "only pure-Ruby gems can be installed into a canister " \
42
+ "(run `bundle lock --add-platform ruby`, or drop the gem)"
43
+ end
44
+ source = spec.source
45
+ unless source.is_a?(Bundler::Source::Rubygems)
46
+ raise Error, "#{name} comes from #{source.class.name.split('::').last.downcase} in #{@path}: " \
47
+ "only gems from a gem server can be installed"
48
+ end
49
+ Gem.new(name: name, version: spec.version.to_s, sha256: sums["#{name}-#{spec.version}"])
50
+ end
51
+ end
52
+
53
+ # The CHECKSUMS section's `ruby`-platform sha256 for each name-version.
54
+ def checksums(text)
55
+ section = text[/^CHECKSUMS\n(.*?)(?:\n\S|\z)/m, 1].to_s
56
+ section.lines.each_with_object({}) do |line, h|
57
+ next unless (m = line.match(/\A\s+(\S+) \(([^)\s]+)\) sha256=([0-9a-f]{64})/))
58
+
59
+ h["#{m[1]}-#{m[2]}"] = m[3]
60
+ end
61
+ end
62
+ end
63
+ end