omnizip 0.3.4 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f138ecaafda0455b4a5d3abce1ce426dcbfc2fd851bcdf0e0b0892a9b9b4287
4
- data.tar.gz: 1f94b97bd5e7b102abb907170d393edf4b192059c6aa5862d81b5b48ee354466
3
+ metadata.gz: 6a395e650c4db2554fed636efcec40fec1b1f77e77d2c778fbfbe336fe5fbcd8
4
+ data.tar.gz: 7846eed6f91af6b9e21cb0d17418bbbbe868c2c76109e1456a8db2a8eb561094
5
5
  SHA512:
6
- metadata.gz: 946e0c8adee99dde0acbf309a1ca30765459f15d543775f1964ca603eee24bd1628582797aa7567ed367c46d0f6adf03f56541071e83dfe0ca91f476289f0166
7
- data.tar.gz: 3e9760f59d981bd97976099dd46eae135ad569a1259d37205a51b4e8a081379ff3f53c08d262a680a131619ca16dd8cdeb27aec89e381e1347d4c2436ac24a62
6
+ metadata.gz: 32c4633a90fe9d134cea551c0dd2293d0e488d32650a136d1cc41d5c8b438fd762a181cdadaa212366fe10a0c6066b2fc27a162a9129dacbd5485906b130a6ce
7
+ data.tar.gz: dcc450866ff51cec1373009aee2dc1d4a7d37a061dd2dbcb3ceb9e50f34cdf8465b33a63261bf10f334d5dc6512940b7cb1d97392c0b33067d53f85a99b5679e
@@ -188,6 +188,11 @@ module Omnizip
188
188
  temp.reverse
189
189
  end
190
190
 
191
+ # Truncate entries in place
192
+ def truncate_entries
193
+ @entries.replace(truncate)
194
+ end
195
+
191
196
  # Pack table to binary data
192
197
  #
193
198
  # @return [String] Binary data
@@ -212,9 +212,15 @@ module Omnizip
212
212
  @reserved = values[13]
213
213
 
214
214
  # Decode name from UTF-16LE
215
- name_data = @name_utf16[0...@name_len]
215
+ # name_len includes the null terminator
216
+ name_data = @name_utf16[0...@name_len] if @name_len.positive?
216
217
  @name = begin
217
- Types::Variant.load(Types::Variant::VT_LPWSTR, name_data)
218
+ # Decode and strip null terminator
219
+ decoded = name_data.dup.force_encoding(Encoding::UTF_16LE)
220
+ # Remove trailing null character (UTF-16 null = 2 bytes)
221
+ null_char = "\x00".encode(Encoding::UTF_16LE)
222
+ decoded = decoded.chomp(null_char)
223
+ decoded.encode(Encoding::UTF_8)
218
224
  rescue StandardError
219
225
  ""
220
226
  end
@@ -248,11 +254,14 @@ module Omnizip
248
254
  #
249
255
  # @return [String] 128-byte binary data
250
256
  def pack
251
- # Encode name to UTF-16LE
257
+ # Encode name to UTF-16LE with null terminator
252
258
  name_data = Types::Variant.dump(Types::Variant::VT_LPWSTR, @name)
253
- name_data = name_data[0, 62] if name_data.length > 62
254
- name_data += "\x00\x00".b
259
+ # Truncate to 62 bytes if needed (leaving room for null terminator)
260
+ name_data = name_data[0, 62] + "\x00\x00".b if name_data.length > 62
261
+ # Ensure null terminator exists
262
+ name_data += "\x00\x00".b unless name_data.end_with?("\x00\x00".b)
255
263
  @name_len = name_data.length
264
+ # Pad to 64 bytes total
256
265
  @name_utf16 = name_data + ("\x00".b * (64 - name_data.length))
257
266
 
258
267
  # Set type_id from type
@@ -291,6 +300,29 @@ module Omnizip
291
300
  bat.read(@first_block, @size)
292
301
  end
293
302
 
303
+ # Open stream for reading or writing
304
+ #
305
+ # @param mode [String] Open mode ('r' for read, 'w' for write)
306
+ # @yield [RangesIOMigrateable] IO object
307
+ # @return [RangesIOMigrateable]
308
+ # @raise [Errno::EISDIR] If entry is a directory
309
+ def open(mode = "r")
310
+ raise Errno::EISDIR unless file?
311
+
312
+ io = RangesIOMigrateable.new(self, mode)
313
+ @modify_time = Time.now if io.respond_to?(:writeable?) && io.writeable?
314
+
315
+ if block_given?
316
+ begin
317
+ yield io
318
+ ensure
319
+ io.close
320
+ end
321
+ else
322
+ io
323
+ end
324
+ end
325
+
294
326
  # Lookup child by name
295
327
  #
296
328
  # @param name [String] Child name
@@ -51,8 +51,12 @@ module Omnizip
51
51
  #
52
52
  # @param ranges [Array<Range, Array>] Byte ranges
53
53
  def ranges=(ranges)
54
- # Convert Range objects to arrays
55
- @ranges = ranges.map do |r|
54
+ ranges ||= []
55
+
56
+ # Convert Range objects to arrays, filtering out nils
57
+ @ranges = ranges.filter_map do |r|
58
+ next nil if r.nil?
59
+
56
60
  r.is_a?(Range) ? [r.begin, r.end - r.begin] : r
57
61
  end
58
62
 
@@ -259,6 +263,67 @@ module Omnizip
259
263
  @io.truncate(max_pos) if max_pos > @io.size
260
264
  end
261
265
  end
266
+
267
+ # RangesIO that can migrate between BAT and SBAT based on size
268
+ class RangesIOMigrateable < RangesIOResizeable
269
+ # @return [Dirent] Associated dirent
270
+ attr_reader :dirent
271
+
272
+ # Initialize migrateable RangesIO
273
+ #
274
+ # @param dirent [Dirent] Associated dirent
275
+ # @param mode [String] Open mode
276
+ def initialize(dirent, mode = "r")
277
+ @dirent = dirent
278
+ bat = dirent.ole.bat_for_size(dirent.size)
279
+ super(bat, first_block: dirent.first_block, size: dirent.size)
280
+ @mode = mode
281
+ end
282
+
283
+ # Check if writable
284
+ def writeable?
285
+ @mode.include?("w") || @mode.include?("a") || @mode.include?("+")
286
+ end
287
+
288
+ # Truncate with BAT migration support
289
+ #
290
+ # @param new_size [Integer] New size in bytes
291
+ def truncate(new_size)
292
+ new_bat = @dirent.ole.bat_for_size(new_size)
293
+
294
+ if new_bat.instance_of?(@bat.class)
295
+ super
296
+ else
297
+ # BAT migration needed
298
+ pos = [@pos, new_size].min
299
+ self.pos = 0
300
+ keep = read([@size, new_size].min)
301
+ super(0)
302
+
303
+ @bat = new_bat
304
+ @io = new_bat.io
305
+ super
306
+
307
+ self.pos = 0
308
+ write(keep)
309
+ self.pos = pos
310
+ end
311
+
312
+ # Update dirent's first_block and size after any resize
313
+ @dirent.first_block = @first_block
314
+ @dirent.size = new_size
315
+ end
316
+
317
+ # Forward first_block to dirent (for reading)
318
+ def first_block
319
+ @first_block
320
+ end
321
+
322
+ def first_block=(val)
323
+ @first_block = val
324
+ @dirent.first_block = val
325
+ end
326
+ end
262
327
  end
263
328
  end
264
329
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
3
4
  require_relative "constants"
4
5
  require_relative "header"
5
6
  require_relative "allocation_table"
@@ -142,28 +143,178 @@ module Omnizip
142
143
  #
143
144
  # @param dirents [Array<Dirent>]
144
145
  # @param idx [Integer]
146
+ # @param visited [Set] Set of visited indices to detect cycles
145
147
  # @return [Array<Dirent>]
146
- def build_tree(dirents, idx = 0)
148
+ def build_tree(dirents, idx = 0, visited = nil)
147
149
  return [] if idx == EOT
148
150
 
151
+ # Initialize visited set on first call
152
+ visited ||= Set.new
153
+
154
+ # Check for circular references
155
+ return [] if visited.include?(idx)
156
+
157
+ visited << idx
158
+
149
159
  dirent = dirents[idx]
160
+ return [] unless dirent
150
161
 
151
162
  # Build children recursively
152
- build_tree(dirents, dirent.child).each { |child| dirent << child }
163
+ build_tree(dirents, dirent.child, visited).each { |child| dirent << child }
153
164
 
154
165
  # Set index
155
166
  dirent.idx = idx
156
167
 
157
168
  # Return list for tree building
158
- build_tree(dirents, dirent.prev) + [dirent] + build_tree(dirents, dirent.next)
169
+ build_tree(dirents, dirent.prev, visited) + [dirent] + build_tree(dirents, dirent.next, visited)
159
170
  end
160
171
 
161
172
  # Close storage
162
173
  def close
163
174
  @sb_file&.close
175
+ flush if @writeable
164
176
  @io.close if @close_parent
165
177
  end
166
178
 
179
+ # Flush all changes to disk
180
+ #
181
+ # Writes all metadata (dirents, allocation tables, header) to the file.
182
+ # This is the main "save" method for OLE documents.
183
+ def flush
184
+ return unless @writeable
185
+
186
+ # Update root dirent
187
+ @root.name = "Root Entry"
188
+ @root.first_block = @sb_file.first_block
189
+ @root.size = @sb_file.size
190
+
191
+ # Flatten dirent tree
192
+ @dirents = @root.flatten
193
+
194
+ # Serialize dirents using bbat
195
+ dirent_io = RangesIOResizeable.new(@bbat, first_block: @header.dirent_start)
196
+ dirent_io.write(@dirents.map(&:pack).join)
197
+ # Pad to block boundary
198
+ padding = ((dirent_io.size / @bbat.block_size.to_f).ceil * @bbat.block_size) - dirent_io.size
199
+ dirent_io.write("\x00".b * padding) if padding.positive?
200
+ @header.dirent_start = dirent_io.first_block
201
+ dirent_io.close
202
+
203
+ # Serialize sbat
204
+ sbat_io = RangesIOResizeable.new(@bbat, first_block: @header.sbat_start)
205
+ sbat_io.write(@sbat.pack)
206
+ @header.sbat_start = sbat_io.first_block
207
+ @header.num_sbat = @bbat.chain(@header.sbat_start).length
208
+ sbat_io.close
209
+
210
+ # Clear BAT/META_BAT markers
211
+ @bbat.entries.each_with_index do |val, idx|
212
+ if [BAT, META_BAT].include?(val)
213
+ @bbat.entries[idx] = AVAIL
214
+ end
215
+ end
216
+
217
+ # Calculate and allocate BAT blocks
218
+ write_bat_blocks
219
+
220
+ # Write header
221
+ @io.seek(0)
222
+ @io.write(@header.pack)
223
+ @io.write(@bbat_chain.pack("V*"))
224
+ @io.flush
225
+ end
226
+
227
+ private
228
+
229
+ # Write BAT (Block Allocation Table) blocks
230
+ def write_bat_blocks
231
+ # Truncate bbat to remove trailing AVAILs
232
+ @bbat.entries.replace(@bbat.entries.reject { |e| e == AVAIL }.push(AVAIL))
233
+
234
+ # Calculate space needed for BAT
235
+ num_mbat_blocks = 0
236
+ io = RangesIOResizeable.new(@bbat, first_block: EOC)
237
+
238
+ @bbat.truncate_entries
239
+ @io.truncate(@bbat.block_size * (@bbat.length + 1))
240
+
241
+ # Iteratively calculate BAT/MBAT space
242
+ loop do
243
+ bbat_data_len = ((@bbat.length + num_mbat_blocks) * 4 / @bbat.block_size.to_f).ceil * @bbat.block_size
244
+ new_num_mbat_blocks = calculate_mbat_blocks(bbat_data_len)
245
+
246
+ if new_num_mbat_blocks != num_mbat_blocks
247
+ num_mbat_blocks = new_num_mbat_blocks
248
+ elsif io.size != bbat_data_len
249
+ io.truncate(bbat_data_len)
250
+ else
251
+ break
252
+ end
253
+ end
254
+
255
+ # Get BAT chain and mark blocks
256
+ @bbat_chain = @bbat.chain(io.first_block)
257
+ io.close
258
+
259
+ @bbat_chain.each { |b| @bbat.entries[b] = BAT }
260
+ @header.num_bat = @bbat_chain.length
261
+
262
+ # Allocate MBAT blocks if needed
263
+ mbat_blocks = allocate_mbat_blocks(num_mbat_blocks)
264
+ @header.mbat_start = mbat_blocks.first || EOC
265
+ @header.num_mbat = num_mbat_blocks
266
+
267
+ # Write BAT data
268
+ RangesIO.open(@io, @bbat.ranges(@bbat_chain)) do |f|
269
+ f.write(@bbat.pack)
270
+ end
271
+
272
+ # Write MBAT if present
273
+ write_mbat_blocks(mbat_blocks, num_mbat_blocks) if num_mbat_blocks.positive?
274
+
275
+ # Pad BAT chain to 109 entries in header
276
+ @bbat_chain += [AVAIL] * [109 - @bbat_chain.length, 0].max
277
+ end
278
+
279
+ # Calculate number of MBAT blocks needed
280
+ def calculate_mbat_blocks(bbat_data_len)
281
+ excess_bat_blocks = (bbat_data_len / @bbat.block_size) - 109
282
+ return 0 if excess_bat_blocks <= 0
283
+
284
+ (excess_bat_blocks * 4 / (@bbat.block_size - 4).to_f).ceil
285
+ end
286
+
287
+ # Allocate MBAT blocks
288
+ def allocate_mbat_blocks(count)
289
+ (0...count).map do
290
+ block = @bbat.free_block
291
+ @bbat.entries[block] = META_BAT
292
+ block
293
+ end
294
+ end
295
+
296
+ # Write MBAT blocks
297
+ def write_mbat_blocks(mbat_blocks, _num_mbat)
298
+ # Get BAT entries beyond the first 109
299
+ mbat_data = @bbat_chain[109..] || []
300
+
301
+ # Add linked list pointers
302
+ entries_per_block = (@bbat.block_size / 4) - 1
303
+ mbat_data = mbat_data.each_slice(entries_per_block).to_a
304
+
305
+ mbat_data.zip(mbat_blocks[1..] + [nil]).each_with_index do |(entries, next_block), idx|
306
+ block_data = entries + (next_block ? [next_block] : [])
307
+ # Pad to block size
308
+ block_data += [AVAIL] * ((@bbat.block_size / 4) - block_data.length)
309
+
310
+ RangesIO.open(@io, @bbat.ranges([mbat_blocks[idx]])) do |f|
311
+ f.write(block_data.pack("V*"))
312
+ end
313
+ end
314
+ end
315
+
316
+ public
317
+
167
318
  # Get appropriate BAT for size
168
319
  #
169
320
  # @param size [Integer] File size
@@ -42,9 +42,12 @@ module Omnizip
42
42
  def self.load(str)
43
43
  return new("") if str.nil? || str.empty?
44
44
 
45
- # Decode UTF-16LE to UTF-8, strip null terminator
46
- decoded = str.encode(Encoding::UTF_8, Encoding::UTF_16LE)
47
- new(decoded.chomp("\x00"))
45
+ # Force binary data to UTF-16LE encoding, then transcode to UTF-8
46
+ # Strip null terminator (UTF-16 null = 2 bytes)
47
+ decoded = str.dup.force_encoding(Encoding::UTF_16LE)
48
+ decoded = decoded.chomp("\x00".encode(Encoding::UTF_16LE))
49
+ decoded = decoded.encode(Encoding::UTF_8)
50
+ new(decoded)
48
51
  end
49
52
 
50
53
  # Dump to UTF-16LE binary data
@@ -0,0 +1,546 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "zlib"
5
+ require "stringio"
6
+ require "digest"
7
+ require_relative "constants"
8
+ require_relative "lead"
9
+ require_relative "header"
10
+ require_relative "tag"
11
+ require_relative "../cpio/writer"
12
+
13
+ module Omnizip
14
+ module Formats
15
+ module Rpm
16
+ # RPM package writer
17
+ #
18
+ # Creates RPM packages with binary payload (CPIO archive).
19
+ # Supports gzip compression for the payload.
20
+ #
21
+ # @example Create an RPM package
22
+ # writer = Rpm::Writer.new(
23
+ # name: "myapp",
24
+ # version: "1.0.0",
25
+ # release: "1",
26
+ # arch: "x86_64"
27
+ # )
28
+ # writer.add_file("/usr/bin/myapp", content, mode: 0755)
29
+ # writer.add_directory("/etc/myapp")
30
+ # writer.add_file("/etc/myapp/config.yml", config_content)
31
+ # writer.write("myapp-1.0.0-1.x86_64.rpm")
32
+ #
33
+ class Writer
34
+ include Constants
35
+
36
+ # Architecture mapping
37
+ ARCHITECTURES = {
38
+ "noarch" => 0,
39
+ "i386" => 1,
40
+ "i486" => 2,
41
+ "i586" => 3,
42
+ "i686" => 4,
43
+ "x86_64" => 9,
44
+ "amd64" => 9,
45
+ "ia64" => 11,
46
+ "ppc" => 5,
47
+ "ppc64" => 16,
48
+ "sparc" => 6,
49
+ "sparc64" => 7,
50
+ "alpha" => 8,
51
+ "s390" => 14,
52
+ "s390x" => 15,
53
+ "arm" => 12,
54
+ "aarch64" => 19,
55
+ }.freeze
56
+
57
+ # Tag ID mapping (reverse of Tag::TAG_IDS)
58
+ TAG_NAMES = {
59
+ # Header tags
60
+ name: 1000,
61
+ version: 1001,
62
+ release: 1002,
63
+ epoch: 1003,
64
+ summary: 1004,
65
+ description: 1005,
66
+ buildtime: 1006,
67
+ buildhost: 1007,
68
+ size: 1009,
69
+ distribution: 1010,
70
+ vendor: 1011,
71
+ license: 1014,
72
+ packager: 1015,
73
+ group: 1016,
74
+ url: 1020,
75
+ os: 1021,
76
+ arch: 1022,
77
+ prein: 1023,
78
+ postin: 1024,
79
+ preun: 1025,
80
+ postun: 1026,
81
+ filesizes: 1028,
82
+ filemodes: 1030,
83
+ fileuids: 1031,
84
+ filegids: 1032,
85
+ filemtimes: 1034,
86
+ filedigests: 1035,
87
+ filelinktos: 1036,
88
+ fileflags: 1037,
89
+ fileusername: 1039,
90
+ filegroupname: 1040,
91
+ archivesize: 1046,
92
+ rpmversion: 1064,
93
+ dirindexes: 1116,
94
+ basenames: 1117,
95
+ dirnames: 1118,
96
+ payloadformat: 1124,
97
+ payloadcompressor: 1125,
98
+ payloadflags: 1126,
99
+
100
+ # Signature tags
101
+ sigsize: 257,
102
+ sha1header: 269,
103
+ }.freeze
104
+
105
+ # @return [String] Package name
106
+ attr_reader :name
107
+
108
+ # @return [String] Package version
109
+ attr_reader :version
110
+
111
+ # @return [String] Package release
112
+ attr_reader :release
113
+
114
+ # @return [String, nil] Package epoch
115
+ attr_reader :epoch
116
+
117
+ # @return [String] Architecture
118
+ attr_reader :arch
119
+
120
+ # @return [Hash<String, String>] File contents
121
+ attr_reader :files
122
+
123
+ # @return [Hash] Additional metadata
124
+ attr_reader :metadata
125
+
126
+ # Initialize RPM writer
127
+ #
128
+ # @param name [String] Package name
129
+ # @param version [String] Package version
130
+ # @param release [String] Package release
131
+ # @param arch [String] Architecture (default: "noarch")
132
+ # @param epoch [String, nil] Optional epoch
133
+ # @param metadata [Hash] Additional metadata
134
+ def initialize(name:, version:, release:, arch: "noarch", epoch: nil, **metadata)
135
+ @name = name
136
+ @version = version
137
+ @release = release
138
+ @arch = arch
139
+ @epoch = epoch
140
+ @metadata = metadata
141
+ @files = []
142
+ @directories = []
143
+ @compression = :gzip
144
+ end
145
+
146
+ # Add file to package
147
+ #
148
+ # @param path [String] File path in package (absolute path)
149
+ # @param content [String] File content
150
+ # @param mode [Integer] File permissions (default: 0644)
151
+ # @param owner [String] File owner (default: "root")
152
+ # @param group [String] File group (default: "root")
153
+ # @param mtime [Integer] Modification time (default: now)
154
+ def add_file(path, content, mode: 0o644, owner: "root", group: "root", mtime: nil)
155
+ @files << {
156
+ path: path,
157
+ content: content,
158
+ mode: mode,
159
+ owner: owner,
160
+ group: group,
161
+ mtime: mtime || Time.now.to_i,
162
+ }
163
+ end
164
+
165
+ # Add directory to package
166
+ #
167
+ # @param path [String] Directory path in package (absolute path)
168
+ # @param mode [Integer] Directory permissions (default: 0755)
169
+ # @param owner [String] Directory owner (default: "root")
170
+ # @param group [String] Directory group (default: "root")
171
+ def add_directory(path, mode: 0o755, owner: "root", group: "root")
172
+ @directories << {
173
+ path: path,
174
+ mode: mode,
175
+ owner: owner,
176
+ group: group,
177
+ }
178
+ end
179
+
180
+ # Write RPM package to file
181
+ #
182
+ # @param output_path [String] Output file path
183
+ # @return [String] Output path
184
+ def write(output_path)
185
+ File.open(output_path, "wb") do |io|
186
+ write_to_io(io)
187
+ end
188
+ output_path
189
+ end
190
+
191
+ # Write RPM package to IO
192
+ #
193
+ # @param io [IO] Output IO
194
+ def write_to_io(io)
195
+ # Build payload (compressed CPIO)
196
+ payload_io = StringIO.new("".b)
197
+ cpio_data = build_cpio_payload
198
+ compress_payload(cpio_data, payload_io)
199
+ payload_io.rewind
200
+ payload_data = payload_io.read
201
+
202
+ # Build headers
203
+ main_header = build_main_header(payload_data.bytesize)
204
+ sig_header = build_signature_header(main_header.bytesize, payload_data.bytesize)
205
+
206
+ # Build lead
207
+ lead = build_lead
208
+
209
+ # Write RPM in order
210
+ io.write(lead)
211
+ io.write(sig_header)
212
+ io.write(main_header)
213
+ io.write(payload_data)
214
+ end
215
+
216
+ private
217
+
218
+ # Build lead (96 bytes)
219
+ #
220
+ # @return [String] Packed lead
221
+ def build_lead
222
+ name_field = "#{@name}-#{@version}-#{@release}"
223
+ name_field = name_field[0, 65] # Truncate to 65 chars + null
224
+ name_padded = name_field.ljust(66, "\0")
225
+
226
+ arch_num = ARCHITECTURES.fetch(@arch.downcase, 0)
227
+
228
+ # Pack format:
229
+ # A4 = magic (4 bytes)
230
+ # CC = major/minor version (2 bytes)
231
+ # n = package type (2 bytes, 0 = binary)
232
+ # n = architecture (2 bytes)
233
+ # A66 = name (66 bytes)
234
+ # n = os (2 bytes, 1 = Linux)
235
+ # n = signature type (2 bytes, 5 = signed)
236
+ # A16 = reserved (16 bytes)
237
+ [LEAD_MAGIC, 3, 0, PACKAGE_BINARY, arch_num, name_padded, 1,
238
+ HEADER_SIGNED_TYPE, "\0" * 16].pack("A4 CC n n A66 n n A16")
239
+ end
240
+
241
+ # Build signature header
242
+ #
243
+ # @param header_size [Integer] Main header size
244
+ # @param payload_size [Integer] Payload size
245
+ # @return [String] Packed signature header
246
+ def build_signature_header(header_size, payload_size)
247
+ tags = [
248
+ { id: TAG_NAMES[:sigsize], type: TYPE_INT32, value: [header_size + payload_size] },
249
+ { id: TAG_NAMES[:sha1header], type: TYPE_STRING, value: "" },
250
+ ]
251
+ build_header_data(tags)
252
+ end
253
+
254
+ # Build main header
255
+ #
256
+ # @param payload_size [Integer] Payload size (uncompressed)
257
+ # @return [String] Packed main header
258
+ def build_main_header(payload_size)
259
+ # Build file lists
260
+ dirnames = []
261
+ basenames = []
262
+ dirindexes = []
263
+ filemodes = []
264
+ filesizes = []
265
+ fileowners = []
266
+ filegroups = []
267
+ filemtimes = []
268
+ filedigests = []
269
+ filelinktos = []
270
+ fileflags = []
271
+
272
+ # Process directories first
273
+ dir_set = Set.new
274
+ @directories.each do |dir|
275
+ dir_path = dir[:path]
276
+ parent = File.dirname(dir_path.sub(%r{/+$}, ""))
277
+ dir_name = File.basename(dir_path.sub(%r{/+$}, ""))
278
+
279
+ dir_set.size
280
+ dir_set << dir_path
281
+
282
+ # Parent directory for relative path
283
+ parent_dir = parent == "." ? "/" : parent
284
+ dirnames.size
285
+ dirnames << parent_dir unless dirnames.include?(parent_dir)
286
+ parent_index = dirnames.index(parent_dir)
287
+
288
+ basenames << dir_name
289
+ dirindexes << parent_index
290
+ filemodes << (dir[:mode] | 0o040000) # Directory flag
291
+ filesizes << 4096 # Typical directory size
292
+ fileowners << dir[:owner]
293
+ filegroups << dir[:group]
294
+ filemtimes << Time.now.to_i
295
+ filedigests << ""
296
+ filelinktos << ""
297
+ fileflags << 0
298
+ end
299
+
300
+ # Process files
301
+ @files.each do |file|
302
+ file_path = file[:path]
303
+ parent_dir = File.dirname(file_path)
304
+ base_name = File.basename(file_path)
305
+
306
+ # Ensure directory is in list
307
+ dir_index = dirnames.index(parent_dir)
308
+ dir_index ||= dirnames.size.tap { dirnames << parent_dir }
309
+
310
+ basenames << base_name
311
+ dirindexes << dir_index
312
+ filemodes << file[:mode]
313
+ filesizes << file[:content].bytesize
314
+ fileowners << file[:owner]
315
+ filegroups << file[:group]
316
+ filemtimes << file[:mtime]
317
+ filedigests << Digest::MD5.hexdigest(file[:content])
318
+ filelinktos << ""
319
+ fileflags << 0
320
+ end
321
+
322
+ tags = [
323
+ { id: TAG_NAMES[:name], type: TYPE_STRING, value: @name },
324
+ { id: TAG_NAMES[:version], type: TYPE_STRING, value: @version },
325
+ { id: TAG_NAMES[:release], type: TYPE_STRING, value: @release },
326
+ { id: TAG_NAMES[:arch], type: TYPE_STRING, value: @arch },
327
+ { id: TAG_NAMES[:os], type: TYPE_STRING, value: "linux" },
328
+ { id: TAG_NAMES[:rpmversion], type: TYPE_STRING, value: "4.16.0" },
329
+ { id: TAG_NAMES[:payloadformat], type: TYPE_STRING, value: "cpio" },
330
+ { id: TAG_NAMES[:payloadcompressor], type: TYPE_STRING, value: "gzip" },
331
+ { id: TAG_NAMES[:payloadflags], type: TYPE_STRING, value: "9" },
332
+ { id: TAG_NAMES[:archivesize], type: TYPE_INT32, value: [payload_size] },
333
+ { id: TAG_NAMES[:dirnames], type: TYPE_STRING_ARRAY, value: dirnames },
334
+ { id: TAG_NAMES[:basenames], type: TYPE_STRING_ARRAY, value: basenames },
335
+ { id: TAG_NAMES[:dirindexes], type: TYPE_INT32, value: dirindexes },
336
+ { id: TAG_NAMES[:filemodes], type: TYPE_INT16, value: filemodes },
337
+ { id: TAG_NAMES[:filesizes], type: TYPE_INT32, value: filesizes },
338
+ { id: TAG_NAMES[:fileusername], type: TYPE_STRING_ARRAY, value: fileowners },
339
+ { id: TAG_NAMES[:filegroupname], type: TYPE_STRING_ARRAY, value: filegroups },
340
+ { id: TAG_NAMES[:filemtimes], type: TYPE_INT32, value: filemtimes },
341
+ { id: TAG_NAMES[:filedigests], type: TYPE_STRING_ARRAY, value: filedigests },
342
+ { id: TAG_NAMES[:filelinktos], type: TYPE_STRING_ARRAY, value: filelinktos },
343
+ { id: TAG_NAMES[:fileflags], type: TYPE_INT32, value: fileflags },
344
+ ]
345
+
346
+ # Add optional metadata
347
+ {
348
+ summary: TYPE_STRING,
349
+ description: TYPE_STRING,
350
+ license: TYPE_STRING,
351
+ group: TYPE_STRING,
352
+ url: TYPE_STRING,
353
+ vendor: TYPE_STRING,
354
+ packager: TYPE_STRING,
355
+ }.each do |key, type|
356
+ value = @metadata[key]
357
+ tags << { id: TAG_NAMES[key], type: type, value: value } if value
358
+ end
359
+
360
+ tags << { id: TAG_NAMES[:epoch], type: TYPE_INT32, value: [@epoch.to_i] } if @epoch
361
+
362
+ build_header_data(tags)
363
+ end
364
+
365
+ # Build header data structure
366
+ #
367
+ # @param tags [Array<Hash>] Array of tag definitions
368
+ # @return [String] Packed header
369
+ def build_header_data(tags)
370
+ # Build data blob and tag entries
371
+ data_blob = "".b
372
+ tag_entries = []
373
+
374
+ tags.each do |tag_def|
375
+ offset = data_blob.bytesize
376
+ value = tag_def[:value]
377
+ type = tag_def[:type]
378
+
379
+ packed_data = pack_tag_value(type, value)
380
+ data_blob << packed_data
381
+
382
+ count = case type
383
+ when TYPE_STRING_ARRAY
384
+ value.is_a?(Array) ? value.size : 1
385
+ when TYPE_STRING
386
+ 1
387
+ else
388
+ value.is_a?(Array) ? value.size : 1
389
+ end
390
+
391
+ tag_entries << [tag_def[:id], type, offset, count].pack("NNNN")
392
+ end
393
+
394
+ # Build complete header
395
+ entry_count = tags.size
396
+ data_length = data_blob.bytesize
397
+
398
+ # Pad data blob to 8-byte boundary
399
+ padding = (8 - (data_length % 8)) % 8
400
+ data_blob << ("\0" * padding)
401
+
402
+ # Header header: magic (8) + entry_count (4) + data_length (4)
403
+ header_header = [HEADER_MAGIC, entry_count, data_blob.bytesize].pack("A8 NN")
404
+
405
+ header_header + tag_entries.join + data_blob
406
+ end
407
+
408
+ # Pack tag value based on type
409
+ #
410
+ # @param type [Integer] Tag type
411
+ # @param value [Object] Value to pack
412
+ # @return [String] Packed data
413
+ def pack_tag_value(type, value)
414
+ case type
415
+ when TYPE_STRING
416
+ "#{value}\0".b
417
+ when TYPE_STRING_ARRAY
418
+ value.map { |v| "#{v}\0" }.join.b
419
+ when TYPE_INT8
420
+ [value].flatten.pack("C*")
421
+ when TYPE_INT16
422
+ [value].flatten.pack("n*")
423
+ when TYPE_INT32
424
+ [value].flatten.pack("N*")
425
+ when TYPE_INT64
426
+ [value].flatten.pack("Q>")
427
+ when TYPE_BINARY
428
+ value.b
429
+ else
430
+ value.to_s.b
431
+ end
432
+ end
433
+
434
+ # Build CPIO payload
435
+ #
436
+ # @return [String] CPIO archive data
437
+ def build_cpio_payload
438
+ cpio_io = StringIO.new("".b)
439
+
440
+ # Write directories
441
+ @directories.each do |dir|
442
+ entry_data = build_cpio_directory_entry(dir)
443
+ cpio_io.write(entry_data)
444
+ end
445
+
446
+ # Write files
447
+ @files.each do |file|
448
+ entry_data = build_cpio_file_entry(file)
449
+ cpio_io.write(entry_data)
450
+ end
451
+
452
+ # Write trailer
453
+ cpio_io.write(build_cpio_trailer)
454
+
455
+ cpio_io.string
456
+ end
457
+
458
+ # Build CPIO directory entry
459
+ #
460
+ # @param dir [Hash] Directory info
461
+ # @return [String] Packed CPIO entry
462
+ def build_cpio_directory_entry(dir)
463
+ mode = dir[:mode] | 0o040000 # Directory flag
464
+ path = dir[:path].sub(%r{/+$}, "") # Remove trailing slashes
465
+
466
+ build_cpio_entry(path, "", mode)
467
+ end
468
+
469
+ # Build CPIO file entry
470
+ #
471
+ # @param file [Hash] File info
472
+ # @return [String] Packed CPIO entry
473
+ def build_cpio_file_entry(file)
474
+ build_cpio_entry(file[:path], file[:content], file[:mode])
475
+ end
476
+
477
+ # Build CPIO entry (newc format)
478
+ #
479
+ # @param path [String] Entry path
480
+ # @param data [String] Entry data
481
+ # @param mode [Integer] File mode
482
+ # @return [String] Packed CPIO entry
483
+ def build_cpio_entry(path, data, mode)
484
+ name = path.start_with?("/") ? path[1..] : path
485
+ namesize = name.bytesize + 1
486
+ filesize = data.bytesize
487
+
488
+ inode = @inode_counter ||= 1
489
+ @inode_counter += 1
490
+
491
+ # Build header (110 bytes for newc)
492
+ header = format(
493
+ "070701%08x%08x%08x%08x%08x%08x%08x%08x%08x%08x%08x%08x%08x%s\x00",
494
+ inode, # inode
495
+ mode, # mode
496
+ 0, # uid
497
+ 0, # gid
498
+ 1, # nlink
499
+ Time.now.to_i, # mtime
500
+ filesize, # filesize
501
+ 0, # devmajor
502
+ 0, # devminor
503
+ 0, # rdevmajor
504
+ 0, # rdevminor
505
+ namesize, # namesize
506
+ 0, # checksum (0 for newc)
507
+ name, # name
508
+ )
509
+
510
+ # Pad header to 4-byte boundary
511
+ header_padding = (4 - (header.bytesize % 4)) % 4
512
+ header << ("\0" * header_padding)
513
+
514
+ # Pad data to 4-byte boundary
515
+ data_padding = (4 - (filesize % 4)) % 4
516
+
517
+ header + data + ("\0" * data_padding)
518
+ end
519
+
520
+ # Build CPIO trailer
521
+ #
522
+ # @return [String] Trailer entry
523
+ def build_cpio_trailer
524
+ build_cpio_entry("TRAILER!!!", "", 0)
525
+ end
526
+
527
+ # Compress payload
528
+ #
529
+ # @param data [String] Uncompressed data
530
+ # @param output [IO] Output IO for compressed data
531
+ def compress_payload(data, output)
532
+ case @compression
533
+ when :gzip
534
+ gz = Zlib::GzipWriter.new(output, 9)
535
+ gz.write(data)
536
+ gz.finish
537
+ when :none
538
+ output.write(data)
539
+ else
540
+ raise ArgumentError, "Unsupported compression: #{@compression}"
541
+ end
542
+ end
543
+ end
544
+ end
545
+ end
546
+ end
@@ -12,7 +12,7 @@ module Omnizip
12
12
  module Formats
13
13
  # RPM package format support
14
14
  #
15
- # Provides read access to RPM packages, extracting metadata
15
+ # Provides read and write access to RPM packages, extracting metadata
16
16
  # and file contents from the payload.
17
17
  #
18
18
  # @example Open RPM and list files
@@ -22,7 +22,17 @@ module Omnizip
22
22
  #
23
23
  # @example Extract RPM contents
24
24
  # Omnizip::Formats::Rpm.extract('package.rpm', 'output/')
25
+ #
26
+ # @example Create an RPM package
27
+ # writer = Omnizip::Formats::Rpm::Writer.new(
28
+ # name: "mypkg",
29
+ # version: "1.0.0",
30
+ # release: "1"
31
+ # )
32
+ # writer.add_file("/usr/bin/app", "#!/bin/sh\necho hello")
33
+ # writer.write("mypkg.rpm")
25
34
  module Rpm
35
+ autoload :Writer, "omnizip/formats/rpm/writer"
26
36
  class << self
27
37
  # Open RPM package
28
38
  #
@@ -302,6 +312,19 @@ module Omnizip
302
312
  extract_cpio(decompressor, output_dir)
303
313
  end
304
314
 
315
+ # Get raw payload data
316
+ #
317
+ # Returns the compressed payload as-is (without decompression).
318
+ # Useful for saving the payload as a file (e.g., fonts.src.cpio.gz).
319
+ #
320
+ # @return [String] Raw compressed payload data
321
+ def raw_payload
322
+ raise "RPM not opened" unless @file
323
+
324
+ payload_io = payload
325
+ payload_io.read
326
+ end
327
+
305
328
  private
306
329
 
307
330
  def parse!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omnizip
4
- VERSION = "0.3.4"
4
+ VERSION = "0.3.5"
5
5
  end
data/lib/omnizip.rb CHANGED
@@ -96,6 +96,16 @@ require_relative "omnizip/formats/xar"
96
96
  # ISO 9660 CD-ROM format (Weeks 11-14)
97
97
  require_relative "omnizip/formats/iso"
98
98
 
99
+ # CPIO, RPM, and OLE formats (autoload for lazy loading)
100
+ # These formats benefit most from autoload since they are less commonly used
101
+ module Omnizip
102
+ module Formats
103
+ autoload :Cpio, "omnizip/formats/cpio"
104
+ autoload :Rpm, "omnizip/formats/rpm"
105
+ autoload :Ole, "omnizip/formats/ole"
106
+ end
107
+ end
108
+
99
109
  # Platform-specific features (Weeks 11-14)
100
110
  require_relative "omnizip/platform"
101
111
  require_relative "omnizip/platform/ntfs_streams"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omnizip
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-20 00:00:00.000000000 Z
11
+ date: 2026-02-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -449,6 +449,7 @@ files:
449
449
  - lib/omnizip/formats/rpm/header.rb
450
450
  - lib/omnizip/formats/rpm/lead.rb
451
451
  - lib/omnizip/formats/rpm/tag.rb
452
+ - lib/omnizip/formats/rpm/writer.rb
452
453
  - lib/omnizip/formats/seven_zip.rb
453
454
  - lib/omnizip/formats/seven_zip/bcj2_stream_decompressor.rb
454
455
  - lib/omnizip/formats/seven_zip/coder_chain.rb