rubycc 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +56 -0
  3. data/LICENSE.txt +21 -0
  4. data/NOTICE +52 -0
  5. data/README.md +208 -0
  6. data/data/README.md +117 -0
  7. data/data/r10_corpus_scan.json +4082 -0
  8. data/data/r10_manual_classification.json +5342 -0
  9. data/data/r10_verification_m4a.json +1531 -0
  10. data/data/r10_verification_m4b.json +1818 -0
  11. data/data/r10_verification_m4c.json +1489 -0
  12. data/data/r10_verification_m4d.json +318 -0
  13. data/data/r10_verification_rbs.json +192 -0
  14. data/data/verified_gems.json +397 -0
  15. data/exe/rmake +16 -0
  16. data/exe/rubycc +9 -0
  17. data/exe/rubycc-ar +114 -0
  18. data/exe/rubycc-doctor +14 -0
  19. data/exe/rubycc-pkgconf +8 -0
  20. data/include/float.h +87 -0
  21. data/include/iso646.h +18 -0
  22. data/include/libc/alloca.h +18 -0
  23. data/include/libc/arpa/inet.h +61 -0
  24. data/include/libc/assert.h +43 -0
  25. data/include/libc/dirent.h +72 -0
  26. data/include/libc/dlfcn.h +58 -0
  27. data/include/libc/features.h +205 -0
  28. data/include/libc/glibc/aarch64/ctype.h +108 -0
  29. data/include/libc/glibc/aarch64/endian.h +54 -0
  30. data/include/libc/glibc/aarch64/errno.h +154 -0
  31. data/include/libc/glibc/aarch64/fcntl.h +144 -0
  32. data/include/libc/glibc/aarch64/inttypes.h +182 -0
  33. data/include/libc/glibc/aarch64/limits.h +63 -0
  34. data/include/libc/glibc/aarch64/pthread.h +131 -0
  35. data/include/libc/glibc/aarch64/setjmp.h +84 -0
  36. data/include/libc/glibc/aarch64/stdint.h +174 -0
  37. data/include/libc/glibc/aarch64/sys/epoll.h +88 -0
  38. data/include/libc/glibc/aarch64/sys/fcntl.h +19 -0
  39. data/include/libc/glibc/aarch64/sys/select.h +75 -0
  40. data/include/libc/glibc/aarch64/sys/stat.h +136 -0
  41. data/include/libc/glibc/aarch64/sys/syscall.h +167 -0
  42. data/include/libc/glibc/aarch64/sys/time.h +74 -0
  43. data/include/libc/glibc/aarch64/sys/types.h +127 -0
  44. data/include/libc/glibc/aarch64/time.h +113 -0
  45. data/include/libc/glibc/x86_64/ctype.h +108 -0
  46. data/include/libc/glibc/x86_64/endian.h +54 -0
  47. data/include/libc/glibc/x86_64/errno.h +154 -0
  48. data/include/libc/glibc/x86_64/fcntl.h +134 -0
  49. data/include/libc/glibc/x86_64/inttypes.h +182 -0
  50. data/include/libc/glibc/x86_64/limits.h +61 -0
  51. data/include/libc/glibc/x86_64/pthread.h +111 -0
  52. data/include/libc/glibc/x86_64/setjmp.h +82 -0
  53. data/include/libc/glibc/x86_64/stdint.h +163 -0
  54. data/include/libc/glibc/x86_64/sys/epoll.h +91 -0
  55. data/include/libc/glibc/x86_64/sys/fcntl.h +19 -0
  56. data/include/libc/glibc/x86_64/sys/select.h +75 -0
  57. data/include/libc/glibc/x86_64/sys/stat.h +130 -0
  58. data/include/libc/glibc/x86_64/sys/syscall.h +188 -0
  59. data/include/libc/glibc/x86_64/sys/time.h +74 -0
  60. data/include/libc/glibc/x86_64/sys/types.h +123 -0
  61. data/include/libc/glibc/x86_64/time.h +113 -0
  62. data/include/libc/grp.h +50 -0
  63. data/include/libc/langinfo.h +126 -0
  64. data/include/libc/link.h +18 -0
  65. data/include/libc/locale.h +83 -0
  66. data/include/libc/math.h +191 -0
  67. data/include/libc/netinet/in.h +126 -0
  68. data/include/libc/netinet/tcp.h +44 -0
  69. data/include/libc/poll.h +46 -0
  70. data/include/libc/pwd.h +58 -0
  71. data/include/libc/regex.h +53 -0
  72. data/include/libc/sched.h +35 -0
  73. data/include/libc/signal.h +204 -0
  74. data/include/libc/stdio.h +157 -0
  75. data/include/libc/stdlib.h +92 -0
  76. data/include/libc/string.h +74 -0
  77. data/include/libc/strings.h +25 -0
  78. data/include/libc/sys/cdefs.h +126 -0
  79. data/include/libc/sys/inotify.h +111 -0
  80. data/include/libc/sys/ioctl.h +36 -0
  81. data/include/libc/sys/mman.h +65 -0
  82. data/include/libc/sys/param.h +41 -0
  83. data/include/libc/sys/resource.h +109 -0
  84. data/include/libc/sys/socket.h +167 -0
  85. data/include/libc/sys/statfs.h +82 -0
  86. data/include/libc/sys/timerfd.h +55 -0
  87. data/include/libc/sys/uio.h +40 -0
  88. data/include/libc/sys/un.h +25 -0
  89. data/include/libc/sys/utsname.h +35 -0
  90. data/include/libc/sys/wait.h +135 -0
  91. data/include/libc/termios.h +179 -0
  92. data/include/libc/unistd.h +194 -0
  93. data/include/stdalign.h +16 -0
  94. data/include/stdarg.h +31 -0
  95. data/include/stdatomic.h +158 -0
  96. data/include/stdbool.h +15 -0
  97. data/include/stdckdint.h +28 -0
  98. data/include/stddef.h +60 -0
  99. data/include/stdnoreturn.h +18 -0
  100. data/include/x86intrin.h +16 -0
  101. data/lib/rubycc/backend/aarch64.rb +1724 -0
  102. data/lib/rubycc/backend/x86_64.rb +1369 -0
  103. data/lib/rubycc/compile_error.rb +36 -0
  104. data/lib/rubycc/compiler.rb +305 -0
  105. data/lib/rubycc/doctor/builder.rb +151 -0
  106. data/lib/rubycc/doctor/cli.rb +190 -0
  107. data/lib/rubycc/doctor/fetcher.rb +84 -0
  108. data/lib/rubycc/doctor/gemfile.rb +115 -0
  109. data/lib/rubycc/doctor/verified_gems.rb +114 -0
  110. data/lib/rubycc/doctor.rb +16 -0
  111. data/lib/rubycc/driver.rb +463 -0
  112. data/lib/rubycc/front/ast.rb +528 -0
  113. data/lib/rubycc/front/constant_evaluator.rb +631 -0
  114. data/lib/rubycc/front/initializer_resolver.rb +592 -0
  115. data/lib/rubycc/front/lexeme_reader.rb +460 -0
  116. data/lib/rubycc/front/lexer.rb +232 -0
  117. data/lib/rubycc/front/parser.rb +4122 -0
  118. data/lib/rubycc/front/token.rb +49 -0
  119. data/lib/rubycc/ir/call_convention.rb +486 -0
  120. data/lib/rubycc/ir/generator.rb +6036 -0
  121. data/lib/rubycc/ir/ir.rb +417 -0
  122. data/lib/rubycc/link/compat_runtime.rb +89 -0
  123. data/lib/rubycc/link/errors.rb +13 -0
  124. data/lib/rubycc/link/executable_linker.rb +405 -0
  125. data/lib/rubycc/link/library_resolver.rb +437 -0
  126. data/lib/rubycc/link/partial_linker.rb +546 -0
  127. data/lib/rubycc/link/shared_linker.rb +1732 -0
  128. data/lib/rubycc/mkmf_shim.rb +83 -0
  129. data/lib/rubycc/objfile/ar_archive.rb +437 -0
  130. data/lib/rubycc/objfile/elf_reader.rb +646 -0
  131. data/lib/rubycc/objfile/elf_writer.rb +891 -0
  132. data/lib/rubycc/objfile/relocatable_writer.rb +376 -0
  133. data/lib/rubycc/pkgconf/cli.rb +139 -0
  134. data/lib/rubycc/pkgconf/errors.rb +39 -0
  135. data/lib/rubycc/pkgconf/model.rb +28 -0
  136. data/lib/rubycc/pkgconf/parser.rb +109 -0
  137. data/lib/rubycc/pkgconf/pkgconf.rb +15 -0
  138. data/lib/rubycc/pkgconf/resolver.rb +70 -0
  139. data/lib/rubycc/pkgconf/search_path.rb +38 -0
  140. data/lib/rubycc/pkgconf/system_path_filter.rb +131 -0
  141. data/lib/rubycc/preprocess/constant_expression.rb +219 -0
  142. data/lib/rubycc/preprocess/glibc_version.rb +124 -0
  143. data/lib/rubycc/preprocess/pp_token.rb +62 -0
  144. data/lib/rubycc/preprocess/preprocessor.rb +2020 -0
  145. data/lib/rubycc/preprocess/scanner.rb +290 -0
  146. data/lib/rubycc/preprocess/token_converter.rb +157 -0
  147. data/lib/rubycc/rmake/cli.rb +143 -0
  148. data/lib/rubycc/rmake/errors.rb +71 -0
  149. data/lib/rubycc/rmake/executor.rb +818 -0
  150. data/lib/rubycc/rmake/expander.rb +251 -0
  151. data/lib/rubycc/rmake/makefile.rb +352 -0
  152. data/lib/rubycc/rmake/model.rb +137 -0
  153. data/lib/rubycc/rmake/parser.rb +226 -0
  154. data/lib/rubycc/rmake/rmake.rb +15 -0
  155. data/lib/rubycc/type.rb +1236 -0
  156. data/lib/rubycc/version.rb +5 -0
  157. data/lib/rubycc.rb +36 -0
  158. data/lib/rubygems_plugin.rb +102 -0
  159. metadata +219 -0
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ # The mkmf integration shim: requiring this file (before `require "mkmf"`)
6
+ # reroutes every conftest command mkmf issues — compile, link, preprocess — to
7
+ # rubycc, without patching mkmf itself. mkmf builds its probe commands out of a
8
+ # handful of RbConfig keys (`$(CC)`, `$(LDSHARED)`, `$(CPP)`, `$(PKG_CONFIG)`)
9
+ # and runs them through `system(env, *command)`, so replacing just those keys
10
+ # with the rubycc executables makes `have_header`, `have_func`, `have_library`,
11
+ # `try_compile`/`try_link`/`try_run`, `check_sizeof` and the rest resolve
12
+ # against rubycc's own compiler and linker. mkmf.log stays untouched — mkmf
13
+ # writes it — so the log's format (the "checked program was:" sections, the
14
+ # echoed command lines) is the genuine article.
15
+ #
16
+ # Two RbConfig hashes are rewritten: CONFIG, which mkmf merges into every
17
+ # conftest command's expansion context, and MAKEFILE_CONFIG, which the generated
18
+ # Makefile's `CC = …`/`LDSHARED = …` assignments are drawn from — so both the
19
+ # probe phase and the eventual build route through rubycc. The change is
20
+ # in-process only (no file on disk is modified) and idempotent: requiring the
21
+ # shim twice is a no-op.
22
+ #
23
+ # The rubycc executables shebang-launch `require "rubycc"`, which needs the
24
+ # repository's `lib/` on the load path when rubycc is run from a checkout rather
25
+ # than an installed gem. The shim prepends that directory to `RUBYLIB` so the
26
+ # value propagates to the child processes mkmf spawns.
27
+ module Rubycc
28
+ module MkmfShim
29
+ # The repository's lib/ directory (this file lives at lib/rubycc/mkmf_shim.rb)
30
+ # and the gcc-compatible executables that stand in for the external toolchain.
31
+ LIB_DIR = File.expand_path("..", __dir__)
32
+ RUBYCC_EXE = File.expand_path("../../exe/rubycc", __dir__)
33
+ PKGCONF_EXE = File.expand_path("../../exe/rubycc-pkgconf", __dir__)
34
+
35
+ # The RbConfig keys rewritten to point at rubycc. Each maps to the command
36
+ # string mkmf embeds verbatim when it expands `$(CC)` and friends: a single
37
+ # shell-metacharacter-free executable path (plus, for the compound tools, the
38
+ # driver's own mode flag) so mkmf's `system(env, *command)` execs it directly.
39
+ def self.replacements
40
+ {
41
+ "CC" => RUBYCC_EXE,
42
+ "LDSHARED" => "#{RUBYCC_EXE} -shared",
43
+ "CPP" => "#{RUBYCC_EXE} -E",
44
+ "PKG_CONFIG" => PKGCONF_EXE
45
+ }
46
+ end
47
+
48
+ # Rewrites the toolchain keys in both RbConfig hashes and prepends the
49
+ # repository lib/ to RUBYLIB. Idempotent: the RUBYLIB entry is added only
50
+ # once, and re-assigning the same command strings is harmless.
51
+ def self.install!
52
+ configs = [RbConfig::CONFIG]
53
+ configs << RbConfig::MAKEFILE_CONFIG if defined?(RbConfig::MAKEFILE_CONFIG)
54
+
55
+ replacements.each do |key, value|
56
+ configs.each do |config|
57
+ # PKG_CONFIG is only meaningful if the host build already knew a
58
+ # pkg-config; leave an absent key absent so mkmf's own "no pkg-config"
59
+ # path is preserved, but override a present one (even the empty string
60
+ # RbConfig ships when none was found) to point at rubycc-pkgconf.
61
+ next if key == "PKG_CONFIG" && !config.key?(key)
62
+
63
+ config[key] = value
64
+ end
65
+ end
66
+
67
+ prepend_rubylib!
68
+ end
69
+
70
+ # Puts the repository lib/ at the front of RUBYLIB so a rubycc executable
71
+ # spawned by mkmf can `require "rubycc"` from a source checkout. Skips the
72
+ # work when the directory is already the leading entry.
73
+ def self.prepend_rubylib!
74
+ current = ENV["RUBYLIB"]
75
+ entries = current.to_s.split(File::PATH_SEPARATOR)
76
+ return if entries.first == LIB_DIR
77
+
78
+ ENV["RUBYLIB"] = [LIB_DIR, *current&.split(File::PATH_SEPARATOR)].join(File::PATH_SEPARATOR)
79
+ end
80
+ end
81
+ end
82
+
83
+ Rubycc::MkmfShim.install!
@@ -0,0 +1,437 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycc
4
+ module ObjFile
5
+ # Raised for any input that is not a well-formed GNU `ar` archive this reader
6
+ # accepts: a bad global magic, a member header that is truncated or missing
7
+ # its `\x60\n` terminator, a non-numeric size field, a size that runs past the
8
+ # end of the file, or a long-name reference that cannot be resolved against
9
+ # the `//` extended-name table. The message names the specific defect so the
10
+ # linker (L3), which reads static libraries through this class, can surface it.
11
+ class ArFormatError < Rubycc::Error; end
12
+
13
+ # The eight-byte magic that opens every `ar` archive.
14
+ AR_MAGIC = "!<arch>\n".b
15
+
16
+ # A single archive member: its resolved `name`, raw `data` bytes, and the four
17
+ # POSIX metadata fields (`mtime`/`uid`/`gid`/`mode`) the 60-byte header
18
+ # carries. `header_offset` is the byte position of that header in the whole
19
+ # image — the value the symbol index points at, so a symbol can be mapped back
20
+ # to the member that defines it. `special?` marks the two reserved members
21
+ # (`/` symbol table, `//` name table) that are archive metadata rather than
22
+ # real files, so callers listing or extracting members can skip them.
23
+ ArMember = Struct.new(
24
+ :name, :data, :mtime, :uid, :gid, :mode, :header_offset, :special,
25
+ keyword_init: true
26
+ ) do
27
+ def special? = special
28
+ end
29
+
30
+ # Reads a GNU-format `ar` archive into its members and (when present) its
31
+ # symbol index. BSD archives are out of scope; only `!<arch>\n` is accepted.
32
+ #
33
+ # The model mirrors ELFReader's load-then-query taste: `read`/`read_file`
34
+ # walk the whole image once into `#members` (in file order) and, if the
35
+ # archive carries one, a symbol name -> member map. That map is exactly what
36
+ # the linker's lazy-extraction driver queries ("which member defines this
37
+ # still-undefined symbol?"), so it is exposed both as the raw ordered
38
+ # `#symbols` list and the first-wins `#symbol_index` hash.
39
+ #
40
+ # Three reserved member names are understood: `/` is the classic ranlib
41
+ # symbol table (big-endian uint32 count, that many big-endian uint32
42
+ # member-header offsets, then NUL-terminated symbol names); `//` is the
43
+ # extended-name table holding long member names each terminated by `/\n`; and
44
+ # `/N` in a member's name field is a decimal byte offset into that `//` table.
45
+ # A short name is stored as `name/`, the trailing slash marking its end so a
46
+ # name may keep trailing spaces. Archives written without a symbol index
47
+ # (plain `ar qc`) are tolerated: `#symbols` is then empty.
48
+ class ArReader
49
+ HEADER_SIZE = 60
50
+
51
+ # Field offsets and widths within a 60-byte member header. The five ASCII
52
+ # fields are space-padded; only `size` (decimal) is needed to walk the
53
+ # archive, the rest are decoded for the member's metadata.
54
+ NAME_OFF = 0
55
+ NAME_LEN = 16
56
+ MTIME_OFF = 16
57
+ MTIME_LEN = 12
58
+ UID_OFF = 28
59
+ UID_LEN = 6
60
+ GID_OFF = 34
61
+ GID_LEN = 6
62
+ MODE_OFF = 40
63
+ MODE_LEN = 8
64
+ SIZE_OFF = 48
65
+ SIZE_LEN = 10
66
+ MAGIC_OFF = 58
67
+ HEADER_MAGIC = "\x60\x0a".b
68
+
69
+ class << self
70
+ # Parses an in-memory archive (an ASCII-8BIT String) into a ready-to-query
71
+ # reader.
72
+ def read(bytes)
73
+ new(bytes).tap(&:parse!)
74
+ end
75
+
76
+ # Reads an archive file from disk and parses it.
77
+ def read_file(path)
78
+ read(File.binread(path))
79
+ end
80
+ end
81
+
82
+ attr_reader :members, :symbols, :name_table
83
+
84
+ def initialize(bytes)
85
+ @data = bytes.b
86
+ @members = []
87
+ @symbols = []
88
+ @name_table = nil
89
+ @symtab_raw = nil
90
+ end
91
+
92
+ def parse!
93
+ unless @data.byteslice(0, AR_MAGIC.bytesize) == AR_MAGIC
94
+ raise ArFormatError, "not an ar archive (bad global magic)"
95
+ end
96
+
97
+ pos = AR_MAGIC.bytesize
98
+ # This loop always terminates: parse_member advances pos by at least the
99
+ # 60-byte header plus a non-negative member size (a size field that is not
100
+ # a non-negative decimal is rejected outright), so pos strictly increases
101
+ # by >= 60 each turn and reaches @data.bytesize in a bounded number of
102
+ # steps — a malformed archive cannot make it stall or loop.
103
+ pos = parse_member(pos) while pos < @data.bytesize
104
+ resolve_symbol_index
105
+ self
106
+ end
107
+
108
+ # The member with the given name, or nil. Regular members only — the `/`
109
+ # and `//` metadata members never carry a user-facing name.
110
+ def member(name)
111
+ @members.find { |m| !m.special? && m.name == name }
112
+ end
113
+
114
+ # symbol name -> defining member, first definition winning when a symbol is
115
+ # exported by more than one member (the order the linker resolves in).
116
+ def symbol_index
117
+ @symbol_index ||= @symbols.each_with_object({}) do |entry, map|
118
+ map[entry[:name]] ||= entry[:member]
119
+ end
120
+ end
121
+
122
+ # The member that defines `symbol_name`, or nil — the query the lazy linker
123
+ # asks for each still-undefined reference.
124
+ def member_defining(symbol_name)
125
+ symbol_index[symbol_name]
126
+ end
127
+
128
+ private
129
+
130
+ # Parses one member starting at `pos` and returns the offset of the next
131
+ # member (past the even-boundary padding byte). The `/` and `//` reserved
132
+ # members are recorded like any other but flagged special and, for `//`,
133
+ # captured as the name table so later `/N` references resolve.
134
+ def parse_member(pos)
135
+ if pos + HEADER_SIZE > @data.bytesize
136
+ raise ArFormatError, "truncated member header at offset #{pos}"
137
+ end
138
+
139
+ header = @data.byteslice(pos, HEADER_SIZE)
140
+ unless header.byteslice(MAGIC_OFF, 2) == HEADER_MAGIC
141
+ raise ArFormatError, "member header at offset #{pos} lacks its `\\x60\\n` terminator"
142
+ end
143
+
144
+ size = decimal_field(header, SIZE_OFF, SIZE_LEN, "size", pos)
145
+ data_off = pos + HEADER_SIZE
146
+ if data_off + size > @data.bytesize
147
+ raise ArFormatError, "member at offset #{pos} declares #{size} bytes running past end of file"
148
+ end
149
+ data = @data.byteslice(data_off, size)
150
+
151
+ raw_name = header.byteslice(NAME_OFF, NAME_LEN)
152
+ kind, resolved = classify_name(raw_name, pos)
153
+
154
+ case kind
155
+ when :symtab
156
+ @symtab_raw = data
157
+ when :nametable
158
+ @name_table = data
159
+ end
160
+
161
+ @members << ArMember.new(
162
+ name: resolved,
163
+ data: data,
164
+ mtime: decimal_field(header, MTIME_OFF, MTIME_LEN, "mtime", pos, allow_blank: true),
165
+ uid: decimal_field(header, UID_OFF, UID_LEN, "uid", pos, allow_blank: true),
166
+ gid: decimal_field(header, GID_OFF, GID_LEN, "gid", pos, allow_blank: true),
167
+ mode: octal_field(header, MODE_OFF, MODE_LEN, pos),
168
+ header_offset: pos,
169
+ special: kind == :symtab || kind == :nametable
170
+ )
171
+
172
+ # Member data is padded with a single `\n` to an even offset; the size
173
+ # field counts only the real bytes, so an odd size means one pad byte.
174
+ data_off + size + (size.odd? ? 1 : 0)
175
+ end
176
+
177
+ # Decodes the 16-byte name field into [kind, name]. `/` alone is the symbol
178
+ # table, `//` the extended-name table, `/N` a long-name reference resolved
179
+ # through that table, and anything else a short `name/` (or, defensively, a
180
+ # bare name) with its trailing slash stripped.
181
+ def classify_name(raw_name, pos)
182
+ trimmed = raw_name.rstrip
183
+ if trimmed == "/"
184
+ [:symtab, "/"]
185
+ elsif trimmed == "//"
186
+ [:nametable, "//"]
187
+ elsif trimmed.start_with?("/") && trimmed.byteslice(1..).match?(/\A\d+\z/)
188
+ [:long, resolve_long_name(trimmed.byteslice(1..).to_i, pos)]
189
+ else
190
+ [:short, trimmed.chomp("/")]
191
+ end
192
+ end
193
+
194
+ # Resolves a `/N` reference: read from byte `offset` in the `//` table up to
195
+ # the terminating `\n` and drop the trailing `/` GNU appends to each entry.
196
+ def resolve_long_name(offset, pos)
197
+ table = @name_table or
198
+ raise ArFormatError, "member at offset #{pos} references a name table that is absent"
199
+ if offset >= table.bytesize
200
+ raise ArFormatError, "member at offset #{pos} references name-table offset #{offset} out of range"
201
+ end
202
+
203
+ stop = table.index("\n".b, offset) || table.bytesize
204
+ table.byteslice(offset...stop).chomp("/").force_encoding(Encoding::UTF_8)
205
+ end
206
+
207
+ # Turns the raw `/` symbol table into name -> member entries. Deferred until
208
+ # every member's header_offset is known so each big-endian offset can be
209
+ # matched to the member it names. A symbol pointing at an unknown offset is
210
+ # skipped rather than fatal — a stale index should not sink an otherwise
211
+ # readable archive.
212
+ def resolve_symbol_index
213
+ return if @symtab_raw.nil? || @symtab_raw.bytesize < 4
214
+
215
+ by_offset = @members.each_with_object({}) { |m, h| h[m.header_offset] = m }
216
+ count = @symtab_raw.byteslice(0, 4).unpack1("N")
217
+ offsets_end = 4 + count * 4
218
+ return if offsets_end > @symtab_raw.bytesize
219
+
220
+ offsets = @symtab_raw.byteslice(4, count * 4).unpack("N*")
221
+ names_blob = @symtab_raw.byteslice(offsets_end..) || "".b
222
+
223
+ cursor = 0
224
+ count.times do |i|
225
+ stop = names_blob.index("\0".b, cursor) or break
226
+ name = names_blob.byteslice(cursor...stop).force_encoding(Encoding::UTF_8)
227
+ cursor = stop + 1
228
+ member = by_offset[offsets[i]]
229
+ @symbols << { name: name, member: member } if member
230
+ end
231
+ end
232
+
233
+ # Reads a space-padded ASCII decimal field. A field of all spaces decodes to
234
+ # 0 when `allow_blank` (GNU leaves the deterministic uid/gid/mtime blank in
235
+ # some writers); a non-numeric size or similar is a format error.
236
+ def decimal_field(header, offset, length, what, pos, allow_blank: false)
237
+ text = header.byteslice(offset, length).strip
238
+ return 0 if text.empty? && allow_blank
239
+ unless text.match?(/\A\d+\z/)
240
+ raise ArFormatError, "member at offset #{pos} has a non-numeric #{what} field #{text.inspect}"
241
+ end
242
+
243
+ text.to_i
244
+ end
245
+
246
+ # Reads the octal mode field; a blank field means 0.
247
+ def octal_field(header, offset, length, pos)
248
+ text = header.byteslice(offset, length).strip
249
+ return 0 if text.empty?
250
+ unless text.match?(/\A[0-7]+\z/)
251
+ raise ArFormatError, "member at offset #{pos} has a non-octal mode field #{text.inspect}"
252
+ end
253
+
254
+ text.to_i(8)
255
+ end
256
+ end
257
+
258
+ # Builds a GNU-format `ar` archive. The counterpart of ArReader, mirroring
259
+ # ELFWriter's builder taste: members are appended in order with #add_member
260
+ # and the whole image is assembled by #to_binary.
261
+ #
262
+ # Every archive is written with a classic ranlib symbol index as its first
263
+ # `/` member (always, even when empty), so output is a drop-in for `ar rcs`
264
+ # and the linker can extract lazily. The index's symbols are gathered by
265
+ # handing each member's bytes to ELFReader and taking its defined global and
266
+ # weak symbols; a member ELFReader cannot parse (a non-ELF file) simply
267
+ # contributes none. Long member names (over 15 characters, once the trailing
268
+ # `/` is counted) move to a `//` extended-name table and are referenced by a
269
+ # `/N` byte offset; shorter names are stored inline as `name/`.
270
+ #
271
+ # Output is fully deterministic (N4): mtime, uid and gid are pinned to 0 and
272
+ # the mode to 0644 (the metadata GNU `ar` writes in deterministic mode), so
273
+ # identical member bytes always produce a byte-identical archive.
274
+ class ArWriter
275
+ # Pinned deterministic member metadata. The reserved `/` and `//` members
276
+ # carry a 0 mode, matching GNU.
277
+ DETERMINISTIC_MTIME = "0"
278
+ DETERMINISTIC_UID = "0"
279
+ DETERMINISTIC_GID = "0"
280
+ REGULAR_MODE = "644"
281
+ SPECIAL_MODE = "0"
282
+
283
+ # A name plus its trailing-slash form fits inline when at most this many
284
+ # bytes; a longer name goes to the `//` table.
285
+ INLINE_NAME_CAPACITY = 16
286
+
287
+ def initialize
288
+ @members = []
289
+ end
290
+
291
+ # Appends a member with the given name and raw data. Names are not
292
+ # de-duplicated here — replace-or-append is the CLI's job; the writer lays
293
+ # out exactly what it is given, in order.
294
+ def add_member(name, data)
295
+ @members << { name: name.to_s, data: data.b }
296
+ self
297
+ end
298
+
299
+ # Assembles and returns the archive as an ASCII-8BIT String.
300
+ def to_binary
301
+ entries = @members.map { |m| { name: m[:name], data: m[:data], symbols: gather_symbols(m[:data]) } }
302
+
303
+ name_table, name_field = build_name_table(entries)
304
+ flat_symbols = flatten_symbols(entries)
305
+ symtab_size = symtab_byte_size(flat_symbols)
306
+
307
+ # The symbol index and the name table precede every regular member, so
308
+ # each regular member's header offset — the value the index records — is
309
+ # known once their spans are summed. The index's own size depends only on
310
+ # the symbol count and names, not on the offsets it will hold, so there is
311
+ # no circular dependency.
312
+ base = AR_MAGIC.bytesize + member_span(symtab_size)
313
+ base += member_span(name_table.bytesize) unless name_table.empty?
314
+ offset = base
315
+ entries.each do |entry|
316
+ entry[:header_offset] = offset
317
+ offset += member_span(entry[:data].bytesize)
318
+ end
319
+
320
+ symtab = build_symtab(flat_symbols, entries)
321
+
322
+ out = +"".b
323
+ out << AR_MAGIC
324
+ emit_member(out, "/", symtab, SPECIAL_MODE)
325
+ emit_member(out, "//", name_table, SPECIAL_MODE) unless name_table.empty?
326
+ entries.each { |entry| emit_member(out, name_field[entry.object_id], entry[:data], REGULAR_MODE) }
327
+ out
328
+ end
329
+
330
+ # Convenience: assemble and write the archive to `path`.
331
+ def write(path)
332
+ File.binwrite(path, to_binary)
333
+ end
334
+
335
+ private
336
+
337
+ # The defined global and weak symbol names an ELF member exports, gathered
338
+ # through ELFReader so no ELF is re-parsed by hand. A non-ELF member (or one
339
+ # ELFReader rejects) contributes nothing.
340
+ def gather_symbols(data)
341
+ reader = ELFReader.read(data)
342
+ reader.symbols.select do |sym|
343
+ (sym.bind == :global || sym.bind == :weak) && !sym.undefined? && !sym.name.to_s.empty?
344
+ end.map(&:name)
345
+ rescue ELFFormatError
346
+ []
347
+ end
348
+
349
+ # Assigns every member a name field, moving long names into the `//` table.
350
+ # Returns [table_bytes, entry.object_id -> name field]. A name fits inline
351
+ # when `name/` is at most 16 bytes; otherwise it is appended to the table
352
+ # (terminated by `/\n`, GNU's convention) and referenced by `/offset`.
353
+ def build_name_table(entries)
354
+ table = +"".b
355
+ fields = {}
356
+ entries.each do |entry|
357
+ name = entry[:name]
358
+ if name.bytesize + 1 > INLINE_NAME_CAPACITY
359
+ fields[entry.object_id] = "/#{table.bytesize}"
360
+ table << name.b << "/\n".b
361
+ else
362
+ fields[entry.object_id] = "#{name}/"
363
+ end
364
+ end
365
+ [table, fields]
366
+ end
367
+
368
+ # Flattens every member's symbols into [name, entry] pairs in member order,
369
+ # so the index lists a member's exports contiguously and points them all at
370
+ # that member's header.
371
+ def flatten_symbols(entries)
372
+ entries.flat_map { |entry| entry[:symbols].map { |name| [name, entry] } }
373
+ end
374
+
375
+ # The `/` symbol table's byte size: the uint32 count, one uint32 per symbol
376
+ # offset, then each NUL-terminated name.
377
+ def symtab_byte_size(flat_symbols)
378
+ 4 + 4 * flat_symbols.size + flat_symbols.sum { |(name, _)| name.bytesize + 1 }
379
+ end
380
+
381
+ # Builds the `/` symbol table body once member header offsets are fixed:
382
+ # big-endian count, big-endian member-header offsets, then the names.
383
+ def build_symtab(flat_symbols, _entries)
384
+ buf = +"".b
385
+ buf << [flat_symbols.size].pack("N")
386
+ flat_symbols.each { |(_, entry)| buf << [entry[:header_offset]].pack("N") }
387
+ flat_symbols.each { |(name, _)| buf << name.b << "\0".b }
388
+ buf
389
+ end
390
+
391
+ # The whole-file span a member occupies: its 60-byte header, its data, and
392
+ # the single `\n` pad that keeps the next member on an even offset.
393
+ def member_span(data_size)
394
+ HEADER_SIZE + data_size + (data_size.odd? ? 1 : 0)
395
+ end
396
+
397
+ HEADER_SIZE = 60
398
+
399
+ # Appends a member (header, data, and the even-boundary pad byte) to `out`.
400
+ def emit_member(out, name_field, data, mode)
401
+ out << member_header(name_field, data.bytesize, mode)
402
+ out << data
403
+ out << "\n".b if data.bytesize.odd?
404
+ end
405
+
406
+ # A 60-byte member header. Every ASCII field is left-justified and
407
+ # space-padded; the deterministic metadata is pinned and only name, size and
408
+ # mode vary between members.
409
+ def member_header(name_field, size, mode)
410
+ header = +"".b
411
+ header << pad(name_field, NAME_LEN)
412
+ header << pad(DETERMINISTIC_MTIME, MTIME_LEN)
413
+ header << pad(DETERMINISTIC_UID, UID_LEN)
414
+ header << pad(DETERMINISTIC_GID, GID_LEN)
415
+ header << pad(mode, MODE_LEN)
416
+ header << pad(size.to_s, SIZE_LEN)
417
+ header << HEADER_MAGIC
418
+ header
419
+ end
420
+
421
+ NAME_LEN = 16
422
+ MTIME_LEN = 12
423
+ UID_LEN = 6
424
+ GID_LEN = 6
425
+ MODE_LEN = 8
426
+ SIZE_LEN = 10
427
+ HEADER_MAGIC = "\x60\x0a".b
428
+
429
+ def pad(text, width)
430
+ bytes = text.to_s.b
431
+ raise ArFormatError, "field #{text.inspect} exceeds #{width} bytes" if bytes.bytesize > width
432
+
433
+ bytes + (" ".b * (width - bytes.bytesize))
434
+ end
435
+ end
436
+ end
437
+ end