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.
@@ -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, int *exit_code)
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
- char *ruby_optv[5] = { script_info[0], "-C", script_dir, "--", NULL };
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
- bool RunScript(char *argv[], bool is_chdir_to_script_dir, int *exit_code);
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);