cabriolet 0.1.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 (78) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +799 -0
  3. data/CHANGELOG.md +44 -0
  4. data/LICENSE +29 -0
  5. data/README.adoc +1207 -0
  6. data/exe/cabriolet +6 -0
  7. data/lib/cabriolet/auto.rb +173 -0
  8. data/lib/cabriolet/binary/bitstream.rb +148 -0
  9. data/lib/cabriolet/binary/bitstream_writer.rb +180 -0
  10. data/lib/cabriolet/binary/chm_structures.rb +213 -0
  11. data/lib/cabriolet/binary/hlp_structures.rb +66 -0
  12. data/lib/cabriolet/binary/kwaj_structures.rb +74 -0
  13. data/lib/cabriolet/binary/lit_structures.rb +107 -0
  14. data/lib/cabriolet/binary/oab_structures.rb +112 -0
  15. data/lib/cabriolet/binary/structures.rb +56 -0
  16. data/lib/cabriolet/binary/szdd_structures.rb +60 -0
  17. data/lib/cabriolet/cab/compressor.rb +382 -0
  18. data/lib/cabriolet/cab/decompressor.rb +510 -0
  19. data/lib/cabriolet/cab/extractor.rb +357 -0
  20. data/lib/cabriolet/cab/parser.rb +264 -0
  21. data/lib/cabriolet/chm/compressor.rb +513 -0
  22. data/lib/cabriolet/chm/decompressor.rb +436 -0
  23. data/lib/cabriolet/chm/parser.rb +254 -0
  24. data/lib/cabriolet/cli.rb +776 -0
  25. data/lib/cabriolet/compressors/base.rb +34 -0
  26. data/lib/cabriolet/compressors/lzss.rb +250 -0
  27. data/lib/cabriolet/compressors/lzx.rb +581 -0
  28. data/lib/cabriolet/compressors/mszip.rb +315 -0
  29. data/lib/cabriolet/compressors/quantum.rb +446 -0
  30. data/lib/cabriolet/constants.rb +75 -0
  31. data/lib/cabriolet/decompressors/base.rb +39 -0
  32. data/lib/cabriolet/decompressors/lzss.rb +138 -0
  33. data/lib/cabriolet/decompressors/lzx.rb +726 -0
  34. data/lib/cabriolet/decompressors/mszip.rb +390 -0
  35. data/lib/cabriolet/decompressors/none.rb +27 -0
  36. data/lib/cabriolet/decompressors/quantum.rb +456 -0
  37. data/lib/cabriolet/errors.rb +39 -0
  38. data/lib/cabriolet/format_detector.rb +156 -0
  39. data/lib/cabriolet/hlp/compressor.rb +272 -0
  40. data/lib/cabriolet/hlp/decompressor.rb +198 -0
  41. data/lib/cabriolet/hlp/parser.rb +131 -0
  42. data/lib/cabriolet/huffman/decoder.rb +79 -0
  43. data/lib/cabriolet/huffman/encoder.rb +108 -0
  44. data/lib/cabriolet/huffman/tree.rb +138 -0
  45. data/lib/cabriolet/kwaj/compressor.rb +479 -0
  46. data/lib/cabriolet/kwaj/decompressor.rb +237 -0
  47. data/lib/cabriolet/kwaj/parser.rb +183 -0
  48. data/lib/cabriolet/lit/compressor.rb +255 -0
  49. data/lib/cabriolet/lit/decompressor.rb +250 -0
  50. data/lib/cabriolet/models/cabinet.rb +81 -0
  51. data/lib/cabriolet/models/chm_file.rb +28 -0
  52. data/lib/cabriolet/models/chm_header.rb +67 -0
  53. data/lib/cabriolet/models/chm_section.rb +38 -0
  54. data/lib/cabriolet/models/file.rb +119 -0
  55. data/lib/cabriolet/models/folder.rb +102 -0
  56. data/lib/cabriolet/models/folder_data.rb +21 -0
  57. data/lib/cabriolet/models/hlp_file.rb +45 -0
  58. data/lib/cabriolet/models/hlp_header.rb +37 -0
  59. data/lib/cabriolet/models/kwaj_header.rb +98 -0
  60. data/lib/cabriolet/models/lit_header.rb +55 -0
  61. data/lib/cabriolet/models/oab_header.rb +95 -0
  62. data/lib/cabriolet/models/szdd_header.rb +72 -0
  63. data/lib/cabriolet/modifier.rb +326 -0
  64. data/lib/cabriolet/oab/compressor.rb +353 -0
  65. data/lib/cabriolet/oab/decompressor.rb +315 -0
  66. data/lib/cabriolet/parallel.rb +333 -0
  67. data/lib/cabriolet/repairer.rb +288 -0
  68. data/lib/cabriolet/streaming.rb +221 -0
  69. data/lib/cabriolet/system/file_handle.rb +107 -0
  70. data/lib/cabriolet/system/io_system.rb +87 -0
  71. data/lib/cabriolet/system/memory_handle.rb +105 -0
  72. data/lib/cabriolet/szdd/compressor.rb +217 -0
  73. data/lib/cabriolet/szdd/decompressor.rb +184 -0
  74. data/lib/cabriolet/szdd/parser.rb +127 -0
  75. data/lib/cabriolet/validator.rb +332 -0
  76. data/lib/cabriolet/version.rb +5 -0
  77. data/lib/cabriolet.rb +104 -0
  78. metadata +157 -0
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ module Models
5
+ # OAB (Outlook Offline Address Book) file header
6
+ #
7
+ # OAB files come in two variants:
8
+ # - Full files (version 3.1)
9
+ # - Incremental patches (version 3.2)
10
+ class OABHeader
11
+ attr_accessor :version_hi, :version_lo, :block_max, :target_size, :source_size, :source_crc, :target_crc,
12
+ :is_patch
13
+
14
+ # Create new OAB header
15
+ #
16
+ # @param version_hi [Integer] High version number
17
+ # @param version_lo [Integer] Low version number (1=full, 2=patch)
18
+ # @param block_max [Integer] Maximum block size
19
+ # @param target_size [Integer] Decompressed output size
20
+ # @param source_size [Integer] Base file size (patches only)
21
+ # @param source_crc [Integer] Base file CRC (patches only)
22
+ # @param target_crc [Integer] Target file CRC (patches only)
23
+ def initialize(version_hi:, version_lo:, block_max:, target_size:,
24
+ source_size: nil, source_crc: nil, target_crc: nil)
25
+ @version_hi = version_hi
26
+ @version_lo = version_lo
27
+ @block_max = block_max
28
+ @target_size = target_size
29
+ @source_size = source_size
30
+ @source_crc = source_crc
31
+ @target_crc = target_crc
32
+ @is_patch = (version_lo == 2)
33
+ end
34
+
35
+ # Check if this is a valid OAB header
36
+ #
37
+ # @return [Boolean]
38
+ def valid?
39
+ version_hi == 3 && [1, 2].include?(version_lo)
40
+ end
41
+
42
+ # Check if this is an incremental patch
43
+ #
44
+ # @return [Boolean]
45
+ def patch?
46
+ is_patch
47
+ end
48
+
49
+ # Check if this is a full file
50
+ #
51
+ # @return [Boolean]
52
+ def full?
53
+ !is_patch
54
+ end
55
+ end
56
+
57
+ # OAB block header for full files
58
+ class OABBlockHeader
59
+ attr_accessor :flags, :compressed_size, :uncompressed_size, :crc
60
+
61
+ def initialize(flags:, compressed_size:, uncompressed_size:, crc:)
62
+ @flags = flags
63
+ @compressed_size = compressed_size
64
+ @uncompressed_size = uncompressed_size
65
+ @crc = crc
66
+ end
67
+
68
+ # Check if block is compressed
69
+ #
70
+ # @return [Boolean]
71
+ def compressed?
72
+ flags == 1
73
+ end
74
+
75
+ # Check if block is uncompressed
76
+ #
77
+ # @return [Boolean]
78
+ def uncompressed?
79
+ flags.zero?
80
+ end
81
+ end
82
+
83
+ # OAB block header for patch files
84
+ class OABPatchBlockHeader
85
+ attr_accessor :patch_size, :target_size, :source_size, :crc
86
+
87
+ def initialize(patch_size:, target_size:, source_size:, crc:)
88
+ @patch_size = patch_size
89
+ @target_size = target_size
90
+ @source_size = source_size
91
+ @crc = crc
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ module Models
5
+ # Represents an SZDD file header
6
+ #
7
+ # SZDD files are single-file compressed archives using LZSS compression.
8
+ # They were commonly used with MS-DOS COMPRESS.EXE and EXPAND.EXE commands.
9
+ class SZDDHeader
10
+ # SZDD format types
11
+ FORMAT_NORMAL = :normal
12
+ FORMAT_QBASIC = :qbasic
13
+
14
+ # Format of the SZDD file (:normal or :qbasic)
15
+ # @return [Symbol]
16
+ attr_accessor :format
17
+
18
+ # Uncompressed file size in bytes
19
+ # @return [Integer]
20
+ attr_accessor :length
21
+
22
+ # Missing character from the original filename (NORMAL format only)
23
+ # Commonly the last character (e.g., 't' in 'file.txt' -> 'file.tx_')
24
+ # @return [String, nil]
25
+ attr_accessor :missing_char
26
+
27
+ # Original or suggested filename
28
+ # @return [String, nil]
29
+ attr_accessor :filename
30
+
31
+ # Initialize a new SZDD header
32
+ #
33
+ # @param format [Symbol] Format type (:normal or :qbasic)
34
+ # @param length [Integer] Uncompressed size
35
+ # @param missing_char [String, nil] Missing filename character
36
+ # @param filename [String, nil] Original filename
37
+ def initialize(format: FORMAT_NORMAL, length: 0, missing_char: nil,
38
+ filename: nil)
39
+ @format = format
40
+ @length = length
41
+ @missing_char = missing_char
42
+ @filename = filename
43
+ end
44
+
45
+ # Check if this is a NORMAL format SZDD file
46
+ #
47
+ # @return [Boolean]
48
+ def normal_format?
49
+ @format == FORMAT_NORMAL
50
+ end
51
+
52
+ # Check if this is a QBASIC format SZDD file
53
+ #
54
+ # @return [Boolean]
55
+ def qbasic_format?
56
+ @format == FORMAT_QBASIC
57
+ end
58
+
59
+ # Generate suggested output filename from compressed filename
60
+ #
61
+ # @param compressed_filename [String] The compressed filename
62
+ # @return [String] Suggested output filename
63
+ def suggested_filename(compressed_filename)
64
+ return compressed_filename unless normal_format? && @missing_char
65
+
66
+ # Replace trailing underscore with missing character
67
+ # Pattern: ends with .XX_ where XX is any 2+ characters
68
+ compressed_filename.sub(/\.(\w+)_$/, ".\\1#{@missing_char}")
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ # Archive modification functionality (add, update, remove files)
5
+ class Modifier
6
+ def initialize(path)
7
+ @path = path
8
+ @format = FormatDetector.detect(path)
9
+ @modifications = []
10
+ @parser_class = FormatDetector.format_to_parser(@format)
11
+
12
+ raise UnsupportedFormatError, "Unknown format: #{path}" unless @format
13
+
14
+ unless @parser_class
15
+ raise UnsupportedFormatError,
16
+ "No parser for format: #{@format}"
17
+ end
18
+ end
19
+
20
+ # Add a file to the archive
21
+ #
22
+ # @param name [String] File name in archive
23
+ # @param source [String, nil] Source file path (nil for data parameter)
24
+ # @param data [String, nil] File data (if source not provided)
25
+ # @param options [Hash] File metadata options
26
+ # @return [self]
27
+ #
28
+ # @example
29
+ # modifier = Cabriolet::Modifier.new('archive.cab')
30
+ # modifier.add_file('new.txt', source: 'path/to/new.txt')
31
+ # modifier.add_file('data.bin', data: binary_data)
32
+ # modifier.save
33
+ def add_file(name, source: nil, data: nil, **options)
34
+ if source.nil? && data.nil?
35
+ raise ArgumentError,
36
+ "Must provide either source or data"
37
+ end
38
+
39
+ file_data = source ? File.read(source, mode: "rb") : data
40
+
41
+ @modifications << {
42
+ action: :add,
43
+ name: name,
44
+ data: file_data,
45
+ options: options,
46
+ }
47
+
48
+ self
49
+ end
50
+
51
+ # Update an existing file in the archive
52
+ #
53
+ # @param name [String] File name to update
54
+ # @param source [String, nil] Source file path
55
+ # @param data [String, nil] New file data
56
+ # @return [self]
57
+ #
58
+ # @example
59
+ # modifier.update_file('config.xml', data: new_xml_data)
60
+ def update_file(name, source: nil, data: nil, **options)
61
+ if source.nil? && data.nil?
62
+ raise ArgumentError,
63
+ "Must provide either source or data"
64
+ end
65
+
66
+ file_data = source ? File.read(source, mode: "rb") : data
67
+
68
+ @modifications << {
69
+ action: :update,
70
+ name: name,
71
+ data: file_data,
72
+ options: options,
73
+ }
74
+
75
+ self
76
+ end
77
+
78
+ # Remove a file from the archive
79
+ #
80
+ # @param name [String] File name to remove
81
+ # @return [self]
82
+ #
83
+ # @example
84
+ # modifier.remove_file('old.txt')
85
+ def remove_file(name)
86
+ @modifications << {
87
+ action: :remove,
88
+ name: name,
89
+ }
90
+
91
+ self
92
+ end
93
+
94
+ # Rename a file in the archive
95
+ #
96
+ # @param old_name [String] Current file name
97
+ # @param new_name [String] New file name
98
+ # @return [self]
99
+ #
100
+ # @example
101
+ # modifier.rename_file('old_name.txt', 'new_name.txt')
102
+ def rename_file(old_name, new_name)
103
+ @modifications << {
104
+ action: :rename,
105
+ old_name: old_name,
106
+ new_name: new_name,
107
+ }
108
+
109
+ self
110
+ end
111
+
112
+ # Save modifications to the archive
113
+ #
114
+ # @param output [String, nil] Output path (nil for in-place update)
115
+ # @return [ModificationReport] Report of changes made
116
+ #
117
+ # @example
118
+ # modifier.save # Update in-place
119
+ # modifier.save(output: 'modified.cab') # Save to new file
120
+ def save(output: nil)
121
+ output ||= @path
122
+
123
+ # Parse original archive
124
+ archive = @parser_class.new.parse(@path)
125
+
126
+ # Apply modifications
127
+ modified_files = apply_modifications(archive)
128
+
129
+ # Rebuild archive
130
+ rebuild_archive(modified_files, output)
131
+
132
+ ModificationReport.new(
133
+ success: true,
134
+ original: @path,
135
+ output: output,
136
+ modifications: @modifications.count,
137
+ added: @modifications.count { |m| m[:action] == :add },
138
+ updated: @modifications.count { |m| m[:action] == :update },
139
+ removed: @modifications.count { |m| m[:action] == :remove },
140
+ renamed: @modifications.count { |m| m[:action] == :rename },
141
+ )
142
+ rescue StandardError => e
143
+ ModificationReport.new(
144
+ success: false,
145
+ original: @path,
146
+ output: output,
147
+ error: e.message,
148
+ )
149
+ end
150
+
151
+ # Preview modifications without saving
152
+ #
153
+ # @return [Array<Hash>] List of planned modifications
154
+ def preview
155
+ @modifications.map do |mod|
156
+ case mod[:action]
157
+ when :add
158
+ { action: "ADD", file: mod[:name], size: mod[:data].bytesize }
159
+ when :update
160
+ { action: "UPDATE", file: mod[:name], size: mod[:data].bytesize }
161
+ when :remove
162
+ { action: "REMOVE", file: mod[:name] }
163
+ when :rename
164
+ { action: "RENAME", from: mod[:old_name], to: mod[:new_name] }
165
+ end
166
+ end
167
+ end
168
+
169
+ private
170
+
171
+ def apply_modifications(archive)
172
+ # Start with existing files
173
+ files_map = {}
174
+ archive.files.each do |file|
175
+ files_map[file.name] = file
176
+ end
177
+
178
+ # Apply each modification
179
+ @modifications.each do |mod|
180
+ case mod[:action]
181
+ when :add
182
+ files_map[mod[:name]] = create_file_object(mod)
183
+ when :update
184
+ if files_map[mod[:name]]
185
+ files_map[mod[:name]] =
186
+ create_file_object(mod)
187
+ end
188
+ when :remove
189
+ files_map.delete(mod[:name])
190
+ when :rename
191
+ if files_map[mod[:old_name]]
192
+ file = files_map.delete(mod[:old_name])
193
+ files_map[mod[:new_name]] = rename_file_object(file, mod[:new_name])
194
+ end
195
+ end
196
+ end
197
+
198
+ files_map.values
199
+ end
200
+
201
+ def create_file_object(mod)
202
+ # Create a simple file object with necessary attributes
203
+ FileObject.new(
204
+ name: mod[:name],
205
+ data: mod[:data],
206
+ attributes: mod[:options][:attributes] || 0x20, # Archive attribute
207
+ date: mod[:options][:date],
208
+ time: mod[:options][:time],
209
+ )
210
+ end
211
+
212
+ def rename_file_object(file, new_name)
213
+ FileObject.new(
214
+ name: new_name,
215
+ data: file.data,
216
+ attributes: file.respond_to?(:attributes) ? file.attributes : 0x20,
217
+ date: file.respond_to?(:date) ? file.date : nil,
218
+ time: file.respond_to?(:time) ? file.time : nil,
219
+ )
220
+ end
221
+
222
+ def rebuild_archive(files, output)
223
+ case @format
224
+ when :cab
225
+ rebuild_cab(files, output)
226
+ else
227
+ raise UnsupportedOperationError,
228
+ "Modification not supported for #{@format}"
229
+ end
230
+ end
231
+
232
+ def rebuild_cab(files, output)
233
+ require_relative "cab/compressor"
234
+
235
+ compressor = CAB::Compressor.new(
236
+ output: output,
237
+ compression: :mszip,
238
+ )
239
+
240
+ files.each do |file|
241
+ compressor.add_file_data(
242
+ file.name,
243
+ file.data,
244
+ attributes: file.attributes,
245
+ date: file.date,
246
+ time: file.time,
247
+ )
248
+ end
249
+
250
+ compressor.compress
251
+ end
252
+
253
+ # Simple file object for modified files
254
+ class FileObject
255
+ attr_reader :name, :data, :attributes, :date, :time
256
+
257
+ def initialize(name:, data:, attributes: nil, date: nil, time: nil)
258
+ @name = name
259
+ @data = data
260
+ @attributes = attributes
261
+ @date = date
262
+ @time = time
263
+ end
264
+
265
+ def size
266
+ @data.bytesize
267
+ end
268
+ end
269
+ end
270
+
271
+ # Modification report
272
+ class ModificationReport
273
+ attr_reader :success, :original, :output, :modifications, :added, :updated,
274
+ :removed, :renamed, :error
275
+
276
+ def initialize(success:, original:, output:, modifications: 0, added: 0, updated: 0, removed: 0, renamed: 0,
277
+ error: nil)
278
+ @success = success
279
+ @original = original
280
+ @output = output
281
+ @modifications = modifications
282
+ @added = added
283
+ @updated = updated
284
+ @removed = removed
285
+ @renamed = renamed
286
+ @error = error
287
+ end
288
+
289
+ def success?
290
+ @success
291
+ end
292
+
293
+ def summary
294
+ if success?
295
+ "Modified #{@modifications} items: +#{@added} ~#{@updated} -#{@removed} →#{@renamed}"
296
+ else
297
+ "Modification failed: #{@error}"
298
+ end
299
+ end
300
+
301
+ def detailed_report
302
+ report = ["=" * 60]
303
+ report << "Archive Modification Report"
304
+ report << ("=" * 60)
305
+ report << "Original: #{@original}"
306
+ report << "Output: #{@output}"
307
+ report << "Status: #{success? ? 'SUCCESS' : 'FAILED'}"
308
+ report << ""
309
+
310
+ if success?
311
+ report << "Modifications:"
312
+ report << " Added: #{@added}"
313
+ report << " Updated: #{@updated}"
314
+ report << " Removed: #{@removed}"
315
+ report << " Renamed: #{@renamed}"
316
+ report << " Total: #{@modifications}"
317
+ else
318
+ report << "Error: #{@error}"
319
+ end
320
+
321
+ report << ""
322
+ report << ("=" * 60)
323
+ report.join("\n")
324
+ end
325
+ end
326
+ end