ocran 1.4.4 → 1.4.5
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 +18 -0
- data/README.md +435 -16
- data/lib/ocran/cosmo_toolchain.rb +383 -0
- data/lib/ocran/direction.rb +592 -58
- data/lib/ocran/option.rb +165 -3
- data/lib/ocran/rubyopt_processor.rb +95 -0
- data/lib/ocran/runner.rb +80 -0
- data/lib/ocran/runtime_environment.rb +19 -1
- data/lib/ocran/stub_builder.rb +23 -6
- data/lib/ocran/version.rb +1 -1
- data/lib/ocran/zip_payload_builder.rb +282 -0
- data/lib/ocran/zip_writer.rb +287 -0
- data/src/Makefile +9 -0
- data/src/script_info.c +15 -2
- data/src/script_info.h +15 -1
- data/src/stub.c +51 -1
- data/src/system_utils_posix.c +40 -0
- data/src/unpack.c +4 -0
- data/src/unpack.h +10 -0
- metadata +5 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "pathname"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "build_constants"
|
|
5
|
+
require_relative "zip_writer"
|
|
6
|
+
|
|
7
|
+
module Ocran
|
|
8
|
+
# Builder that packages an application by INJECTING it into the ZIP store
|
|
9
|
+
# of a cosmopolitan Ruby APE, instead of building a launcher stub that
|
|
10
|
+
# unpacks a temporary directory at every start.
|
|
11
|
+
#
|
|
12
|
+
# A CosmoRuby build auto-runs the member /zip/main.rb of its own archive
|
|
13
|
+
# when there is one, passing the command line through as ARGV. So a
|
|
14
|
+
# complete application is: a byte-for-byte copy of ruby.com, plus the
|
|
15
|
+
# application's files, plus a generated main.rb that prepares the
|
|
16
|
+
# environment and loads the real script. Nothing is compiled at packaging
|
|
17
|
+
# time (no cosmocc) and nothing is written to disk at run time (no
|
|
18
|
+
# extraction, no temp directory).
|
|
19
|
+
#
|
|
20
|
+
# Layout inside the archive:
|
|
21
|
+
#
|
|
22
|
+
# /zip/main.rb generated bootstrap, the entry point
|
|
23
|
+
# /zip/ocran/src/... the application's own files
|
|
24
|
+
# /zip/ocran/gems/... packed pure-Ruby gems (GEM_HOME/GEM_PATH)
|
|
25
|
+
# /zip/ocran/lib/ruby/... files packed relative to the Ruby prefix
|
|
26
|
+
#
|
|
27
|
+
# Everything except main.rb lives under the "ocran/" prefix because the
|
|
28
|
+
# interpreter's own standard library already occupies /zip/lib/ruby and
|
|
29
|
+
# /zip/bin: a shared namespace would let a packed file shadow part of the
|
|
30
|
+
# interpreter. The prefix makes collisions structurally impossible, and
|
|
31
|
+
# it is the same tree the extraction mode would have written to a temp
|
|
32
|
+
# directory, so Direction needs no separate layout.
|
|
33
|
+
class ZipPayloadBuilder
|
|
34
|
+
include BuildConstants
|
|
35
|
+
|
|
36
|
+
# Where the interpreter maps its embedded archive.
|
|
37
|
+
ZIP_ROOT = "/zip"
|
|
38
|
+
# Root of the packed application inside the archive.
|
|
39
|
+
APP_ROOT = "#{ZIP_ROOT}/ocran"
|
|
40
|
+
# Archive member the interpreter runs on startup.
|
|
41
|
+
MAIN_SCRIPT = "main.rb"
|
|
42
|
+
|
|
43
|
+
# Uncompressed size of everything packed, for the build summary.
|
|
44
|
+
attr_reader :data_size
|
|
45
|
+
|
|
46
|
+
def initialize(path, cosmo_ruby:, chdir_before: false, debug_mode: false)
|
|
47
|
+
@path = Pathname(path)
|
|
48
|
+
@cosmo_ruby = Pathname(cosmo_ruby)
|
|
49
|
+
@chdir_before = chdir_before
|
|
50
|
+
@debug_mode = debug_mode
|
|
51
|
+
@entries = []
|
|
52
|
+
@names = {}
|
|
53
|
+
@env = {}
|
|
54
|
+
@exec_args = nil
|
|
55
|
+
@ignored_symlinks = []
|
|
56
|
+
@data_size = 0
|
|
57
|
+
|
|
58
|
+
yield(self) if block_given?
|
|
59
|
+
|
|
60
|
+
finalize
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Symlinks the extraction mode would create (libruby aliases). A ZIP
|
|
64
|
+
# member cannot be a symlink zipos would follow, and none are needed:
|
|
65
|
+
# the packed interpreter is a single static binary.
|
|
66
|
+
attr_reader :ignored_symlinks
|
|
67
|
+
|
|
68
|
+
def mkdir(target)
|
|
69
|
+
add(Entry.new(name: "#{archive_name(target)}/"))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def cp(source, target)
|
|
73
|
+
source = source.to_s
|
|
74
|
+
add(Entry.new(name: archive_name(target), source: source, mode: file_mode(source),
|
|
75
|
+
mtime: File.mtime(source)))
|
|
76
|
+
@data_size += File.size(source)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def symlink(link_path, target)
|
|
80
|
+
@ignored_symlinks << [link_path.to_s, target.to_s]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def export(name, value)
|
|
84
|
+
@env[name.to_s] = replace_root(value.to_s)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The image argument is the packed interpreter the extraction mode
|
|
88
|
+
# would spawn; here the running interpreter IS that binary, so only the
|
|
89
|
+
# script and its arguments matter.
|
|
90
|
+
def exec(image, script, *argv)
|
|
91
|
+
raise "Script is already set" if @exec_args
|
|
92
|
+
@exec_args = [in_archive(image), in_archive(script), argv.map { |arg| replace_root(arg.to_s) }]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
Entry = ZipWriter::Entry
|
|
98
|
+
|
|
99
|
+
def add(entry)
|
|
100
|
+
return if @names[entry.name]
|
|
101
|
+
|
|
102
|
+
@names[entry.name] = true
|
|
103
|
+
@entries << entry
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Archive member name for a target path of the extraction layout. The
|
|
107
|
+
# targets Direction emits are relative to the extraction root, so they
|
|
108
|
+
# only need the application prefix; an absolute target would escape the
|
|
109
|
+
# archive and is a bug.
|
|
110
|
+
def archive_name(target)
|
|
111
|
+
name = target.to_s.tr("\\", "/").delete_prefix("./")
|
|
112
|
+
raise "cannot pack the absolute path #{name} into a ZIP archive" if name.start_with?("/")
|
|
113
|
+
|
|
114
|
+
"ocran/#{name}".chomp("/")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Rewrites the extraction-root placeholder ("|", see BuildConstants)
|
|
118
|
+
# that Direction puts into environment values and exec arguments: in
|
|
119
|
+
# this mode the application root is not a temporary directory but a
|
|
120
|
+
# fixed path inside the archive.
|
|
121
|
+
def replace_root(value)
|
|
122
|
+
value.gsub("#{EXTRACT_ROOT}/", "#{APP_ROOT}/").gsub(/\A#{Regexp.escape(EXTRACT_ROOT.to_s)}\z/, APP_ROOT)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Absolute path inside the archive for a path Direction emits relative
|
|
126
|
+
# to the extraction root.
|
|
127
|
+
def in_archive(path)
|
|
128
|
+
path = replace_root(path.to_s).tr("\\", "/")
|
|
129
|
+
path.start_with?("/") ? path : "#{APP_ROOT}/#{path}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Executable bits are the only permission worth carrying over; packed
|
|
133
|
+
# files are read-only inside the archive anyway.
|
|
134
|
+
def file_mode(source)
|
|
135
|
+
File.executable?(source) ? 0o755 : 0o644
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def finalize
|
|
139
|
+
raise "No script to run was recorded" unless @exec_args
|
|
140
|
+
|
|
141
|
+
FileUtils.cp(@cosmo_ruby.to_s, @path.to_s)
|
|
142
|
+
File.chmod(0o755, @path.to_s)
|
|
143
|
+
|
|
144
|
+
add(Entry.new(name: MAIN_SCRIPT, data: bootstrap_source, mode: 0o644))
|
|
145
|
+
ZipWriter.append(@path.to_s, @entries)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The generated /zip/main.rb. It has to do what the C stub does after
|
|
149
|
+
# extracting: set up the environment, then run the script.
|
|
150
|
+
#
|
|
151
|
+
# Environment variables cannot simply be exported here, because the
|
|
152
|
+
# interpreter is already running by the time main.rb executes and has
|
|
153
|
+
# long since read RUBYLIB, RUBYOPT and GEM_PATH. So each is applied by
|
|
154
|
+
# its runtime equivalent: RUBYLIB by unshifting onto $LOAD_PATH,
|
|
155
|
+
# GEM_HOME/GEM_PATH by setting them and telling RubyGems to re-read
|
|
156
|
+
# them, RUBYOPT's -I/-r by acting on them directly. The variables are
|
|
157
|
+
# also exported so that child processes see the same configuration.
|
|
158
|
+
def bootstrap_source
|
|
159
|
+
script = @exec_args[1]
|
|
160
|
+
build_argv = @exec_args[2]
|
|
161
|
+
|
|
162
|
+
sections = [
|
|
163
|
+
<<~RUBY.chomp,
|
|
164
|
+
# Generated by OCRAN. Entry point of the packaged application: the
|
|
165
|
+
# cosmopolitan Ruby interpreter this file is embedded in runs it on
|
|
166
|
+
# startup, with the command line in ARGV.
|
|
167
|
+
RUBY
|
|
168
|
+
(%{$stderr.puts "OCRAN: main.rb starting, ARGV=\#{ARGV.inspect}"} if @debug_mode),
|
|
169
|
+
<<~RUBY.chomp,
|
|
170
|
+
# Full path of the running executable. The interpreter resolves its
|
|
171
|
+
# own image path, which for a packaged application IS the executable
|
|
172
|
+
# the user started - the same meaning OCRAN_EXECUTABLE has in the
|
|
173
|
+
# extraction mode.
|
|
174
|
+
executable = RbConfig.ruby
|
|
175
|
+
ENV["OCRAN_EXECUTABLE"] = executable
|
|
176
|
+
RUBY
|
|
177
|
+
chdir_source,
|
|
178
|
+
env_source,
|
|
179
|
+
load_path_source,
|
|
180
|
+
gem_path_source,
|
|
181
|
+
rubyopt_source,
|
|
182
|
+
(<<~RUBY.chomp unless build_argv.empty?),
|
|
183
|
+
# Arguments recorded at packaging time ("ocran app.rb -- a b") come
|
|
184
|
+
# before the ones the user passes at run time.
|
|
185
|
+
ARGV.unshift(#{build_argv.map(&:inspect).join(", ")})
|
|
186
|
+
RUBY
|
|
187
|
+
<<~RUBY.chomp,
|
|
188
|
+
# Kernel#load, not require: the script must run with $0 and __FILE__
|
|
189
|
+
# set to itself, so that "if __FILE__ == $0" guards fire and files
|
|
190
|
+
# packed next to it are found through __dir__.
|
|
191
|
+
$PROGRAM_NAME = #{script.inspect}
|
|
192
|
+
load #{script.inspect}
|
|
193
|
+
RUBY
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
sections.compact.reject(&:empty?).join("\n\n") + "\n"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# --chdir-first cannot mean "change into the application directory"
|
|
200
|
+
# here: the application lives inside the archive and zipos cannot be a
|
|
201
|
+
# working directory. The directory holding the executable is the
|
|
202
|
+
# closest equivalent, and the one an application that keeps data next
|
|
203
|
+
# to itself actually wants.
|
|
204
|
+
def chdir_source
|
|
205
|
+
return "" unless @chdir_before
|
|
206
|
+
|
|
207
|
+
<<~RUBY.chomp
|
|
208
|
+
# --chdir-first: the archive cannot be a working directory, so use
|
|
209
|
+
# the directory the executable was started from.
|
|
210
|
+
Dir.chdir(File.dirname(executable))
|
|
211
|
+
RUBY
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Environment variables other than the load-path ones, exported as is.
|
|
215
|
+
def env_source
|
|
216
|
+
plain = @env.reject { |name, _| %w[RUBYLIB RUBYOPT GEM_HOME GEM_PATH].include?(name) }
|
|
217
|
+
return "" if plain.empty?
|
|
218
|
+
|
|
219
|
+
plain.map { |name, value| "ENV[#{name.inspect}] = #{value.inspect}" }.join("\n")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def load_path_source
|
|
223
|
+
paths = split_paths(@env["RUBYLIB"])
|
|
224
|
+
return "" if paths.empty?
|
|
225
|
+
|
|
226
|
+
<<~RUBY.chomp
|
|
227
|
+
# RUBYLIB equivalent: the interpreter read the variable before this
|
|
228
|
+
# file ran, so the entries are put on the load path directly. They
|
|
229
|
+
# are also exported for child processes.
|
|
230
|
+
ENV["RUBYLIB"] = #{@env["RUBYLIB"].inspect}
|
|
231
|
+
$LOAD_PATH.unshift(*#{paths.inspect})
|
|
232
|
+
RUBY
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def gem_path_source
|
|
236
|
+
home, path = @env["GEM_HOME"], @env["GEM_PATH"]
|
|
237
|
+
return "" unless home || path
|
|
238
|
+
|
|
239
|
+
<<~RUBY.chomp
|
|
240
|
+
# RubyGems has already computed its paths from the environment it
|
|
241
|
+
# started with; point it at the packed gems and make it re-read them.
|
|
242
|
+
ENV["GEM_HOME"] = #{home.inspect}
|
|
243
|
+
ENV["GEM_PATH"] = #{path.inspect}
|
|
244
|
+
Gem.clear_paths
|
|
245
|
+
RUBY
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# RUBYOPT is likewise too late to export. -I and -r are replayed; every
|
|
249
|
+
# other flag is reported at build time (see Direction) and dropped.
|
|
250
|
+
def rubyopt_source
|
|
251
|
+
rubyopt = @env["RUBYOPT"].to_s
|
|
252
|
+
return "" if rubyopt.strip.empty?
|
|
253
|
+
|
|
254
|
+
includes, requires = self.class.parse_rubyopt(rubyopt)
|
|
255
|
+
lines = ["ENV[\"RUBYOPT\"] = #{rubyopt.inspect}"]
|
|
256
|
+
lines << "$LOAD_PATH.unshift(*#{includes.inspect})" unless includes.empty?
|
|
257
|
+
requires.each { |lib| lines << "require #{lib.inspect}" }
|
|
258
|
+
lines.join("\n")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def split_paths(value)
|
|
262
|
+
value.to_s.split(File::PATH_SEPARATOR).reject(&:empty?)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Splits a RUBYOPT string into the -I directories and -r libraries that
|
|
266
|
+
# can be replayed from inside the script, and the flags that cannot.
|
|
267
|
+
# Returns [includes, requires, unsupported].
|
|
268
|
+
def self.parse_rubyopt(rubyopt)
|
|
269
|
+
includes, requires, unsupported = [], [], []
|
|
270
|
+
|
|
271
|
+
rubyopt.split(/\s+/).reject(&:empty?).each do |token|
|
|
272
|
+
case token
|
|
273
|
+
when /\A-I(.+)\z/ then includes << $1
|
|
274
|
+
when /\A-r(.+)\z/ then requires << $1
|
|
275
|
+
else unsupported << token
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
[includes, requires, unsupported]
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "zlib"
|
|
3
|
+
|
|
4
|
+
module Ocran
|
|
5
|
+
# Minimal ZIP archive appender, used to inject an application into the
|
|
6
|
+
# ZIP store of a cosmopolitan Ruby APE (see ZipPayloadBuilder).
|
|
7
|
+
#
|
|
8
|
+
# Why not shell out to the `zip` command: OCRAN packages applications on
|
|
9
|
+
# Windows build hosts too, where `zip` generally does not exist, and even
|
|
10
|
+
# on POSIX it is not guaranteed to be installed. Why not rubyzip: OCRAN
|
|
11
|
+
# has exactly one runtime dependency (fiddle) and adding a gem just to
|
|
12
|
+
# append a few hundred stored/deflated entries is not worth it. Zlib is
|
|
13
|
+
# part of the standard library, and the format below is the 1989-era
|
|
14
|
+
# subset (no ZIP64, no encryption, no data descriptors) that
|
|
15
|
+
# Cosmopolitan's zipos reads.
|
|
16
|
+
#
|
|
17
|
+
# Appending, specifically: an APE already contains a ZIP archive (the
|
|
18
|
+
# interpreter's own standard library lives in it), and the existing
|
|
19
|
+
# entries must keep working. The layout of a ZIP file is
|
|
20
|
+
#
|
|
21
|
+
# [local header + data]* [central directory] [end of central directory]
|
|
22
|
+
#
|
|
23
|
+
# and the central directory records absolute offsets of the local
|
|
24
|
+
# headers. So the append is: cut the file at the start of the central
|
|
25
|
+
# directory, write the new local headers there, write the ORIGINAL
|
|
26
|
+
# central directory bytes unchanged (every offset it holds is still
|
|
27
|
+
# valid, because nothing before it moved), then the central directory
|
|
28
|
+
# records for the new entries, then a fresh end-of-central-directory
|
|
29
|
+
# record. This is what the `zip` command does when it appends to an
|
|
30
|
+
# archive with a non-ZIP prefix, and it leaves the executable part of the
|
|
31
|
+
# APE - which lives before all of this - byte-identical.
|
|
32
|
+
module ZipWriter
|
|
33
|
+
# End of central directory record: signature plus 18 bytes of fixed
|
|
34
|
+
# fields; a trailing archive comment may follow.
|
|
35
|
+
EOCD_SIGNATURE = "PK\x05\x06".b
|
|
36
|
+
EOCD_SIZE = 22
|
|
37
|
+
# A ZIP archive comment can be up to 0xffff bytes, so the record can
|
|
38
|
+
# start at most that far from the end of the file.
|
|
39
|
+
MAX_EOCD_SEARCH = 0xffff + EOCD_SIZE
|
|
40
|
+
|
|
41
|
+
# Markers of the ZIP64 format extensions. OCRAN never writes them; an
|
|
42
|
+
# input archive that uses them is rejected rather than corrupted.
|
|
43
|
+
ZIP64_EOCD_LOCATOR_SIGNATURE = "PK\x06\x07".b
|
|
44
|
+
|
|
45
|
+
CENTRAL_SIGNATURE = "PK\x01\x02".b
|
|
46
|
+
LOCAL_SIGNATURE = "PK\x03\x04".b
|
|
47
|
+
|
|
48
|
+
# "Made by" field: UNIX (3) in the high byte so the external file
|
|
49
|
+
# attributes below are read as UNIX permission bits, ZIP spec 2.0 in
|
|
50
|
+
# the low byte.
|
|
51
|
+
VERSION_MADE_BY = (3 << 8) | 20
|
|
52
|
+
# Version needed to extract: 2.0 is what DEFLATE requires.
|
|
53
|
+
VERSION_NEEDED = 20
|
|
54
|
+
# General purpose bit 11: file name is UTF-8.
|
|
55
|
+
FLAG_UTF8 = 0x0800
|
|
56
|
+
|
|
57
|
+
METHOD_STORED = 0
|
|
58
|
+
METHOD_DEFLATED = 8
|
|
59
|
+
|
|
60
|
+
# MS-DOS directory attribute, set in the low byte of the external file
|
|
61
|
+
# attributes for directory entries.
|
|
62
|
+
MSDOS_DIR_ATTRIBUTE = 0x10
|
|
63
|
+
|
|
64
|
+
# UNIX st_mode file type bits, stored in the high word of the external
|
|
65
|
+
# file attributes together with the permission bits. They are NOT
|
|
66
|
+
# optional: Cosmopolitan's zipos reports the external attributes as
|
|
67
|
+
# st_mode, and a member without S_IFREG is not a regular file - Ruby's
|
|
68
|
+
# own require/load refuse to open it (they check S_ISREG), and a
|
|
69
|
+
# directory without S_IFDIR cannot be traversed, so Dir.glob comes back
|
|
70
|
+
# empty even though File.read on the exact path works. This mismatch is
|
|
71
|
+
# what makes an otherwise valid archive unusable inside an APE.
|
|
72
|
+
S_IFREG = 0o100000
|
|
73
|
+
S_IFDIR = 0o040000
|
|
74
|
+
DEFAULT_FILE_MODE = 0o644
|
|
75
|
+
DEFAULT_DIRECTORY_MODE = 0o755
|
|
76
|
+
|
|
77
|
+
# An archive member to add. +name+ is the archive-relative path (with
|
|
78
|
+
# forward slashes, no leading slash); a name ending in "/" is a
|
|
79
|
+
# directory entry with no content. +source+ is a path to read the
|
|
80
|
+
# content from, +data+ is the content itself; exactly one of them is
|
|
81
|
+
# given for a file entry.
|
|
82
|
+
Entry = Struct.new(:name, :source, :data, :mode, :mtime, keyword_init: true) do
|
|
83
|
+
def directory? = name.end_with?("/")
|
|
84
|
+
|
|
85
|
+
def content
|
|
86
|
+
return "".b if directory?
|
|
87
|
+
|
|
88
|
+
(data || File.binread(source)).b
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
module_function
|
|
93
|
+
|
|
94
|
+
# Appends the given entries to the ZIP archive at the end of +path+,
|
|
95
|
+
# in place. Returns the number of bytes the file grew by.
|
|
96
|
+
#
|
|
97
|
+
# Raises when the file has no readable central directory, when it uses
|
|
98
|
+
# ZIP64, or when an entry would shadow a name the archive already
|
|
99
|
+
# contains (a duplicate name is not a format error, but for the APE it
|
|
100
|
+
# would mean an application file silently overriding part of the
|
|
101
|
+
# interpreter's own standard library).
|
|
102
|
+
def append(path, entries)
|
|
103
|
+
entries = entries.reject(&:nil?)
|
|
104
|
+
return 0 if entries.empty?
|
|
105
|
+
|
|
106
|
+
File.open(path, "r+b") do |io|
|
|
107
|
+
eocd = read_eocd(io, path)
|
|
108
|
+
central = read_central_directory(io, eocd)
|
|
109
|
+
existing = central_directory_names(central)
|
|
110
|
+
|
|
111
|
+
entries.each do |entry|
|
|
112
|
+
if existing.include?(entry.name)
|
|
113
|
+
raise "cannot add #{entry.name} to #{path}: the archive already contains an entry with that name"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
entries = with_parent_directories(entries, existing)
|
|
118
|
+
|
|
119
|
+
before = io.size
|
|
120
|
+
io.truncate(eocd[:cd_offset])
|
|
121
|
+
io.seek(eocd[:cd_offset])
|
|
122
|
+
|
|
123
|
+
records = entries.map { |entry| write_local(io, entry) }
|
|
124
|
+
|
|
125
|
+
cd_offset = io.pos
|
|
126
|
+
io.write(central)
|
|
127
|
+
records.each { |record| io.write(central_record(record)) }
|
|
128
|
+
cd_size = io.pos - cd_offset
|
|
129
|
+
|
|
130
|
+
io.write(end_of_central_directory(eocd[:total_entries] + records.size, cd_size, cd_offset))
|
|
131
|
+
io.size - before
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Returns the entries with an explicit directory entry inserted before
|
|
136
|
+
# every member for each parent directory that neither the archive nor
|
|
137
|
+
# the entry list already provides. A ZIP archive does not require them,
|
|
138
|
+
# but zipos builds its directory listings from the members it can see,
|
|
139
|
+
# so without them Dir.glob and Dir.entries do not find the packed tree.
|
|
140
|
+
def with_parent_directories(entries, existing)
|
|
141
|
+
seen = existing.dup
|
|
142
|
+
entries.each { |entry| seen[entry.name] = true }
|
|
143
|
+
|
|
144
|
+
entries.flat_map { |entry|
|
|
145
|
+
parents = entry.name.split("/")[0...-1].inject([]) { |acc, part|
|
|
146
|
+
acc << "#{acc.last}#{part}/"
|
|
147
|
+
}
|
|
148
|
+
missing = parents.reject { |name| seen[name] }
|
|
149
|
+
missing.each { |name| seen[name] = true }
|
|
150
|
+
missing.map { |name| Entry.new(name: name) } << entry
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Locates and decodes the end-of-central-directory record. The record
|
|
155
|
+
# is searched for from the end of the file because a ZIP archive is
|
|
156
|
+
# identified by its tail, which is what allows one to be appended to an
|
|
157
|
+
# executable in the first place.
|
|
158
|
+
def read_eocd(io, path)
|
|
159
|
+
size = io.size
|
|
160
|
+
tail_size = [size, MAX_EOCD_SEARCH].min
|
|
161
|
+
io.seek(size - tail_size)
|
|
162
|
+
tail = io.read(tail_size)
|
|
163
|
+
|
|
164
|
+
offset = tail.rindex(EOCD_SIGNATURE)
|
|
165
|
+
unless offset
|
|
166
|
+
raise "#{path} does not end in a ZIP archive (no end-of-central-directory record found); " \
|
|
167
|
+
"it cannot be a cosmopolitan APE with an embedded ZIP store"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if tail.rindex(ZIP64_EOCD_LOCATOR_SIGNATURE)
|
|
171
|
+
raise "#{path} uses the ZIP64 format extensions, which OCRAN cannot append to"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
_signature, _disk, _cd_disk, _disk_entries, total_entries, cd_size, cd_offset, comment_length =
|
|
175
|
+
tail.byteslice(offset, EOCD_SIZE).unpack("a4vvvvVVv")
|
|
176
|
+
|
|
177
|
+
eocd_start = size - tail_size + offset
|
|
178
|
+
unless eocd_start + EOCD_SIZE + comment_length == size
|
|
179
|
+
raise "#{path} has trailing data after its ZIP archive; OCRAN cannot append to it"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
{ cd_offset: cd_offset, cd_size: cd_size, total_entries: total_entries }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def read_central_directory(io, eocd)
|
|
186
|
+
io.seek(eocd[:cd_offset])
|
|
187
|
+
return "".b if eocd[:cd_size].zero?
|
|
188
|
+
|
|
189
|
+
central = io.read(eocd[:cd_size]).to_s.b
|
|
190
|
+
unless central.bytesize == eocd[:cd_size] && central.start_with?(CENTRAL_SIGNATURE)
|
|
191
|
+
raise "the ZIP central directory is truncated or malformed"
|
|
192
|
+
end
|
|
193
|
+
central
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Names of the entries already in the archive, so an application file
|
|
197
|
+
# cannot silently shadow one of them.
|
|
198
|
+
def central_directory_names(central)
|
|
199
|
+
names = {}
|
|
200
|
+
pos = 0
|
|
201
|
+
while central.byteslice(pos, 4) == CENTRAL_SIGNATURE
|
|
202
|
+
name_length, extra_length, comment_length = central.byteslice(pos, 46).unpack("x28vvv")
|
|
203
|
+
names[central.byteslice(pos + 46, name_length)] = true
|
|
204
|
+
pos += 46 + name_length + extra_length + comment_length
|
|
205
|
+
end
|
|
206
|
+
names
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Writes one local file header plus its data at the current position
|
|
210
|
+
# and returns the bookkeeping the central directory record needs.
|
|
211
|
+
def write_local(io, entry)
|
|
212
|
+
content = entry.content
|
|
213
|
+
crc = Zlib.crc32(content)
|
|
214
|
+
compressed, method = compress(content)
|
|
215
|
+
|
|
216
|
+
name = entry.name.b
|
|
217
|
+
flags = name.ascii_only? ? 0 : FLAG_UTF8
|
|
218
|
+
dos_time, dos_date = dos_timestamp(entry.mtime || Time.now)
|
|
219
|
+
offset = io.pos
|
|
220
|
+
|
|
221
|
+
io.write([LOCAL_SIGNATURE, VERSION_NEEDED, flags, method, dos_time, dos_date,
|
|
222
|
+
crc, compressed.bytesize, content.bytesize, name.bytesize, 0]
|
|
223
|
+
.pack("a4vvvvvVVVvv"))
|
|
224
|
+
io.write(name)
|
|
225
|
+
io.write(compressed)
|
|
226
|
+
|
|
227
|
+
{ name: name, flags: flags, method: method, dos_time: dos_time, dos_date: dos_date,
|
|
228
|
+
crc: crc, compressed_size: compressed.bytesize, size: content.bytesize,
|
|
229
|
+
offset: offset, mode: st_mode(entry), directory: entry.directory? }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The UNIX st_mode an extractor (and zipos) should report for the
|
|
233
|
+
# entry: the permission bits plus the file type.
|
|
234
|
+
def st_mode(entry)
|
|
235
|
+
if entry.directory?
|
|
236
|
+
S_IFDIR | (entry.mode || DEFAULT_DIRECTORY_MODE)
|
|
237
|
+
else
|
|
238
|
+
S_IFREG | (entry.mode || DEFAULT_FILE_MODE)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# DEFLATE unless it does not pay off. Raw deflate streams (negative
|
|
243
|
+
# window bits) are what the ZIP format stores - Zlib.deflate would add
|
|
244
|
+
# a zlib header that no unzipper expects.
|
|
245
|
+
def compress(content)
|
|
246
|
+
return ["".b, METHOD_STORED] if content.empty?
|
|
247
|
+
|
|
248
|
+
deflater = Zlib::Deflate.new(Zlib::BEST_COMPRESSION, -Zlib::MAX_WBITS)
|
|
249
|
+
deflated = begin
|
|
250
|
+
deflater.deflate(content, Zlib::FINISH)
|
|
251
|
+
ensure
|
|
252
|
+
deflater.close
|
|
253
|
+
end
|
|
254
|
+
deflated.bytesize < content.bytesize ? [deflated, METHOD_DEFLATED] : [content, METHOD_STORED]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def central_record(record)
|
|
258
|
+
external = (record[:mode] << 16) | (record[:directory] ? MSDOS_DIR_ATTRIBUTE : 0)
|
|
259
|
+
|
|
260
|
+
[CENTRAL_SIGNATURE, VERSION_MADE_BY, VERSION_NEEDED, record[:flags], record[:method],
|
|
261
|
+
record[:dos_time], record[:dos_date], record[:crc], record[:compressed_size],
|
|
262
|
+
record[:size], record[:name].bytesize, 0, 0, 0, 0, external, record[:offset]]
|
|
263
|
+
.pack("a4vvvvvvVVVvvvvvVV") + record[:name]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def end_of_central_directory(total_entries, cd_size, cd_offset)
|
|
267
|
+
if total_entries > 0xffff
|
|
268
|
+
raise "too many ZIP entries (#{total_entries}); OCRAN does not write ZIP64 archives"
|
|
269
|
+
end
|
|
270
|
+
if cd_offset + cd_size > 0xffffffff
|
|
271
|
+
raise "the packaged archive would exceed 4 GiB; OCRAN does not write ZIP64 archives"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
[EOCD_SIGNATURE, 0, 0, total_entries, total_entries, cd_size, cd_offset, 0]
|
|
275
|
+
.pack("a4vvvvVVv")
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# MS-DOS packed time and date. The format has two-second resolution and
|
|
279
|
+
# starts in 1980, so earlier timestamps are clamped.
|
|
280
|
+
def dos_timestamp(time)
|
|
281
|
+
time = time.getlocal
|
|
282
|
+
year = [time.year, 1980].max
|
|
283
|
+
[(time.hour << 11) | (time.min << 5) | (time.sec / 2),
|
|
284
|
+
((year - 1980) << 9) | (time.month << 5) | time.day]
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
data/src/Makefile
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
# Detect host OS
|
|
2
|
+
#
|
|
3
|
+
# Experimental: the POSIX stub also builds with Cosmopolitan Libc
|
|
4
|
+
# (https://github.com/jart/cosmopolitan) into an Actually Portable
|
|
5
|
+
# Executable: make CC=cosmocc
|
|
6
|
+
# See ../docs/cosmocc-port-plan.md for status and caveats.
|
|
2
7
|
UNAME := $(shell uname -s 2>/dev/null || echo Windows)
|
|
3
8
|
IS_POSIX := $(filter $(UNAME),Linux Darwin)
|
|
4
9
|
|
|
@@ -67,6 +72,10 @@ endif
|
|
|
67
72
|
clean:
|
|
68
73
|
rm -f $(BINARIES) $(COMMON_OBJS) $(CONSOLE_OBJS) $(WINDOW_OBJS) \
|
|
69
74
|
$(RESOURCE_OBJ)
|
|
75
|
+
# cosmocc (CC=cosmocc) byproducts
|
|
76
|
+
rm -f $(addsuffix .com.dbg, $(PROG_NAMES)) \
|
|
77
|
+
$(addsuffix .aarch64.elf, $(PROG_NAMES))
|
|
78
|
+
rm -rf .aarch64 lzma/.aarch64
|
|
70
79
|
|
|
71
80
|
install: $(BINARIES)
|
|
72
81
|
mkdir -p $(BINDIR)
|
data/src/script_info.c
CHANGED
|
@@ -163,7 +163,8 @@ static char **shallow_merge_argv(char *argv1[], char *argv2[])
|
|
|
163
163
|
return outv;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
bool RunScript(char *argv[], bool is_chdir_to_script_dir,
|
|
166
|
+
bool RunScript(char *argv[], bool is_chdir_to_script_dir,
|
|
167
|
+
const char *chdir_dir, int *exit_code)
|
|
167
168
|
{
|
|
168
169
|
if (!IsScriptInfoSet()) {
|
|
169
170
|
APP_ERROR("Script info is not initialized");
|
|
@@ -202,6 +203,7 @@ bool RunScript(char *argv[], bool is_chdir_to_script_dir, int *exit_code)
|
|
|
202
203
|
goto cleanup;
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
const char *target_dir = NULL;
|
|
205
207
|
if (is_chdir_to_script_dir) {
|
|
206
208
|
script_dir = GetParentPath(script_name);
|
|
207
209
|
if (!script_dir) {
|
|
@@ -213,8 +215,19 @@ bool RunScript(char *argv[], bool is_chdir_to_script_dir, int *exit_code)
|
|
|
213
215
|
"Changing working directory to script directory '%s'",
|
|
214
216
|
script_dir
|
|
215
217
|
);
|
|
218
|
+
target_dir = script_dir;
|
|
219
|
+
} else if (chdir_dir) {
|
|
220
|
+
DEBUG(
|
|
221
|
+
"Changing working directory to '%s'",
|
|
222
|
+
chdir_dir
|
|
223
|
+
);
|
|
224
|
+
target_dir = chdir_dir;
|
|
225
|
+
}
|
|
216
226
|
|
|
217
|
-
|
|
227
|
+
if (target_dir) {
|
|
228
|
+
char *ruby_optv[5] = {
|
|
229
|
+
script_info[0], "-C", (char *)target_dir, "--", NULL
|
|
230
|
+
};
|
|
218
231
|
char **new_argv = shallow_merge_argv(ruby_optv, merged_argv + 1);
|
|
219
232
|
if (!new_argv) {
|
|
220
233
|
APP_ERROR("Failed to merge script arguments with extra arguments");
|
data/src/script_info.h
CHANGED
|
@@ -4,4 +4,18 @@
|
|
|
4
4
|
char **GetScriptInfo(void);
|
|
5
5
|
bool SetScriptInfo(const char *info, size_t info_size);
|
|
6
6
|
void FreeScriptInfo(void);
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Launches the packaged script.
|
|
9
|
+
*
|
|
10
|
+
* @param argv Original argv of the stub; elements after
|
|
11
|
+
* argv[0] are appended to the script arguments.
|
|
12
|
+
* @param is_chdir_to_script_dir When true, the script starts with its working
|
|
13
|
+
* directory set to the extracted script's
|
|
14
|
+
* directory (--chdir-first).
|
|
15
|
+
* @param chdir_dir When non-NULL (and is_chdir_to_script_dir is
|
|
16
|
+
* false), the script starts with its working
|
|
17
|
+
* directory set to this path (--chdir-exe-dir).
|
|
18
|
+
* @param exit_code Receives the script's exit code.
|
|
19
|
+
*/
|
|
20
|
+
bool RunScript(char *argv[], bool is_chdir_to_script_dir,
|
|
21
|
+
const char *chdir_dir, int *exit_code);
|