cabriolet 0.1.2 → 0.2.1

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +703 -38
  3. data/lib/cabriolet/algorithm_factory.rb +250 -0
  4. data/lib/cabriolet/base_compressor.rb +206 -0
  5. data/lib/cabriolet/binary/bitstream.rb +167 -16
  6. data/lib/cabriolet/binary/bitstream_writer.rb +150 -21
  7. data/lib/cabriolet/binary/chm_structures.rb +2 -2
  8. data/lib/cabriolet/binary/hlp_structures.rb +258 -37
  9. data/lib/cabriolet/binary/lit_structures.rb +231 -65
  10. data/lib/cabriolet/binary/oab_structures.rb +17 -1
  11. data/lib/cabriolet/cab/command_handler.rb +226 -0
  12. data/lib/cabriolet/cab/compressor.rb +108 -84
  13. data/lib/cabriolet/cab/decompressor.rb +16 -20
  14. data/lib/cabriolet/cab/extractor.rb +142 -66
  15. data/lib/cabriolet/cab/file_compression_work.rb +52 -0
  16. data/lib/cabriolet/cab/file_compression_worker.rb +89 -0
  17. data/lib/cabriolet/checksum.rb +49 -0
  18. data/lib/cabriolet/chm/command_handler.rb +227 -0
  19. data/lib/cabriolet/chm/compressor.rb +7 -3
  20. data/lib/cabriolet/chm/decompressor.rb +39 -21
  21. data/lib/cabriolet/chm/parser.rb +5 -2
  22. data/lib/cabriolet/cli/base_command_handler.rb +127 -0
  23. data/lib/cabriolet/cli/command_dispatcher.rb +140 -0
  24. data/lib/cabriolet/cli/command_registry.rb +83 -0
  25. data/lib/cabriolet/cli.rb +356 -607
  26. data/lib/cabriolet/collections/file_collection.rb +175 -0
  27. data/lib/cabriolet/compressors/base.rb +1 -1
  28. data/lib/cabriolet/compressors/lzx.rb +241 -54
  29. data/lib/cabriolet/compressors/mszip.rb +35 -3
  30. data/lib/cabriolet/compressors/quantum.rb +36 -95
  31. data/lib/cabriolet/decompressors/base.rb +1 -1
  32. data/lib/cabriolet/decompressors/lzss.rb +13 -3
  33. data/lib/cabriolet/decompressors/lzx.rb +70 -33
  34. data/lib/cabriolet/decompressors/mszip.rb +126 -39
  35. data/lib/cabriolet/decompressors/quantum.rb +83 -53
  36. data/lib/cabriolet/errors.rb +3 -0
  37. data/lib/cabriolet/extraction/base_extractor.rb +88 -0
  38. data/lib/cabriolet/extraction/extractor.rb +171 -0
  39. data/lib/cabriolet/extraction/file_extraction_work.rb +60 -0
  40. data/lib/cabriolet/extraction/file_extraction_worker.rb +106 -0
  41. data/lib/cabriolet/file_entry.rb +156 -0
  42. data/lib/cabriolet/file_manager.rb +144 -0
  43. data/lib/cabriolet/format_base.rb +79 -0
  44. data/lib/cabriolet/hlp/command_handler.rb +282 -0
  45. data/lib/cabriolet/hlp/compressor.rb +28 -238
  46. data/lib/cabriolet/hlp/decompressor.rb +107 -147
  47. data/lib/cabriolet/hlp/parser.rb +52 -101
  48. data/lib/cabriolet/hlp/quickhelp/compression_stream.rb +138 -0
  49. data/lib/cabriolet/hlp/quickhelp/compressor.rb +151 -0
  50. data/lib/cabriolet/hlp/quickhelp/decompressor.rb +558 -0
  51. data/lib/cabriolet/hlp/quickhelp/file_writer.rb +125 -0
  52. data/lib/cabriolet/hlp/quickhelp/huffman_stream.rb +74 -0
  53. data/lib/cabriolet/hlp/quickhelp/huffman_tree.rb +167 -0
  54. data/lib/cabriolet/hlp/quickhelp/offset_calculator.rb +61 -0
  55. data/lib/cabriolet/hlp/quickhelp/parser.rb +274 -0
  56. data/lib/cabriolet/hlp/quickhelp/structure_builder.rb +93 -0
  57. data/lib/cabriolet/hlp/quickhelp/topic_builder.rb +52 -0
  58. data/lib/cabriolet/hlp/quickhelp/topic_compressor.rb +83 -0
  59. data/lib/cabriolet/hlp/winhelp/btree_builder.rb +289 -0
  60. data/lib/cabriolet/hlp/winhelp/compressor.rb +400 -0
  61. data/lib/cabriolet/hlp/winhelp/decompressor.rb +192 -0
  62. data/lib/cabriolet/hlp/winhelp/parser.rb +484 -0
  63. data/lib/cabriolet/hlp/winhelp/zeck_lz77.rb +271 -0
  64. data/lib/cabriolet/huffman/encoder.rb +15 -12
  65. data/lib/cabriolet/huffman/tree.rb +85 -1
  66. data/lib/cabriolet/kwaj/command_handler.rb +213 -0
  67. data/lib/cabriolet/kwaj/compressor.rb +7 -3
  68. data/lib/cabriolet/kwaj/decompressor.rb +18 -12
  69. data/lib/cabriolet/lit/command_handler.rb +221 -0
  70. data/lib/cabriolet/lit/compressor.rb +119 -168
  71. data/lib/cabriolet/lit/content_encoder.rb +76 -0
  72. data/lib/cabriolet/lit/content_type_detector.rb +50 -0
  73. data/lib/cabriolet/lit/decompressor.rb +518 -152
  74. data/lib/cabriolet/lit/directory_builder.rb +153 -0
  75. data/lib/cabriolet/lit/guid_generator.rb +16 -0
  76. data/lib/cabriolet/lit/header_writer.rb +124 -0
  77. data/lib/cabriolet/lit/parser.rb +670 -0
  78. data/lib/cabriolet/lit/piece_builder.rb +74 -0
  79. data/lib/cabriolet/lit/structure_builder.rb +252 -0
  80. data/lib/cabriolet/models/hlp_file.rb +130 -29
  81. data/lib/cabriolet/models/hlp_header.rb +105 -17
  82. data/lib/cabriolet/models/lit_header.rb +212 -25
  83. data/lib/cabriolet/models/szdd_header.rb +10 -2
  84. data/lib/cabriolet/models/winhelp_header.rb +127 -0
  85. data/lib/cabriolet/oab/command_handler.rb +257 -0
  86. data/lib/cabriolet/oab/compressor.rb +17 -8
  87. data/lib/cabriolet/oab/decompressor.rb +41 -10
  88. data/lib/cabriolet/offset_calculator.rb +81 -0
  89. data/lib/cabriolet/plugin.rb +233 -0
  90. data/lib/cabriolet/plugin_manager.rb +453 -0
  91. data/lib/cabriolet/plugin_validator.rb +422 -0
  92. data/lib/cabriolet/quantum_shared.rb +105 -0
  93. data/lib/cabriolet/system/io_system.rb +3 -0
  94. data/lib/cabriolet/system/memory_handle.rb +17 -4
  95. data/lib/cabriolet/szdd/command_handler.rb +217 -0
  96. data/lib/cabriolet/szdd/compressor.rb +15 -11
  97. data/lib/cabriolet/szdd/decompressor.rb +18 -9
  98. data/lib/cabriolet/version.rb +1 -1
  99. data/lib/cabriolet.rb +181 -20
  100. metadata +69 -4
  101. data/lib/cabriolet/auto.rb +0 -173
  102. data/lib/cabriolet/parallel.rb +0 -333
@@ -2,53 +2,240 @@
2
2
 
3
3
  module Cabriolet
4
4
  module Models
5
- # Represents the header of a Microsoft Reader LIT file
5
+ # Represents a Microsoft Reader LIT file structure
6
6
  #
7
- # LIT files are Microsoft Reader eBook files that use LZX compression
8
- # and may use DES encryption for DRM-protected content.
9
- class LITHeader
10
- attr_accessor :version, :filename, :length, :encrypted, :files
7
+ # LIT files have a complex structure with:
8
+ # - Primary and secondary headers
9
+ # - Piece table pointing to various data structures
10
+ # - Internal directory with IFCM/AOLL/AOLI chunks
11
+ # - DataSpace sections with transformation layers (compression/encryption)
12
+ # - Manifest mapping internal to original filenames
13
+ class LITFile
14
+ attr_accessor :version, :header_guid, :piece3_guid, :piece4_guid,
15
+ :content_offset, :timestamp, :language_id, :creator_id,
16
+ :entry_chunklen, :count_chunklen, :entry_unknown,
17
+ :count_unknown, :drm_level, :sections, :directory, :manifest
11
18
 
12
19
  def initialize
13
20
  @version = 0
14
- @filename = ""
15
- @length = 0
16
- @encrypted = false
17
- @files = []
21
+ @header_guid = ""
22
+ @piece3_guid = ""
23
+ @piece4_guid = ""
24
+ @content_offset = 0
25
+ @timestamp = 0
26
+ @language_id = 0
27
+ @creator_id = 0
28
+ @entry_chunklen = 0
29
+ @count_chunklen = 0
30
+ @entry_unknown = 0
31
+ @count_unknown = 0
32
+ @drm_level = 0
33
+ @sections = []
34
+ @directory = nil
35
+ @manifest = nil
18
36
  end
19
37
 
20
- # Check if the LIT file is encrypted
38
+ # Check if the LIT file has DRM encryption
21
39
  #
22
- # @return [Boolean] true if the file uses DES encryption
40
+ # @return [Boolean] true if DRM is present
23
41
  def encrypted?
24
- @encrypted
42
+ drm_level.positive?
43
+ end
44
+
45
+ # Get section by name
46
+ #
47
+ # @param name [String] Section name
48
+ # @return [LITSection, nil] The section or nil if not found
49
+ def section(name)
50
+ sections.find { |s| s.name == name }
25
51
  end
26
52
  end
27
53
 
28
- # Represents a file entry within a LIT archive
29
- class LITFile
30
- attr_accessor :filename, :offset, :length, :compressed, :encrypted
54
+ # Represents a section within the LIT file
55
+ #
56
+ # Sections contain compressed/encrypted data with transform layers
57
+ class LITSection
58
+ attr_accessor :name, :transforms, :compressed, :encrypted,
59
+ :uncompressed_length, :compressed_length,
60
+ :window_size, :reset_interval, :reset_table
31
61
 
32
62
  def initialize
33
- @filename = ""
34
- @offset = 0
35
- @length = 0
36
- @compressed = true
63
+ @name = ""
64
+ @transforms = []
65
+ @compressed = false
37
66
  @encrypted = false
67
+ @uncompressed_length = 0
68
+ @compressed_length = 0
69
+ @window_size = 0
70
+ @reset_interval = 0
71
+ @reset_table = []
38
72
  end
39
73
 
40
- # Check if the file is compressed
74
+ # Check if section is compressed
41
75
  #
42
- # @return [Boolean] true if the file uses LZX compression
76
+ # @return [Boolean] true if compressed
43
77
  def compressed?
44
- @compressed
78
+ compressed
45
79
  end
46
80
 
47
- # Check if the file is encrypted
81
+ # Check if section is encrypted
48
82
  #
49
- # @return [Boolean] true if the file uses DES encryption
83
+ # @return [Boolean] true if encrypted
50
84
  def encrypted?
51
- @encrypted
85
+ encrypted
86
+ end
87
+ end
88
+
89
+ # Represents the internal directory structure
90
+ #
91
+ # Directory contains file entries with encoded integers for efficiency
92
+ class LITDirectory
93
+ attr_accessor :entries, :num_chunks, :entry_chunklen, :count_chunklen
94
+
95
+ def initialize
96
+ @entries = []
97
+ @num_chunks = 0
98
+ @entry_chunklen = 0
99
+ @count_chunklen = 0
100
+ end
101
+
102
+ # Find entry by name
103
+ #
104
+ # @param name [String] Entry name
105
+ # @return [LITDirectoryEntry, nil] The entry or nil if not found
106
+ def find(name)
107
+ entries.find { |e| e.name == name }
108
+ end
109
+
110
+ # Get all entries in a section
111
+ #
112
+ # @param section_id [Integer] Section ID
113
+ # @return [Array<LITDirectoryEntry>] Entries in the section
114
+ def entries_in_section(section_id)
115
+ entries.select { |e| e.section == section_id }
116
+ end
117
+ end
118
+
119
+ # Represents a single directory entry
120
+ #
121
+ # Entries use variable-length encoded integers to save space
122
+ class LITDirectoryEntry
123
+ attr_accessor :name, :section, :offset, :size
124
+
125
+ def initialize
126
+ @name = ""
127
+ @section = 0
128
+ @offset = 0
129
+ @size = 0
130
+ end
131
+
132
+ # Check if this is a root entry
133
+ #
134
+ # @return [Boolean] true if root entry
135
+ def root?
136
+ ["/", ""].include?(name)
137
+ end
138
+
139
+ # Get the directory portion of the name
140
+ #
141
+ # @return [String] Directory path
142
+ def directory
143
+ return "/" if root?
144
+
145
+ parts = name.split("/")
146
+ parts[0..-2].join("/")
147
+ end
148
+
149
+ # Get the filename portion
150
+ #
151
+ # @return [String] Filename
152
+ def filename
153
+ return "" if root?
154
+
155
+ name.split("/").last
156
+ end
157
+ end
158
+
159
+ # Represents the manifest file
160
+ #
161
+ # Maps internal filenames to original filenames and content types
162
+ class LITManifest
163
+ attr_accessor :mappings
164
+
165
+ def initialize
166
+ @mappings = []
167
+ end
168
+
169
+ # Find mapping by internal name
170
+ #
171
+ # @param internal_name [String] Internal filename
172
+ # @return [LITManifestMapping, nil] The mapping or nil
173
+ def find_by_internal(internal_name)
174
+ mappings.find { |m| m.internal_name == internal_name }
175
+ end
176
+
177
+ # Find mapping by original name
178
+ #
179
+ # @param original_name [String] Original filename
180
+ # @return [LITManifestMapping, nil] The mapping or nil
181
+ def find_by_original(original_name)
182
+ mappings.find { |m| m.original_name == original_name }
183
+ end
184
+
185
+ # Get all HTML files
186
+ #
187
+ # @return [Array<LITManifestMapping>] HTML file mappings
188
+ def html_files
189
+ mappings.select { |m| m.content_type =~ /html/i }
190
+ end
191
+
192
+ # Get all CSS files
193
+ #
194
+ # @return [Array<LITManifestMapping>] CSS file mappings
195
+ def css_files
196
+ mappings.select { |m| m.content_type =~ /css/i }
197
+ end
198
+
199
+ # Get all image files
200
+ #
201
+ # @return [Array<LITManifestMapping>] Image file mappings
202
+ def image_files
203
+ mappings.select { |m| m.content_type =~ /image/i }
204
+ end
205
+ end
206
+
207
+ # Represents a single manifest mapping
208
+ class LITManifestMapping
209
+ attr_accessor :offset, :internal_name, :original_name, :content_type,
210
+ :group
211
+
212
+ def initialize
213
+ @offset = 0
214
+ @internal_name = ""
215
+ @original_name = ""
216
+ @content_type = ""
217
+ @group = 0
218
+ end
219
+
220
+ # Check if this is an HTML file
221
+ #
222
+ # @return [Boolean] true if HTML
223
+ def html?
224
+ content_type =~ /html/i
225
+ end
226
+
227
+ # Check if this is a CSS file
228
+ #
229
+ # @return [Boolean] true if CSS
230
+ def css?
231
+ content_type =~ /css/i
232
+ end
233
+
234
+ # Check if this is an image
235
+ #
236
+ # @return [Boolean] true if image
237
+ def image?
238
+ content_type =~ /image/i
52
239
  end
53
240
  end
54
241
  end
@@ -64,8 +64,16 @@ module Cabriolet
64
64
  return compressed_filename unless normal_format? && @missing_char
65
65
 
66
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}")
67
+ # Uppercase unless all extension characters are lowercase
68
+ extension_match = compressed_filename.match(/\.(\w+)_$/)
69
+ if extension_match
70
+ extension = extension_match[1]
71
+ # Uppercase unless extension is entirely lowercase
72
+ missing_char = extension == extension.downcase ? @missing_char.downcase : @missing_char.upcase
73
+ compressed_filename.sub(/\.(\w+)_$/, ".\\1#{missing_char}")
74
+ else
75
+ compressed_filename
76
+ end
69
77
  end
70
78
  end
71
79
  end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ module Models
5
+ # Windows Help (WinHelp) file header model
6
+ #
7
+ # Represents the metadata of a Windows Help file (WinHelp 3.x or 4.x).
8
+ # WinHelp files contain an internal file system with |SYSTEM, |TOPIC,
9
+ # and other internal files.
10
+ class WinHelpHeader
11
+ attr_accessor :version, :magic, :directory_offset, :free_list_offset, :file_size, :filename # :winhelp3 or :winhelp4 # Magic number (0x35F3 or 0x3F5F0000)
12
+
13
+ # Internal files in the help file
14
+ # Array of hashes: { filename:, file_size:, starting_block: }
15
+ attr_accessor :internal_files
16
+
17
+ # Parsed |SYSTEM file data (if extracted)
18
+ attr_accessor :system_data
19
+
20
+ # Initialize WinHelp header
21
+ #
22
+ # @param version [Symbol] :winhelp3 or :winhelp4
23
+ # @param magic [Integer] Magic number
24
+ # @param directory_offset [Integer] Offset to internal file directory
25
+ # @param free_list_offset [Integer] Offset to free list
26
+ # @param file_size [Integer] Total file size
27
+ # @param filename [String, nil] Original filename
28
+ def initialize(
29
+ version: :winhelp3,
30
+ magic: 0,
31
+ directory_offset: 0,
32
+ free_list_offset: 0,
33
+ file_size: 0,
34
+ filename: nil
35
+ )
36
+ @version = version
37
+ @magic = magic
38
+ @directory_offset = directory_offset
39
+ @free_list_offset = free_list_offset
40
+ @file_size = file_size
41
+ @filename = filename
42
+
43
+ @internal_files = []
44
+ @system_data = nil
45
+ end
46
+
47
+ # Check if header is valid
48
+ #
49
+ # @return [Boolean] true if header appears valid
50
+ def valid?
51
+ case @version
52
+ when :winhelp3
53
+ @magic == 0x35F3
54
+ when :winhelp4
55
+ (@magic & 0xFFFF) == 0x3F5F
56
+ else
57
+ false
58
+ end
59
+ end
60
+
61
+ # Check if this is WinHelp 3.x format
62
+ #
63
+ # @return [Boolean] true if WinHelp 3.x
64
+ def winhelp3?
65
+ @version == :winhelp3
66
+ end
67
+
68
+ # Check if this is WinHelp 4.x format
69
+ #
70
+ # @return [Boolean] true if WinHelp 4.x
71
+ def winhelp4?
72
+ @version == :winhelp4
73
+ end
74
+
75
+ # Get list of internal filenames
76
+ #
77
+ # @return [Array<String>] Internal file names
78
+ def internal_filenames
79
+ @internal_files.map { |f| f[:filename] }
80
+ end
81
+
82
+ # Find internal file by name
83
+ #
84
+ # @param name [String] Internal filename (e.g., "|SYSTEM")
85
+ # @return [Hash, nil] File entry or nil if not found
86
+ def find_file(name)
87
+ @internal_files.find { |f| f[:filename] == name }
88
+ end
89
+
90
+ # Check if |SYSTEM file exists
91
+ #
92
+ # @return [Boolean] true if |SYSTEM file present
93
+ def has_system_file?
94
+ !find_file("|SYSTEM").nil?
95
+ end
96
+
97
+ # Check if |TOPIC file exists
98
+ #
99
+ # @return [Boolean] true if |TOPIC file present
100
+ def has_topic_file?
101
+ !find_file("|TOPIC").nil?
102
+ end
103
+
104
+ # Get version string
105
+ #
106
+ # @return [String] Human-readable version
107
+ def version_string
108
+ case @version
109
+ when :winhelp3
110
+ "Windows Help 3.x (16-bit)"
111
+ when :winhelp4
112
+ "Windows Help 4.x (32-bit)"
113
+ else
114
+ "Unknown"
115
+ end
116
+ end
117
+
118
+ # Get magic number as hex string
119
+ #
120
+ # @return [String] Hex representation of magic
121
+ def magic_hex
122
+ magic_int = @magic.respond_to?(:to_i) ? @magic.to_i : @magic.to_int
123
+ "0x#{magic_int.to_s(16).upcase}"
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cli/base_command_handler"
4
+ require_relative "decompressor"
5
+ require_relative "compressor"
6
+
7
+ module Cabriolet
8
+ module OAB
9
+ # Command handler for OAB (Outlook Offline Address Book) format
10
+ #
11
+ # This handler implements the unified command interface for OAB files,
12
+ # wrapping the existing OAB::Decompressor and OAB::Compressor classes.
13
+ # OAB files use LZX compression for address book data.
14
+ #
15
+ # Unlike other formats, OAB is a compressed data format rather than
16
+ # an archive - the "list" command displays header information only.
17
+ #
18
+ class CommandHandler < Commands::BaseCommandHandler
19
+ # List OAB file information
20
+ #
21
+ # Displays information about the OAB file including version,
22
+ # block size, and target size.
23
+ #
24
+ # @param file [String] Path to the OAB file
25
+ # @param options [Hash] Additional options (unused)
26
+ # @return [void]
27
+ def list(file, _options = {})
28
+ validate_file_exists(file)
29
+
30
+ display_oab_info(file)
31
+ end
32
+
33
+ # Extract/decompress OAB file
34
+ #
35
+ # Decompresses the OAB file to its original form.
36
+ # Auto-detects output filename if not specified.
37
+ #
38
+ # @param file [String] Path to the OAB file
39
+ # @param output_dir [String] Output directory (not typically used for OAB)
40
+ # @param options [Hash] Additional options
41
+ # @option options [String] :output Output file path
42
+ # @option options [String] :base_file Base file for incremental patches
43
+ # @return [void]
44
+ def extract(file, output_dir = nil, options = {})
45
+ validate_file_exists(file)
46
+
47
+ output = options[:output]
48
+
49
+ # Auto-detect output name if not provided
50
+ if output.nil? && output_dir.nil?
51
+ output = auto_output_filename(file)
52
+ end
53
+
54
+ # If output_dir is specified, construct output path
55
+ if output.nil? && output_dir
56
+ base_name = File.basename(file, ".*")
57
+ output = File.join(output_dir, base_name)
58
+ end
59
+
60
+ decompressor = Decompressor.new
61
+
62
+ # Check if this is an incremental patch
63
+ if options[:base_file]
64
+ base_file = options[:base_file]
65
+ validate_file_exists(base_file)
66
+
67
+ puts "Applying incremental patch: #{file} + #{base_file} -> #{output}" if verbose?
68
+ bytes = decompressor.decompress_incremental(file, base_file, output)
69
+ puts "Applied patch to #{output} (#{bytes} bytes)"
70
+ else
71
+ puts "Decompressing #{file} -> #{output}" if verbose?
72
+ bytes = decompressor.decompress(file, output)
73
+ puts "Decompressed #{file} to #{output} (#{bytes} bytes)"
74
+ end
75
+ end
76
+
77
+ # Create OAB compressed file
78
+ #
79
+ # Compresses a file using OAB LZX compression.
80
+ #
81
+ # @param output [String] Output OAB file path
82
+ # @param files [Array<String>] Input file (single file for OAB)
83
+ # @param options [Hash] Additional options
84
+ # @option options [Integer] :block_size Block size for compression
85
+ # @option options [String] :base_file Base file for creating incremental patch
86
+ # @return [void]
87
+ # @raise [ArgumentError] if no file specified or multiple files
88
+ def create(output, files = [], options = {})
89
+ raise ArgumentError, "No file specified" if files.empty?
90
+
91
+ if files.size > 1
92
+ raise ArgumentError,
93
+ "OAB format supports only one file at a time"
94
+ end
95
+
96
+ file = files.first
97
+ unless File.exist?(file)
98
+ raise ArgumentError,
99
+ "File does not exist: #{file}"
100
+ end
101
+
102
+ compressor = Compressor.new
103
+
104
+ # Auto-generate output name if not provided
105
+ if output.nil?
106
+ output = "#{file}.oab"
107
+ end
108
+
109
+ if options[:base_file]
110
+ base_file = options[:base_file]
111
+ unless File.exist?(base_file)
112
+ raise ArgumentError,
113
+ "Base file does not exist: #{base_file}"
114
+ end
115
+
116
+ puts "Creating incremental patch: #{file} - #{base_file} -> #{output}" if verbose?
117
+ bytes = compressor.compress_incremental(file, base_file, output,
118
+ **options)
119
+ puts "Created incremental patch #{output} (#{bytes} bytes)"
120
+ else
121
+ block_size = options[:block_size]
122
+ puts "Compressing #{file} -> #{output} (block_size: #{block_size || 'default'})" if verbose?
123
+ bytes = compressor.compress(file, output, **options)
124
+ puts "Compressed #{file} to #{output} (#{bytes} bytes)"
125
+ end
126
+ end
127
+
128
+ # Display detailed OAB file information
129
+ #
130
+ # @param file [String] Path to the OAB file
131
+ # @param options [Hash] Additional options (unused)
132
+ # @return [void]
133
+ def info(file, _options = {})
134
+ validate_file_exists(file)
135
+
136
+ display_oab_info(file)
137
+ end
138
+
139
+ # Test OAB file integrity
140
+ #
141
+ # Verifies the OAB file structure.
142
+ #
143
+ # @param file [String] Path to the OAB file
144
+ # @param options [Hash] Additional options (unused)
145
+ # @return [void]
146
+ def test(file, _options = {})
147
+ validate_file_exists(file)
148
+
149
+ puts "Testing #{file}..."
150
+
151
+ # Try to read and validate header
152
+ decompressor = Decompressor.new
153
+ # We can't easily test without decompressing, so we attempt to read the header
154
+ io_system = decompressor.io_system
155
+ handle = io_system.open(file, Constants::MODE_READ)
156
+
157
+ begin
158
+ header_data = io_system.read(handle, 16)
159
+ if header_data.length < 16
160
+ puts "ERROR: Failed to read OAB header"
161
+ return
162
+ end
163
+
164
+ # Check if it's a full file or patch file
165
+ full_header = Binary::OABStructures::FullHeader.read(header_data)
166
+ if full_header.valid?
167
+ puts "OK: OAB full file structure is valid"
168
+ puts "Version: #{full_header.version_hi}.#{full_header.version_lo}"
169
+ puts "Target size: #{full_header.target_size} bytes"
170
+ puts "Block max: #{full_header.block_max} bytes"
171
+ else
172
+ # Check for patch header
173
+ patch_header = Binary::OABStructures::PatchHeader.read(header_data)
174
+ if patch_header.valid?
175
+ puts "OK: OAB patch file structure is valid"
176
+ puts "Version: #{patch_header.version_hi}.#{patch_header.version_lo}"
177
+ puts "Target size: #{patch_header.target_size} bytes"
178
+ puts "Source size: #{patch_header.source_size} bytes"
179
+ else
180
+ puts "ERROR: Invalid OAB header signature"
181
+ end
182
+ end
183
+ rescue StandardError => e
184
+ puts "ERROR: OAB file validation failed: #{e.message}"
185
+ ensure
186
+ io_system.close(handle)
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ # Display OAB file information
193
+ #
194
+ # @param file [String] Path to the OAB file
195
+ # @return [void]
196
+ def display_oab_info(file)
197
+ puts "OAB File Information"
198
+ puts "=" * 50
199
+ puts "Filename: #{file}"
200
+
201
+ decompressor = Decompressor.new
202
+ io_system = decompressor.io_system
203
+ handle = io_system.open(file, Constants::MODE_READ)
204
+
205
+ begin
206
+ header_data = io_system.read(handle, 28) # Read enough for both header types
207
+
208
+ # Try full file header first
209
+ full_header = Binary::OABStructures::FullHeader.read(header_data[0,
210
+ 16])
211
+ if full_header.valid?
212
+ puts "Type: Full OAB file"
213
+ puts "Version: #{full_header.version_hi}.#{full_header.version_lo}"
214
+ puts "Target size: #{full_header.target_size} bytes"
215
+ puts "Block max: #{full_header.block_max} bytes"
216
+ return
217
+ end
218
+
219
+ # Try patch file header
220
+ patch_header = Binary::OABStructures::PatchHeader.read(header_data[0,
221
+ 28])
222
+ if patch_header.valid?
223
+ puts "Type: Incremental OAB patch"
224
+ puts "Version: #{patch_header.version_hi}.#{patch_header.version_lo}"
225
+ puts "Target size: #{patch_header.target_size} bytes"
226
+ puts "Source size: #{patch_header.source_size} bytes"
227
+ puts "Source CRC: 0x#{patch_header.source_crc.to_s(16).upcase}"
228
+ puts "Target CRC: 0x#{patch_header.target_crc.to_s(16).upcase}"
229
+ return
230
+ end
231
+
232
+ puts "Type: Unknown (invalid header)"
233
+ rescue StandardError => e
234
+ puts "Error reading OAB header: #{e.message}"
235
+ ensure
236
+ io_system.close(handle)
237
+ end
238
+ end
239
+
240
+ # Auto-detect output filename from OAB file
241
+ #
242
+ # @param file [String] Original file path
243
+ # @return [String] Detected output filename
244
+ def auto_output_filename(file)
245
+ # Remove .oab extension if present, otherwise just return the basename
246
+ base_name = File.basename(file, ".*")
247
+ # If the file doesn't end with .oab, keep the original name
248
+ if file.end_with?(".oab")
249
+ base_name
250
+ else
251
+ # Return with .dat extension (common for decompressed OAB)
252
+ "#{base_name}.dat"
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end