ocran 1.4.5 → 1.5.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.txt +74 -0
  3. data/README.md +296 -9
  4. data/ext/extconf.rb +5 -1
  5. data/lib/ocran/aot_toolchain.rb +154 -0
  6. data/lib/ocran/command_output.rb +8 -1
  7. data/lib/ocran/compatibility_scan.rb +414 -0
  8. data/lib/ocran/cosmo_toolchain.rb +29 -7
  9. data/lib/ocran/dir_builder.rb +88 -38
  10. data/lib/ocran/direction.rb +232 -33
  11. data/lib/ocran/ed_resource.rb +113 -0
  12. data/lib/ocran/ed_resource_builder.rb +199 -0
  13. data/lib/ocran/file_path_set.rb +9 -5
  14. data/lib/ocran/gem_spec_queryable.rb +7 -3
  15. data/lib/ocran/host_config_helper.rb +11 -1
  16. data/lib/ocran/inno_setup_script_builder.rb +24 -14
  17. data/lib/ocran/launcher_batch_builder.rb +8 -11
  18. data/lib/ocran/npm_package.rb +106 -0
  19. data/lib/ocran/option.rb +433 -35
  20. data/lib/ocran/pe_imports.rb +96 -0
  21. data/lib/ocran/pe_resources.rb +226 -0
  22. data/lib/ocran/refine_pathname.rb +29 -32
  23. data/lib/ocran/roundhouse_builder.rb +316 -0
  24. data/lib/ocran/runner.rb +96 -11
  25. data/lib/ocran/runtime_environment.rb +14 -6
  26. data/lib/ocran/runtime_sdk.rb +252 -0
  27. data/lib/ocran/spinel_builder.rb +183 -0
  28. data/lib/ocran/spinel_compatibility.rb +149 -0
  29. data/lib/ocran/static_runtime/boot.rb +443 -0
  30. data/lib/ocran/static_runtime/build_static_ext.rb +86 -0
  31. data/lib/ocran/static_runtime_builder.rb +814 -0
  32. data/lib/ocran/stub_builder.rb +70 -25
  33. data/lib/ocran/version.rb +1 -1
  34. data/lib/ocran/wasm_builder.rb +570 -0
  35. data/lib/ocran/wasm_compatibility.rb +198 -0
  36. data/lib/ocran/windows_command_escaping.rb +21 -0
  37. data/lib/ocran/zip_payload_builder.rb +18 -9
  38. data/lib/ocran/zip_writer.rb +48 -9
  39. data/src/Makefile +18 -2
  40. data/src/Makefile.mswin +65 -0
  41. data/src/inst_dir.c +34 -6
  42. data/src/path_utils.c +144 -0
  43. data/src/script_info.c +5 -0
  44. data/src/stub.c +77 -19
  45. data/src/stub.rc +1 -1
  46. data/src/system_utils.c +276 -155
  47. data/src/system_utils.h +46 -3
  48. data/src/system_utils_posix.c +243 -148
  49. data/src/unpack.c +9 -0
  50. metadata +23 -5
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+ require "fiddle/import"
3
+ require "fiddle/types"
4
+ # The language/codepage constants and the pure-Ruby blob builder live in a
5
+ # separate, dlload-free file so they can be unit-tested on any platform.
6
+ require_relative "ed_resource_builder"
7
+
8
+ module Ocran
9
+ # Edits PE resources in an executable, in the style of rcedit:
10
+ # * RT_VERSION - version info (VS_VERSIONINFO / VS_FIXEDFILEINFO / StringFileInfo)
11
+ # * RT_MANIFEST - application manifest / requested execution level
12
+ # * RT_STRING - string table entries by numeric ID
13
+ #
14
+ # This is a sibling of EdIcon and uses the same Windows resource-update API
15
+ # (BeginUpdateResourceW / UpdateResourceW / EndUpdateResourceW) via Fiddle.
16
+ module EdResource
17
+ extend Fiddle::Importer
18
+ dlload "kernel32.dll"
19
+
20
+ include Fiddle::Win32Types
21
+ typealias "LPVOID", "void*"
22
+ typealias "LPCWSTR", "char*"
23
+
24
+ MAKEINTRESOURCE = -> (i) { Fiddle::Pointer.new(i) }
25
+ RT_VERSION = MAKEINTRESOURCE.(16)
26
+ RT_STRING = MAKEINTRESOURCE.(6)
27
+ # CREATEPROCESS_MANIFEST_RESOURCE_ID == 1; the manifest is stored under
28
+ # resource type RT_MANIFEST (24), name id 1.
29
+ RT_MANIFEST = MAKEINTRESOURCE.(24)
30
+ MANIFEST_RESOURCE_ID = 1
31
+
32
+ # Language/codepage constants (MAKELANGID, LANGID, LANGID_US_ENGLISH,
33
+ # CODEPAGE_UNICODE, STRING_TABLE_KEY), EXECUTION_LEVELS and
34
+ # MANIFEST_TEMPLATE are defined in ed_resource_builder.rb, required above.
35
+
36
+ extern "DWORD GetLastError()"
37
+ extern "HANDLE BeginUpdateResourceW(LPCWSTR, BOOL)"
38
+ extern "BOOL EndUpdateResourceW(HANDLE, BOOL)"
39
+ extern "BOOL UpdateResourceW(HANDLE, LPCWSTR, LPCWSTR, WORD, LPVOID, DWORD)"
40
+
41
+ class << self
42
+ # Perform every requested resource edit under a single
43
+ # BeginUpdateResource/EndUpdateResource cycle (one PE rewrite).
44
+ def update(executable_filename, version_strings: nil, file_version: nil,
45
+ product_version: nil, resource_strings: nil, manifest_xml: nil,
46
+ execution_level: nil)
47
+ wants_version = !blank?(version_strings) || file_version || product_version
48
+ wants_manifest = manifest_xml || execution_level
49
+ wants_strings = !blank?(resource_strings)
50
+ return unless wants_version || wants_manifest || wants_strings
51
+
52
+ update_resource(executable_filename) do |handle|
53
+ write_version_info(handle, version_strings || {}, file_version, product_version) if wants_version
54
+ write_manifest(handle, manifest_xml, execution_level) if wants_manifest
55
+ write_resource_strings(handle, resource_strings) if wants_strings
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def blank?(value)
62
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
63
+ end
64
+
65
+ def write_version_info(handle, strings, file_version, product_version)
66
+ merged = {}
67
+ strings.each { |k, v| merged[k.to_s] = v }
68
+ merged["FileVersion"] = file_version if file_version
69
+ merged["ProductVersion"] = product_version if product_version
70
+
71
+ blob = Build.version_info(
72
+ strings: merged,
73
+ file_version: file_version || merged["FileVersion"] || "0.0.0.0",
74
+ product_version: product_version || merged["ProductVersion"] || "0.0.0.0"
75
+ )
76
+ put(handle, RT_VERSION, 1, blob)
77
+ end
78
+
79
+ def write_manifest(handle, manifest_xml, execution_level)
80
+ xml = Build.manifest(manifest_xml, execution_level)
81
+ put(handle, RT_MANIFEST, MANIFEST_RESOURCE_ID, xml.b)
82
+ end
83
+
84
+ def write_resource_strings(handle, id_to_value)
85
+ id_to_value.group_by { |id, _| id.to_i / 16 }.each do |bundle_index, pairs|
86
+ bundle = Build.string_bundle(pairs.to_h)
87
+ put(handle, RT_STRING, bundle_index + 1, bundle)
88
+ end
89
+ end
90
+
91
+ def put(handle, type, name, data)
92
+ name_arg = name.is_a?(Integer) ? MAKEINTRESOURCE.(name) : name
93
+ if UpdateResourceW(handle, type, name_arg, LANGID, data, data.bytesize) == 0
94
+ raise "Failed to UpdateResourceW(#{GetLastError()})"
95
+ end
96
+ end
97
+
98
+ def update_resource(executable_filename)
99
+ path = File.expand_path(executable_filename).encode("UTF-16LE").b + "\x00\x00".b
100
+ handle = BeginUpdateResourceW(path, 0)
101
+ if handle == Fiddle::NULL
102
+ raise "Failed to BeginUpdateResourceW(#{GetLastError()})"
103
+ end
104
+
105
+ yield(handle)
106
+
107
+ if EndUpdateResourceW(handle, 0) == 0
108
+ raise "Failed to EndUpdateResourceW(#{GetLastError()})"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ocran
4
+ # Language/codepage identifiers and the pure-Ruby builder for the binary
5
+ # PE-resource blobs (VS_VERSIONINFO and RT_STRING bundles).
6
+ #
7
+ # This file is deliberately free of any Windows API / dlload dependency, so
8
+ # the fiddly resource-blob construction can be required and unit-tested on
9
+ # any platform. The Windows-only injection of these blobs
10
+ # (BeginUpdateResource / UpdateResourceW / EndUpdateResource) lives in
11
+ # ed_resource.rb, which requires this file.
12
+ module EdResource
13
+ MAKELANGID = -> (p, s) { s << 10 | p }
14
+ LANG_ENGLISH = 0x09
15
+ SUBLANG_ENGLISH_US = 0x01
16
+
17
+ # US English / Unicode. Resources are written under this language id so the
18
+ # manifest write replaces (rather than duplicates) the stub's compiled-in
19
+ # RT_MANIFEST, which windres stores under 0x0409. The same id is used for the
20
+ # StringTable key ("040904B0") and the VarFileInfo Translation value (langid
21
+ # in the low word, codepage in the high word).
22
+ LANGID_US_ENGLISH = MAKELANGID.(LANG_ENGLISH, SUBLANG_ENGLISH_US) # 0x0409
23
+ LANGID = LANGID_US_ENGLISH
24
+ CODEPAGE_UNICODE = 0x04B0
25
+ STRING_TABLE_KEY = format("%04X%04X", LANGID_US_ENGLISH, CODEPAGE_UNICODE)
26
+
27
+ EXECUTION_LEVELS = %w[asInvoker highestAvailable requireAdministrator].freeze
28
+
29
+ # Baseline manifest, copied verbatim from src/stub.manifest. Inlined as a
30
+ # constant so the installed gem has no runtime dependency on the src tree.
31
+ MANIFEST_TEMPLATE = <<~MANIFEST
32
+ <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
33
+ <assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
34
+ <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
35
+ <security>
36
+ <requestedPrivileges>
37
+ <requestedExecutionLevel level="asInvoker"/>
38
+ </requestedPrivileges>
39
+ </security>
40
+ </trustInfo>
41
+ <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
42
+ <application>
43
+ <!--The ID below indicates application support for Windows Vista -->
44
+ <supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
45
+ <!--The ID below indicates application support for Windows 7 -->
46
+ <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
47
+ <!--The ID below indicates application support for Windows 8 -->
48
+ <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
49
+ <!--The ID below indicates application support for Windows 8.1 -->
50
+ <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
51
+ <!--The ID below indicates application support for Windows 10 -->
52
+ <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
53
+ </application>
54
+ </compatibility>
55
+ <application>
56
+ <windowsSettings>
57
+ <activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
58
+ </windowsSettings>
59
+ </application>
60
+ </assembly>
61
+ MANIFEST
62
+
63
+ # Builds the binary blobs (VS_VERSIONINFO, RT_STRING bundles) and the
64
+ # manifest XML that Windows expects. All structures are little-endian.
65
+ # Shared with the --static-runtime resource writer (pe_resources.rb), which
66
+ # runs on any build host; ed_resource.rb needs Windows.
67
+ module Build
68
+ module_function
69
+
70
+ # The level attribute of a requestedExecutionLevel element, wherever it
71
+ # sits among the element's attributes, or an XML comment or CDATA section
72
+ # (so element-shaped text inside one is skipped).
73
+ REQUESTED_EXECUTION_LEVEL = /<!--.*?-->|<!\[CDATA\[.*?\]\]>|(<(?:[\w.-]+:)?requestedExecutionLevel\b[^>]*?\slevel\s*=\s*)(["'])[^"']*\2/m
74
+
75
+ # The RT_MANIFEST XML: the given manifest (or the stub's baseline one)
76
+ # with its requestedExecutionLevel replaced when execution_level is given.
77
+ def manifest(manifest_xml, execution_level)
78
+ xml = manifest_xml || MANIFEST_TEMPLATE
79
+ return xml unless execution_level
80
+
81
+ unless EXECUTION_LEVELS.include?(execution_level)
82
+ raise "Invalid requested execution level: #{execution_level}"
83
+ end
84
+ patched = false
85
+ xml.gsub(REQUESTED_EXECUTION_LEVEL) do
86
+ next $& if patched || !$1
87
+
88
+ patched = true
89
+ "#{$1}#{$2}#{execution_level}#{$2}"
90
+ end
91
+ end
92
+
93
+ NUL16 = [0].pack("v").freeze
94
+
95
+ # UTF-16LE, NUL-terminated, as a binary string.
96
+ def wsz(str)
97
+ str.to_s.encode("UTF-16LE").b + NUL16
98
+ end
99
+
100
+ NODE_HEADER_SIZE = 6 # wLength + wValueLength + wType, three WORDs
101
+
102
+ # A generic VS_VERSIONINFO node: a 6-byte header (wLength, wValueLength,
103
+ # wType) followed by a NUL-terminated UTF-16LE key, DWORD-aligned Value,
104
+ # then DWORD-aligned children.
105
+ #
106
+ # Padding aligns Value and each child to a 32-bit boundary relative to the
107
+ # start of the node, so the NODE_HEADER_SIZE prefix is included when
108
+ # computing pad lengths. (Each node itself starts on a 4-byte boundary
109
+ # because parents pad before appending children, and the whole blob is
110
+ # placed at an aligned resource offset.)
111
+ #
112
+ # value_len is stored verbatim in wValueLength: callers pass bytes for
113
+ # binary values and WORD counts for string values (the Win32 quirk).
114
+ def node(key:, wtype:, value: nil, value_len: 0, children: [])
115
+ body = +"".b
116
+ pad = -> { body << ("\x00".b * ((-(NODE_HEADER_SIZE + body.bytesize)) & 3)) }
117
+
118
+ body << wsz(key)
119
+ pad.call
120
+ body << value if value
121
+ unless children.empty?
122
+ pad.call
123
+ children.each do |c|
124
+ pad.call
125
+ body << c
126
+ end
127
+ end
128
+ wlength = NODE_HEADER_SIZE + body.bytesize
129
+ [wlength, value_len, wtype].pack("vvv") + body
130
+ end
131
+
132
+ # Split "X.Y.Z.W" into four integers, padding/truncating to 4.
133
+ def quad(ver)
134
+ parts = ver.to_s.split(".").map(&:to_i)
135
+ parts.fill(0, parts.size...4)[0, 4]
136
+ end
137
+
138
+ # VS_FIXEDFILEINFO (52 bytes).
139
+ def fixed_file_info(file_ver, prod_ver)
140
+ fa, fb, fc, fd = quad(file_ver)
141
+ pa, pb, pc, pd = quad(prod_ver)
142
+ [
143
+ 0xFEEF04BD, # dwSignature
144
+ 0x00010000, # dwStrucVersion (1.0)
145
+ (fa << 16) | fb, # dwFileVersionMS
146
+ (fc << 16) | fd, # dwFileVersionLS
147
+ (pa << 16) | pb, # dwProductVersionMS
148
+ (pc << 16) | pd, # dwProductVersionLS
149
+ 0x0000003F, # dwFileFlagsMask
150
+ 0x00000000, # dwFileFlags
151
+ 0x00000004, # dwFileOS = VOS__WINDOWS32
152
+ 0x00000001, # dwFileType = VFT_APP
153
+ 0x00000000, # dwFileSubtype
154
+ 0x00000000, # dwFileDateMS
155
+ 0x00000000 # dwFileDateLS
156
+ ].pack("V13")
157
+ end
158
+
159
+ # One String node. wValueLength is in UTF-16 code units (WORDs),
160
+ # including the NUL terminator.
161
+ def string_node(key, value)
162
+ v = wsz(value)
163
+ node(key: key, wtype: 1, value: v, value_len: v.bytesize / 2)
164
+ end
165
+
166
+ # Build the full VS_VERSIONINFO blob for RT_VERSION.
167
+ def version_info(strings:, file_version:, product_version:)
168
+ str_children = strings.map { |k, v| string_node(k.to_s, v) }
169
+ string_table = node(key: STRING_TABLE_KEY, wtype: 1, children: str_children)
170
+ string_file_info = node(key: "StringFileInfo", wtype: 1, children: [string_table])
171
+
172
+ translation = node(
173
+ key: "Translation", wtype: 0,
174
+ value: [(CODEPAGE_UNICODE << 16) | LANGID_US_ENGLISH].pack("V"),
175
+ value_len: 4
176
+ )
177
+ var_file_info = node(key: "VarFileInfo", wtype: 1, children: [translation])
178
+
179
+ ffi = fixed_file_info(file_version, product_version)
180
+ node(
181
+ key: "VS_VERSION_INFO", wtype: 0, value: ffi, value_len: ffi.bytesize,
182
+ children: [string_file_info, var_file_info]
183
+ )
184
+ end
185
+
186
+ # Build one RT_STRING bundle (16 slots). id_to_value contains only IDs
187
+ # belonging to this bundle. Each entry is a WORD length (in WORDs, no NUL
188
+ # terminator) followed by the UTF-16LE characters; empty slots are 0x0000.
189
+ def string_bundle(id_to_value)
190
+ slots = Array.new(16) { NUL16.dup }
191
+ id_to_value.each do |id, value|
192
+ u = value.to_s.encode("UTF-16LE").b
193
+ slots[id.to_i % 16] = [u.bytesize / 2].pack("v") + u
194
+ end
195
+ slots.join.b
196
+ end
197
+ end
198
+ end
199
+ end
@@ -45,21 +45,25 @@ module Ocran
45
45
  raise ArgumentError, "Relative paths such as '.' or '..' are not allowed, given: #{target}"
46
46
  end
47
47
 
48
- if (path = @set[target])
49
- if path.eql?(source)
48
+ # Keyed on the normalized target: Hash does not see the refined
49
+ # Pathname#eql?/hash, and on Windows "lib/Foo.rb" and "lib\foo.rb" are
50
+ # one file that must not be written twice.
51
+ key = target.path_key
52
+ if (entry = @set[key])
53
+ if entry[1].eql?(source)
50
54
  return nil
51
55
  else
52
- raise "Conflicting sources for the same target. Target: #{target}, Existing Source: #{path}, Given Source: #{source}"
56
+ raise "Conflicting sources for the same target. Target: #{target}, Existing Source: #{entry[1]}, Given Source: #{source}"
53
57
  end
54
58
  end
55
59
 
56
- @set[target] = source
60
+ @set[key] = [target, source]
57
61
  self
58
62
  end
59
63
 
60
64
  def each
61
65
  return to_enum(__method__) unless block_given?
62
- @set.each { |target, source| yield(source, target) }
66
+ @set.each_value { |target, source| yield(source, target) }
63
67
  end
64
68
 
65
69
  def to_a
@@ -155,7 +155,10 @@ module Ocran
155
155
  actual_files = file_sets.flat_map do |set|
156
156
  case set
157
157
  when :spec
158
- files.map { |file| Pathname(file) }
158
+ # Gem::Specification#files lists paths relative to the gem
159
+ # directory, and may name files the installed gem does not have
160
+ # (e.g. excluded from the package).
161
+ files.map { |file| gem_root / file }.select(&:file?)
159
162
  when :loaded
160
163
  # Some distros expose gem files under additional paths via symlinks
161
164
  # (e.g. Fedora symlinks /usr/share/ruby/psych.rb into the psych gem
@@ -186,8 +189,9 @@ module Ocran
186
189
  raise "Invalid file set: #{set}. Please specify a valid file set (:spec, :loaded, :files, :extras, :scripts)."
187
190
  end
188
191
  end
189
- actual_files.uniq
190
- actual_files
192
+ # The file sets overlap (e.g. :loaded and :scripts). Keyed on path_key
193
+ # because Array#uniq does not see the refined Pathname#eql?.
194
+ actual_files.uniq { |file| file.path_key }
191
195
  end
192
196
  end
193
197
  end
@@ -22,13 +22,23 @@ module Ocran
22
22
  @libdir ||= Pathname.new(RbConfig::CONFIG["libdir"])
23
23
  end
24
24
 
25
+ # True when the interpreter links against a shared libruby that has to
26
+ # be packed next to it. A Ruby configured without --enable-shared still
27
+ # reports LIBRUBY_SO (e.g. "libruby.so.4.0.6") although only
28
+ # libruby-static.a is installed and the interpreter is self-contained.
29
+ def shared_libruby?
30
+ RbConfig::CONFIG["ENABLE_SHARED"] != "no"
31
+ end
32
+
25
33
  def libruby_so
34
+ return nil unless shared_libruby?
26
35
  name = RbConfig::CONFIG["LIBRUBY_SO"]
27
36
  return nil if name.nil? || name.empty?
28
- @libruby_so ||= Pathname.new(name)
37
+ Pathname.new(name)
29
38
  end
30
39
 
31
40
  def libruby_aliases
41
+ return [] unless shared_libruby?
32
42
  aliases = RbConfig::CONFIG["LIBRUBY_ALIASES"]
33
43
  return [] if aliases.nil? || aliases.empty?
34
44
  aliases.split.map { |name| Pathname.new(name) }
@@ -10,18 +10,18 @@ module Ocran
10
10
  ISCC_INVALID_PARAMS = 1
11
11
  ISCC_COMPILATION_FAILED = 2
12
12
 
13
- extend WindowsCommandEscaping
14
-
15
13
  class << self
14
+ # Whether the ISCC command can be run. On Windows it is invoked
15
+ # directly and a missing ISCC is reported by compile; on POSIX it is
16
+ # looked up in PATH (e.g. a Wine wrapper, or a fake ISCC in tests).
17
+ def iscc_available?
18
+ return true if Gem.win_platform?
19
+
20
+ system("command -v #{ISCC_CMD} > /dev/null 2>&1")
21
+ end
22
+
16
23
  def compile(iss_filename, quiet: false)
17
- # "where" and ">NUL" only exist on Windows; use "command -v" on POSIX
18
- # (e.g. when testing the pipeline with a fake ISCC on Linux/macOS).
19
- iscc_found = if Gem.win_platform?
20
- true # ISCC is invoked directly; failure is reported below
21
- else
22
- system("command -v #{quote_and_escape(ISCC_CMD)} > /dev/null 2>&1")
23
- end
24
- unless iscc_found
24
+ unless iscc_available?
25
25
  raise "ISCC command not found. Is the InnoSetup directory in your PATH?"
26
26
  end
27
27
 
@@ -101,22 +101,32 @@ module Ocran
101
101
  @files.add?(source, target)
102
102
  end
103
103
 
104
+ # Inno Setup expands "{...}" as a constant (e.g. {app}) in the Name,
105
+ # DestDir and DestName parameters, and "{{" is how a literal brace is
106
+ # written there. Source is read by the compiler, which expands no
107
+ # constants in it (short of the external flag), so it stays as it is.
108
+ def escape_braces(s)
109
+ s.to_s.gsub("{", "{{")
110
+ end
111
+ private :escape_braces
112
+
104
113
  def build_dir_item(target)
105
- name = File.join("{app}", target)
114
+ name = File.join("{app}", escape_braces(target))
106
115
  "Name: #{quote_and_escape(name)};"
107
116
  end
108
117
  private :build_dir_item
109
118
 
110
119
  def build_file_item(source, target)
111
- dest_dir = File.join("{app}", File.dirname(target))
120
+ dest_dir = File.join("{app}", escape_braces(File.dirname(target)))
112
121
  s = [
113
122
  "Source: #{quote_and_escape(source)};",
114
123
  "DestDir: #{quote_and_escape(dest_dir)};"
115
124
  ]
116
125
  src_name = File.basename(source)
117
126
  dest_name = File.basename(target)
118
- if src_name != dest_name
119
- s << "DestName: #{quote_and_escape(dest_name)};"
127
+ # A name taken over from Source would be expanded unescaped.
128
+ if src_name != dest_name || dest_name.include?("{")
129
+ s << "DestName: #{quote_and_escape(escape_braces(dest_name))};"
120
130
  end
121
131
  s.join(" ")
122
132
  end
@@ -27,7 +27,7 @@ module Ocran
27
27
  def build
28
28
  @build_file.tap do |f|
29
29
  f.puts "@echo off"
30
- @environments.each { |name, val| f.puts build_set_command(name, val) }
30
+ @environments.each { |name, val| f.puts build_set_command(name, batch_value(val, BATCH_FILE_DIR)) }
31
31
  f.puts build_set_command("OCRAN_EXECUTABLE", BATCH_FILE_PATH)
32
32
  f.puts build_start_command(@title, @executable, @script, *@args, chdir_before: @chdir_before)
33
33
  f
@@ -47,13 +47,10 @@ module Ocran
47
47
  @executable, @script, @args = executable, script, args
48
48
  end
49
49
 
50
- def replace_inst_dir_placeholder(s)
51
- s.to_s.gsub(/#{Regexp.escape(EXTRACT_ROOT.to_s)}[\/\\]/, BATCH_FILE_DIR)
52
- end
53
- private :replace_inst_dir_placeholder
54
-
50
+ # +value+ is inserted as is and must already be escaped (see
51
+ # WindowsCommandEscaping#batch_value).
55
52
  def build_set_command(name, value)
56
- "set \"#{name}=#{replace_inst_dir_placeholder(value)}\""
53
+ "set \"#{name}=#{value}\""
57
54
  end
58
55
  private :build_set_command
59
56
 
@@ -61,7 +58,7 @@ module Ocran
61
58
  cmd = ["start"]
62
59
 
63
60
  # Title for Command Prompt window title bar
64
- cmd << quote_and_escape(title)
61
+ cmd << quote_and_escape(escape_percent(title))
65
62
 
66
63
  # Use /d to set the startup directory for the process,
67
64
  # which will be BATCH_FILE_DIR/SRCDIR. This path is where
@@ -71,9 +68,9 @@ module Ocran
71
68
  cmd << "/d #{quote_and_escape("#{BATCH_FILE_DIR}#{SRCDIR}")}"
72
69
  end
73
70
 
74
- cmd << quote_and_escape("#{BATCH_FILE_DIR}#{executable}")
75
- cmd << quote_and_escape("#{BATCH_FILE_DIR}#{script}")
76
- cmd += args.map { |arg| quote_and_escape(replace_inst_dir_placeholder(arg)) }
71
+ cmd << quote_and_escape("#{BATCH_FILE_DIR}#{escape_percent(executable)}")
72
+ cmd << quote_and_escape("#{BATCH_FILE_DIR}#{escape_percent(script)}")
73
+ cmd += args.map { |arg| quote_and_escape(batch_value(arg, BATCH_FILE_DIR)) }
77
74
 
78
75
  # Forward batch file arguments to the command with `%*`
79
76
  cmd << "%*"
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+ require "fileutils"
3
+ require "json"
4
+ require "timeout"
5
+ require "pathname"
6
+
7
+ module Ocran
8
+ # Fetches files out of a published npm package, which is how both
9
+ # ruby.wasm and PicoRuby distribute their browser runtimes: the prebuilt
10
+ # interpreter (.wasm) and the JavaScript that loads it.
11
+ #
12
+ # The package tarball is downloaded from the npm registry once, checked
13
+ # against the sha512 integrity the registry publishes for it, and the
14
+ # requested files are kept in OCRAN's cache directory, so later builds
15
+ # need no network. A cache filled by hand (the same layout) works offline
16
+ # from the start.
17
+ module NpmPackage
18
+ REGISTRY = "https://registry.npmjs.org"
19
+ MAX_REDIRECTS = 5
20
+
21
+ module_function
22
+
23
+ # Returns { file => absolute path } for the given files of the package
24
+ # (paths inside the package's tarball, without the leading "package/").
25
+ def fetch(name, version, files, &progress)
26
+ dir = File.join(cache_dir, name.delete_prefix("@").tr("/", "-"), version)
27
+ paths = files.to_h { |file| [file, File.join(dir, file)] }
28
+ return paths if paths.values.all? { |path| File.file?(path) }
29
+
30
+ progress&.call("Downloading #{name}@#{version} from the npm registry")
31
+ metadata = JSON.parse(download("#{REGISTRY}/#{name.sub("/", "%2f")}/#{version}"))
32
+ tarball = download(metadata.dig("dist", "tarball") || raise("the registry lists no tarball for #{name}@#{version}"))
33
+ verify(tarball, metadata.dig("dist", "integrity"), "#{name}@#{version}")
34
+ extract(tarball, files, dir)
35
+
36
+ missing = paths.reject { |_, path| File.file?(path) }.keys
37
+ raise "#{name}@#{version} does not contain #{missing.join(", ")}" unless missing.empty?
38
+
39
+ paths
40
+ rescue SocketError, SystemCallError, IOError, Timeout::Error, JSON::ParserError => e
41
+ raise "Could not download #{name}@#{version} from the npm registry (#{e.class}: #{e.message}). " \
42
+ "Without network access, put the package's #{files.join(", ")} into #{dir}"
43
+ end
44
+
45
+ def download(url, redirects = MAX_REDIRECTS)
46
+ require "net/http"
47
+ require "uri"
48
+
49
+ uri = URI(url)
50
+ response = Net::HTTP.get_response(uri)
51
+ case response
52
+ when Net::HTTPSuccess
53
+ response.body
54
+ when Net::HTTPRedirection
55
+ raise "too many redirects fetching #{url}" if redirects.zero?
56
+
57
+ download(URI.join(url, response["location"]).to_s, redirects - 1)
58
+ else
59
+ raise "#{url} answered #{response.code} #{response.message}"
60
+ end
61
+ end
62
+
63
+ def verify(data, integrity, what)
64
+ require "digest"
65
+
66
+ algorithm, expected = integrity.to_s.split("-", 2)
67
+ unless algorithm == "sha512" && expected
68
+ raise "the registry publishes no sha512 integrity for #{what}; refusing to use it unverified"
69
+ end
70
+ actual = Digest::SHA512.base64digest(data)
71
+ raise "#{what} does not match the integrity the registry publishes for it" unless actual == expected
72
+ end
73
+
74
+ def extract(tarball, files, dir)
75
+ require "rubygems/package"
76
+ require "stringio"
77
+ require "zlib"
78
+
79
+ wanted = files.to_h { |file| ["package/#{file}", file] }
80
+ FileUtils.mkdir_p(dir)
81
+ Zlib::GzipReader.wrap(StringIO.new(tarball)) do |gz|
82
+ Gem::Package::TarReader.new(gz) do |tar|
83
+ tar.each do |entry|
84
+ file = wanted[entry.full_name] or next
85
+
86
+ path = File.join(dir, file)
87
+ FileUtils.mkdir_p(File.dirname(path))
88
+ # Written under a temporary name first: a half-written file must
89
+ # not pass for a cached one.
90
+ File.binwrite("#{path}.part", entry.read)
91
+ File.rename("#{path}.part", path)
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ def cache_dir
98
+ base = ENV["XDG_CACHE_HOME"]
99
+ base = File.join(Dir.home, ".cache") if base.nil? || base.empty?
100
+ File.join(base, "ocran", "npm")
101
+ rescue ArgumentError # Dir.home unavailable (no HOME)
102
+ require "tmpdir"
103
+ File.join(Dir.tmpdir, "ocran-cache", "npm")
104
+ end
105
+ end
106
+ end