ocran 1.4.6 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.txt +68 -0
- data/README.md +295 -10
- data/ext/extconf.rb +5 -1
- data/lib/ocran/aot_toolchain.rb +154 -0
- data/lib/ocran/command_output.rb +8 -1
- data/lib/ocran/compatibility_scan.rb +414 -0
- data/lib/ocran/cosmo_toolchain.rb +29 -7
- data/lib/ocran/dir_builder.rb +88 -38
- data/lib/ocran/direction.rb +190 -30
- data/lib/ocran/ed_resource.rb +113 -0
- data/lib/ocran/ed_resource_builder.rb +199 -0
- data/lib/ocran/file_path_set.rb +9 -5
- data/lib/ocran/gem_spec_queryable.rb +7 -3
- data/lib/ocran/host_config_helper.rb +11 -1
- data/lib/ocran/inno_setup_script_builder.rb +24 -14
- data/lib/ocran/launcher_batch_builder.rb +8 -11
- data/lib/ocran/npm_package.rb +106 -0
- data/lib/ocran/option.rb +425 -35
- data/lib/ocran/pe_imports.rb +96 -0
- data/lib/ocran/pe_resources.rb +226 -0
- data/lib/ocran/refine_pathname.rb +29 -32
- data/lib/ocran/roundhouse_builder.rb +316 -0
- data/lib/ocran/runner.rb +43 -11
- data/lib/ocran/runtime_sdk.rb +252 -0
- data/lib/ocran/spinel_builder.rb +183 -0
- data/lib/ocran/spinel_compatibility.rb +149 -0
- data/lib/ocran/static_runtime/boot.rb +443 -0
- data/lib/ocran/static_runtime/build_static_ext.rb +86 -0
- data/lib/ocran/static_runtime_builder.rb +814 -0
- data/lib/ocran/stub_builder.rb +70 -25
- data/lib/ocran/version.rb +1 -1
- data/lib/ocran/wasm_builder.rb +570 -0
- data/lib/ocran/wasm_compatibility.rb +198 -0
- data/lib/ocran/windows_command_escaping.rb +21 -0
- data/lib/ocran/zip_payload_builder.rb +18 -9
- data/lib/ocran/zip_writer.rb +48 -9
- data/src/Makefile +18 -2
- data/src/Makefile.mswin +65 -0
- data/src/inst_dir.c +34 -6
- data/src/path_utils.c +144 -0
- data/src/script_info.c +5 -0
- data/src/stub.c +77 -19
- data/src/stub.rc +1 -1
- data/src/system_utils.c +276 -155
- data/src/system_utils.h +46 -3
- data/src/system_utils_posix.c +243 -148
- data/src/unpack.c +9 -0
- metadata +22 -4
|
@@ -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
|
data/lib/ocran/file_path_set.rb
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
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: #{
|
|
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[
|
|
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.
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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}=#{
|
|
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(
|
|
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
|