cabriolet 0.2.1 → 0.2.2

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: d301c701aa57d4033c110e9610d76f11e26ae3b8debd3a52d8a3df5ccb725905
4
- data.tar.gz: d009c4741e7885ec25ad6766dd79158d1955b80ffc16e9acb0c5a0c1a813b9d0
3
+ metadata.gz: 9d2875b9afed58332c6e9823f3f383a60d802bac8514cb2015fdbd6a7f1559dc
4
+ data.tar.gz: 6979ce57ad3d47867bed19330d70b5db483bc33ac8920ee2986faa35828d6cbc
5
5
  SHA512:
6
- metadata.gz: fd0314bf6196cad4749d2497ee639b7a355eab2a93a4a5e4967878e64cabd6413c08bbfab99aa767e67d1a13a0a3e7e428801819192c6506dba2dbf30f653b3d
7
- data.tar.gz: dd1bb57af42a9cc7fa8556bd05632ce79b3b2a994d8276bee8349db7f10428e9d61f254ea8f928583dc8e7f0eca8e2bdd326fd137912fe5afc74c1b0b3f684da
6
+ metadata.gz: edd7b1345bee36e75fb5796a3840f087a9dfa51e41c599fa767938e8d64ab0abc985e563b9fb246fc86fc4798eb3f98539f774023eaf613f50a1c9e4a2a6d518
7
+ data.tar.gz: c5cfc76ae8dc5239efa9e90d0956d2518eab56c669d4b7e4087debc4955f509cc97440296c53084152cb30059b25044b6231cd9a84ee94e85f446a20f3306e08
@@ -34,25 +34,7 @@ module Cabriolet
34
34
  def extract_file(file, output_path, **options)
35
35
  salvage = options[:salvage] || @decompressor.salvage
36
36
  folder = file.folder
37
-
38
- # Validate file
39
- raise Cabriolet::ArgumentError, "File has no folder" unless folder
40
-
41
- if file.offset > Constants::LENGTH_MAX
42
- raise DecompressionError,
43
- "File offset beyond 2GB limit"
44
- end
45
-
46
- # Check file length
47
- filelen = file.length
48
- if filelen > (Constants::LENGTH_MAX - file.offset)
49
- unless salvage
50
- raise DecompressionError,
51
- "File length exceeds 2GB limit"
52
- end
53
-
54
- filelen = Constants::LENGTH_MAX - file.offset
55
- end
37
+ filelen = validate_file_for_extraction(file, folder, salvage)
56
38
 
57
39
  # Check for merge requirements
58
40
  if folder.needs_prev_merge?
@@ -60,81 +42,22 @@ module Cabriolet
60
42
  "File requires previous cabinet, cabinet set is incomplete"
61
43
  end
62
44
 
63
- # Check file fits within folder
64
- unless salvage
65
- max_len = folder.num_blocks * Constants::BLOCK_MAX
66
- if file.offset > max_len || filelen > (max_len - file.offset)
67
- raise DecompressionError, "File extends beyond folder data"
68
- end
69
- end
45
+ validate_file_in_folder(folder, file.offset, filelen, salvage)
70
46
 
71
47
  # Create output directory if needed
72
48
  output_dir = ::File.dirname(output_path)
73
49
  FileUtils.mkdir_p(output_dir) unless ::File.directory?(output_dir)
74
50
 
75
- # Check if we need to change folder or reset (libmspack lines 1076-1078)
76
- if ENV["DEBUG_BLOCK"]
77
- warn "DEBUG extract_file: Checking reset condition for file #{file.filename} (offset=#{file.offset}, length=#{file.length})"
78
- warn " @current_folder == folder: #{@current_folder == folder} (current=#{@current_folder.object_id}, new=#{folder.object_id})"
79
- warn " @current_offset (#{@current_offset}) > file.offset (#{file.offset}): #{@current_offset > file.offset}"
80
- warn " @current_decomp.nil?: #{@current_decomp.nil?}"
81
- warn " Reset needed?: #{@current_folder != folder || @current_offset > file.offset || !@current_decomp}"
82
- end
83
-
84
- if @current_folder != folder || @current_offset > file.offset || !@current_decomp
85
- if ENV["DEBUG_BLOCK"]
86
- warn "DEBUG extract_file: RESETTING state (creating new BlockReader)"
87
- end
88
-
89
- # Reset state
90
- @current_input&.close
91
- @current_input = nil
92
- @current_decomp = nil
93
-
94
- # Create new input (libmspack lines 1092-1095)
95
- # This BlockReader will be REUSED across all files in this folder
96
- @current_input = BlockReader.new(@io_system, folder.data,
97
- folder.num_blocks, salvage)
98
- @current_folder = folder
99
- @current_offset = 0
100
-
101
- # Create decompressor ONCE and reuse it (this is the key fix!)
102
- # The decompressor maintains bitstream state across files
103
- @current_decomp = @decompressor.create_decompressor(folder,
104
- @current_input, nil)
105
- elsif ENV["DEBUG_BLOCK"]
106
- warn "DEBUG extract_file: NOT resetting (reusing existing BlockReader and decompressor)"
107
- end
108
-
109
- # Skip ahead if needed (libmspack lines 1130-1134)
110
- if file.offset > @current_offset
111
- skip_bytes = file.offset - @current_offset
112
-
113
- # Decompress with NULL output to skip (libmspack line 1130: self->d->outfh = NULL)
114
- null_output = System::MemoryHandle.new("", Constants::MODE_WRITE)
115
-
116
- # Reuse existing decompressor, change output to NULL
117
- @current_decomp.instance_variable_set(:@output, null_output)
118
-
119
- # Set output length for LZX frame limiting
120
- @current_decomp.set_output_length(skip_bytes) if @current_decomp.respond_to?(:set_output_length)
121
-
122
- @current_decomp.decompress(skip_bytes)
123
- @current_offset += skip_bytes
124
- end
51
+ setup_decompressor_for_folder(folder, salvage, file.offset)
52
+ skip_to_file_offset(file.offset, salvage, file.filename)
125
53
 
126
54
  # Extract actual file (libmspack lines 1137-1141)
127
55
  output_fh = @io_system.open(output_path, Constants::MODE_WRITE)
128
56
 
129
57
  begin
130
- # Reuse existing decompressor, change output to real file
131
- @current_decomp.instance_variable_set(:@output, output_fh)
132
-
133
- # Set output length for LZX frame limiting
134
- @current_decomp.set_output_length(filelen) if @current_decomp.respond_to?(:set_output_length)
135
-
136
- @current_decomp.decompress(filelen)
137
- @current_offset += filelen
58
+ write_file_data(output_fh, filelen)
59
+ rescue DecompressionError
60
+ handle_extraction_error(output_fh, output_path, file.filename, salvage, filelen)
138
61
  ensure
139
62
  output_fh.close
140
63
  end
@@ -142,6 +65,15 @@ module Cabriolet
142
65
  filelen
143
66
  end
144
67
 
68
+ # Reset extraction state (used in salvage mode to recover from errors)
69
+ def reset_state
70
+ @current_input&.close
71
+ @current_input = nil
72
+ @current_decomp = nil
73
+ @current_folder = nil
74
+ @current_offset = 0
75
+ end
76
+
145
77
  # Extract all files from a cabinet
146
78
  #
147
79
  # @param cabinet [Models::Cabinet] Cabinet to extract from
@@ -150,16 +82,19 @@ module Cabriolet
150
82
  # @option options [Boolean] :preserve_paths Preserve directory structure (default: true)
151
83
  # @option options [Boolean] :set_timestamps Set file modification times (default: true)
152
84
  # @option options [Proc] :progress Progress callback
85
+ # @option options [Boolean] :salvage Skip files that fail to extract (default: false)
153
86
  # @return [Integer] Number of files extracted
154
87
  def extract_all(cabinet, output_dir, **options)
155
88
  preserve_paths = options.fetch(:preserve_paths, true)
156
89
  set_timestamps = options.fetch(:set_timestamps, true)
157
90
  progress = options[:progress]
91
+ salvage = options[:salvage] || false
158
92
 
159
93
  # Create output directory
160
94
  FileUtils.mkdir_p(output_dir) unless ::File.directory?(output_dir)
161
95
 
162
96
  count = 0
97
+ failed_count = 0
163
98
  cabinet.files.each do |file|
164
99
  # Determine output path
165
100
  output_path = if preserve_paths
@@ -169,8 +104,18 @@ module Cabriolet
169
104
  ::File.basename(file.filename))
170
105
  end
171
106
 
172
- # Extract file
173
- extract_file(file, output_path, **options)
107
+ # Extract file (skip if salvage mode and extraction fails)
108
+ begin
109
+ extract_file(file, output_path, **options)
110
+ rescue DecompressionError => e
111
+ if salvage
112
+ warn "Salvage: skipping #{file.filename}: #{e.message}"
113
+ failed_count += 1
114
+ next
115
+ else
116
+ raise
117
+ end
118
+ end
174
119
 
175
120
  # Set timestamp if requested
176
121
  if set_timestamps && file.modification_time
@@ -185,11 +130,148 @@ module Cabriolet
185
130
  progress&.call(file, count, cabinet.files.size)
186
131
  end
187
132
 
133
+ warn "Salvage: #{failed_count} file(s) skipped due to extraction errors" if failed_count.positive?
134
+
188
135
  count
189
136
  end
190
137
 
191
138
  private
192
139
 
140
+ # Validate file for extraction
141
+ #
142
+ # @param file [Models::File] File to validate
143
+ # @param folder [Models::Folder] Folder containing the file
144
+ # @param salvage [Boolean] Salvage mode flag
145
+ # @return [Integer] Validated file length
146
+ def validate_file_for_extraction(file, folder, salvage)
147
+ raise Cabriolet::ArgumentError, "File has no folder" unless folder
148
+
149
+ if file.offset > Constants::LENGTH_MAX
150
+ raise DecompressionError,
151
+ "File offset beyond 2GB limit"
152
+ end
153
+
154
+ filelen = file.length
155
+ if filelen > (Constants::LENGTH_MAX - file.offset)
156
+ unless salvage
157
+ raise DecompressionError,
158
+ "File length exceeds 2GB limit"
159
+ end
160
+
161
+ filelen = Constants::LENGTH_MAX - file.offset
162
+ end
163
+
164
+ filelen
165
+ end
166
+
167
+ # Validate file fits within folder
168
+ #
169
+ # @param folder [Models::Folder] Folder to check
170
+ # @param file_offset [Integer] File offset
171
+ # @param filelen [Integer] File length
172
+ # @param salvage [Boolean] Salvage mode flag
173
+ def validate_file_in_folder(folder, file_offset, filelen, salvage)
174
+ return if salvage
175
+
176
+ max_len = folder.num_blocks * Constants::BLOCK_MAX
177
+ return unless file_offset > max_len || filelen > (max_len - file_offset)
178
+
179
+ raise DecompressionError, "File extends beyond folder data"
180
+ end
181
+
182
+ # Setup decompressor for folder
183
+ #
184
+ # @param folder [Models::Folder] Folder to setup for
185
+ # @param salvage [Boolean] Salvage mode flag
186
+ # @param file_offset [Integer] File offset for reset condition check
187
+ def setup_decompressor_for_folder(folder, salvage, file_offset)
188
+ if ENV["DEBUG_BLOCK"]
189
+ warn "DEBUG extract_file: Checking reset condition"
190
+ warn " @current_folder == folder: #{@current_folder == folder}"
191
+ warn " @current_offset (#{@current_offset}) > file_offset (#{file_offset})"
192
+ warn " @current_decomp.nil?: #{@current_decomp.nil?}"
193
+ end
194
+
195
+ if @current_folder != folder || @current_offset > file_offset || !@current_decomp
196
+ if ENV["DEBUG_BLOCK"]
197
+ warn "DEBUG extract_file: RESETTING state (creating new BlockReader)"
198
+ end
199
+
200
+ # Reset state
201
+ @current_input&.close
202
+ @current_input = nil
203
+ @current_decomp = nil
204
+
205
+ # Create new input (libmspack lines 1092-1095)
206
+ @current_input = BlockReader.new(@io_system, folder.data,
207
+ folder.num_blocks, salvage)
208
+ @current_folder = folder
209
+ @current_offset = 0
210
+
211
+ # Create decompressor ONCE and reuse it
212
+ @current_decomp = @decompressor.create_decompressor(folder,
213
+ @current_input, nil)
214
+ elsif ENV["DEBUG_BLOCK"]
215
+ warn "DEBUG extract_file: NOT resetting (reusing existing BlockReader)"
216
+ end
217
+ end
218
+
219
+ # Skip to file offset
220
+ #
221
+ # @param file_offset [Integer] Target offset
222
+ # @param salvage [Boolean] Salvage mode flag
223
+ # @param filename [String] Filename for error messages
224
+ def skip_to_file_offset(file_offset, salvage, filename)
225
+ return unless file_offset > @current_offset
226
+
227
+ skip_bytes = file_offset - @current_offset
228
+ null_output = System::MemoryHandle.new("", Constants::MODE_WRITE)
229
+
230
+ @current_decomp.instance_variable_set(:@output, null_output)
231
+ @current_decomp.set_output_length(skip_bytes) if @current_decomp.respond_to?(:set_output_length)
232
+
233
+ begin
234
+ @current_decomp.decompress(skip_bytes)
235
+ rescue DecompressionError
236
+ if salvage
237
+ warn "Salvage: unable to skip to file #{filename}, resetting state"
238
+ reset_state
239
+ else
240
+ raise
241
+ end
242
+ end
243
+ @current_offset += skip_bytes
244
+ end
245
+
246
+ # Write file data using decompressor
247
+ #
248
+ # @param output_fh [System::FileHandle] Output file handle
249
+ # @param filelen [Integer] Number of bytes to write
250
+ def write_file_data(output_fh, filelen)
251
+ @current_decomp.instance_variable_set(:@output, output_fh)
252
+ @current_decomp.set_output_length(filelen) if @current_decomp.respond_to?(:set_output_length)
253
+ @current_decomp.decompress(filelen)
254
+ @current_offset += filelen
255
+ end
256
+
257
+ # Handle extraction error
258
+ #
259
+ # @param output_fh [System::FileHandle] Output file handle
260
+ # @param output_path [String] Output file path
261
+ # @param filename [String] Filename for error messages
262
+ # @param salvage [Boolean] Salvage mode flag
263
+ # @raise [DecompressionError] If not in salvage mode
264
+ def handle_extraction_error(output_fh, output_path, filename, salvage, _filelen)
265
+ output_fh.close
266
+ if salvage
267
+ ::File.write(output_path, "", mode: "wb")
268
+ warn "Salvage: created empty file for #{filename} due to decompression error"
269
+ reset_state
270
+ else
271
+ raise
272
+ end
273
+ end
274
+
193
275
  # Set file attributes based on CAB attributes
194
276
  #
195
277
  # @param path [String] File path
@@ -105,6 +105,9 @@ module Cabriolet
105
105
  reset_interval: 0, output_length: 0, is_delta: false, salvage: false, **_kwargs)
106
106
  super(io_system, input, output, buffer_size)
107
107
 
108
+ # Store salvage flag for error handling
109
+ @salvage = salvage
110
+
108
111
  # Validate window_bits
109
112
  if is_delta
110
113
  unless (17..25).cover?(window_bits)
@@ -195,7 +198,17 @@ module Cabriolet
195
198
  frame_size = calculate_frame_size
196
199
 
197
200
  # Decode blocks until frame is complete
198
- decode_frame(frame_size)
201
+ begin
202
+ decode_frame(frame_size)
203
+ rescue DecompressionError => e
204
+ # In salvage mode, if decompression fails, return what we have so far
205
+ if @salvage
206
+ warn "Salvage: LZX decompression failed at frame #{@frame}: #{e.message}"
207
+ return total_written
208
+ else
209
+ raise
210
+ end
211
+ end
199
212
 
200
213
  # Apply Intel E8 transformation if needed
201
214
  frame_data = if should_apply_e8_transform?(frame_size)
@@ -391,6 +404,35 @@ module Cabriolet
391
404
  @maintree = Huffman::Tree.new(@maintree_lengths, @maintree_maxsymbols,
392
405
  bit_order: :msb)
393
406
  unless @maintree.build_table(LENGTH_TABLEBITS)
407
+ # In salvage mode, try to build with a default distribution
408
+ if @salvage
409
+ # For a valid Huffman tree with @maintree_maxsymbols symbols and LENGTH_TABLEBITS=12,
410
+ # we need sum(2^(12-len)) = 4096 (for complete tree) or <= 4096 (for partial).
411
+ # For @maintree_maxsymbols = 784, we need to distribute symbols across lengths 8-10:
412
+ # Using: 128 at len8 (2048 slots) + 384 at len9 (768 slots) + 272 at len10 (256 slots)
413
+ # Total: 2048 + 768 + 256 = 3072 slots, leaving 1024 for longer codes
414
+ # Simpler: use lengths that sum to exactly 4096
415
+ # 784 symbols: distribute as 192 at len9, 592 at len10 = 384 + 592 = 976 (not enough)
416
+ # 784 symbols: distribute as 64 at len8, 576 at len9, 144 at len10 = 128 + 1152 + 144 = 1424
417
+ # Final: 784 symbols across lengths 8-11 to fill 4096 slots
418
+ # Verify: 64*128 + 384*64 + 256*32 + 80*16 = 8192 + 24576 + 8192 + 1280 = 42240 (wrong)
419
+
420
+ # Recalculate: 2^(12-len) slots needed per symbol
421
+ # len8: 16 slots/symbol, len9: 8 slots/symbol, len10: 4 slots/symbol, len11: 2 slots/symbol
422
+ # Total slots = sum(2^(12-len) for each symbol) must <= 4096
423
+ # Simple valid distribution for 784 symbols:
424
+ # 256 at len10 = 256*4 = 1024
425
+ # 528 at len12 = 528*1 = 528
426
+ # Total = 1552 (valid but incomplete tree)
427
+
428
+ default_main_lengths = []
429
+ 256.times { default_main_lengths << 10 }
430
+ 528.times { default_main_lengths << 12 }
431
+ @maintree_lengths = default_main_lengths
432
+ @maintree = Huffman::Tree.new(default_main_lengths, @maintree_maxsymbols,
433
+ bit_order: :msb)
434
+ return if @maintree.build_table(LENGTH_TABLEBITS)
435
+ end
394
436
  raise DecompressionError,
395
437
  "Failed to build main tree"
396
438
  end
@@ -428,6 +470,22 @@ module Cabriolet
428
470
  bit_order: :msb)
429
471
  return if @pretree.build_table(PRETREE_TABLEBITS)
430
472
 
473
+ # In salvage mode, try to continue with a valid default tree
474
+ if @salvage
475
+ # For a valid Huffman tree with table_bits=6, we need exactly 64 slots.
476
+ # With 8 symbols at length 3: 8 * 2^(6-3) = 8 * 8 = 64 slots (complete)
477
+ # For simplicity: 8 at length 3 fills direct table (64 slots)
478
+ default_lengths = [
479
+ 3, 3, 3, 3, 3, 3, 3, 3, # 8 at length 3: fills 64 slots
480
+ 7, 7, 7, 7, 7, 7, 7, 7, # 8 at length 7: extended table
481
+ 7, 7, 7, 7 # 4 at length 7: extended table
482
+ ]
483
+ @pretree_lengths = default_lengths
484
+ @pretree = Huffman::Tree.new(default_lengths, PRETREE_MAXSYMBOLS,
485
+ bit_order: :msb)
486
+ return if @pretree.build_table(PRETREE_TABLEBITS)
487
+ end
488
+
431
489
  raise DecompressionError, "Failed to build pretree"
432
490
  end
433
491
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cabriolet
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.2"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cabriolet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
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-01-17 00:00:00.000000000 Z
11
+ date: 2026-02-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bindata