ocran 1.4.3 → 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.
@@ -20,6 +20,8 @@ module Ocran
20
20
  AUTO_CLEAN_INST_DIR = 0x04
21
21
  CHDIR_BEFORE_SCRIPT = 0x08
22
22
  DATA_COMPRESSED = 0x10
23
+ RUN_IN_EXE_DIR = 0x20
24
+ CHDIR_TO_EXE_DIR = 0x40
23
25
 
24
26
  WINDOWS = Gem.win_platform?
25
27
 
@@ -76,6 +78,12 @@ module Ocran
76
78
  # When set to true, the working directory is changed to the application's
77
79
  # deployment location at runtime.
78
80
  #
81
+ # chdir_to_exe_dir:
82
+ # When set to true, the working directory is changed to the directory
83
+ # containing the (stub) executable before the script starts. This makes
84
+ # relative file access resolve next to the executable regardless of how
85
+ # it was invoked. Mutually exclusive with chdir_before.
86
+ #
79
87
  # debug_mode:
80
88
  # When the debug_mode option is set to true, the stub will output debug information
81
89
  # when the exe file is executed. Debug mode can also be enabled within the directive
@@ -95,8 +103,23 @@ module Ocran
95
103
  # icon_path:
96
104
  # Specifies the path to the icon file to be embedded in the stub's resources.
97
105
  #
98
- def initialize(path, chdir_before: nil, debug_extract: nil, debug_mode: nil,
99
- enable_compression: nil, gui_mode: nil, icon_path: nil)
106
+ # run_in_exe_dir:
107
+ # When set to true, the stub runs the application directly from its own
108
+ # directory instead of extracting to a temporary directory. Used for
109
+ # installer (Inno Setup) wrapper executables where the application files
110
+ # are installed next to the stub (pre-1.4/OCRA behavior). The directory
111
+ # is never deleted on exit.
112
+ #
113
+ # stub_path:
114
+ # Path to a stub binary to package with instead of the pre-built
115
+ # stub shipped with the gem (e.g. an APE stub freshly compiled with
116
+ # cosmocc, see --cosmo). When set, it takes precedence over both
117
+ # STUB_PATH and STUBW_PATH.
118
+ #
119
+ def initialize(path, chdir_before: nil, chdir_to_exe_dir: nil,
120
+ debug_extract: nil, debug_mode: nil,
121
+ enable_compression: nil, gui_mode: nil, icon_path: nil,
122
+ run_in_exe_dir: nil, stub_path: nil)
100
123
  @dirs = FilePathSet.new
101
124
  @files = FilePathSet.new
102
125
  @data_size = 0
@@ -108,7 +131,9 @@ module Ocran
108
131
  output_dir = File.dirname(path)
109
132
  FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
110
133
  stub_tmp = File.join(output_dir, ".ocran_stub_#{$$}_#{Time.now.to_i}")
111
- stub_src = if gui_mode && WINDOWS
134
+ stub_src = if stub_path
135
+ stub_path
136
+ elsif gui_mode && WINDOWS
112
137
  STUBW_PATH
113
138
  else
114
139
  STUB_PATH
@@ -129,7 +154,7 @@ module Ocran
129
154
  @of = of
130
155
  @opcode_offset = @of.size
131
156
 
132
- write_header(debug_mode, debug_extract, chdir_before, enable_compression)
157
+ write_header(debug_mode, debug_extract, chdir_before, enable_compression, run_in_exe_dir, chdir_to_exe_dir)
133
158
 
134
159
  b = proc {
135
160
  yield(self)
@@ -213,14 +238,21 @@ module Ocran
213
238
  end
214
239
  private :compress
215
240
 
216
- def write_header(debug_mode, debug_extract, chdir_before, compressed)
241
+ def write_header(debug_mode, debug_extract, chdir_before, compressed, run_in_exe_dir = nil, chdir_to_exe_dir = nil)
217
242
  next_to_exe, delete_after = debug_extract, !debug_extract
243
+ if run_in_exe_dir
244
+ # Wrapper mode: run in place next to the executable — never extract,
245
+ # and (critically) never delete the application directory on exit.
246
+ next_to_exe, delete_after = false, false
247
+ end
218
248
  @of << [0 |
219
249
  (debug_mode ? DEBUG_MODE : 0) |
220
250
  (next_to_exe ? EXTRACT_TO_EXE_DIR : 0) |
221
251
  (delete_after ? AUTO_CLEAN_INST_DIR : 0) |
222
252
  (chdir_before ? CHDIR_BEFORE_SCRIPT : 0) |
223
- (compressed ? DATA_COMPRESSED : 0)
253
+ (compressed ? DATA_COMPRESSED : 0) |
254
+ (run_in_exe_dir ? RUN_IN_EXE_DIR : 0) |
255
+ (chdir_to_exe_dir ? CHDIR_TO_EXE_DIR : 0)
224
256
  ].pack("C")
225
257
  end
226
258
  private :write_header
data/lib/ocran/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ocran
4
- VERSION = "1.4.3"
4
+ VERSION = "1.4.5"
5
5
  end
@@ -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)