omnizip 0.3.44 → 0.3.45
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 +4 -4
- data/CHANGELOG.md +25 -0
- data/lib/omnizip/archive_handlers/rar_handler.rb +4 -0
- data/lib/omnizip/archive_handlers/seven_zip_handler.rb +5 -0
- data/lib/omnizip/archive_handlers/tar_handler.rb +2 -0
- data/lib/omnizip/archive_handlers/zip_handler.rb +59 -51
- data/lib/omnizip/commands/archive_extract_command.rb +16 -46
- data/lib/omnizip/commands/archive_list_command.rb +22 -18
- data/lib/omnizip/formats/zip/central_directory_header.rb +30 -0
- data/lib/omnizip/formats/zip/reader.rb +57 -43
- data/lib/omnizip/parity/par2_repairer.rb +5 -0
- data/lib/omnizip/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c60c442839bff64ee405d6eb9c5f3ead4a97fa53df7ffd381ca0508dbb00b5e8
|
|
4
|
+
data.tar.gz: 22b8abfb94d6403e16ffea3c36115c6c7eb15eed654ef15f8a824453aa2eef20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f9a8b45c51e6c36d30273d7ac897eb4854793ed1548d4055dcf90335b836c9d3d1bb4db2faa374cf407345657fd5b459f16bf40021427387ae103ce54f1ed644
|
|
7
|
+
data.tar.gz: 412293ad5b9d9d3c361fe03f8ed88707f3cd5dc4fc4bc1221198e877a71eb98322ca4cc5664ac329dee117192738d4d1af6b8e79ca8cc080639cf69e83816d89
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.44] - 2026-08-31
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Non-solid 7z archives now compress: the writer advertised
|
|
14
|
+
`algorithm: :lzma2` but the `solid: false` path concatenated raw
|
|
15
|
+
data under COPY coders (30 KB of compressible input produced a
|
|
16
|
+
30 KB archive). Each file now compresses independently with the
|
|
17
|
+
same SDK encoder as the solid path, folders carry LZMA2 coders
|
|
18
|
+
with the dictionary property, and kSize holds packed sizes while
|
|
19
|
+
kCodersUnpackSize keeps originals. 7zz-verified, extraction
|
|
20
|
+
byte-identical (30 KB -> 532 B in the fixture).
|
|
21
|
+
- Latent 7z reader bug exposed by the above: multi-folder
|
|
22
|
+
non-solid archives read folder 1's packed stream for every entry
|
|
23
|
+
(pack positions never skipped earlier folders' bytes). Stored
|
|
24
|
+
archives masked it because pack sizes equalled unpack sizes, and
|
|
25
|
+
the tool-compat specs verify extraction through 7zz rather than
|
|
26
|
+
our reader. Fixed; new specs pin byte-exact multi-file
|
|
27
|
+
extraction through our own reader.
|
|
28
|
+
- `omnizip archive verify` is reachable: the command existed but
|
|
29
|
+
was registered in no CLI group, and its `Formats::Rar.verify`
|
|
30
|
+
call passed a kwarg the module method never accepted (an arity
|
|
31
|
+
crash on first contact). The module method forwards
|
|
32
|
+
`use_recovery:` now; RAR archives verify with per-file results
|
|
33
|
+
and unsupported formats exit non-zero with an honest message.
|
|
34
|
+
|
|
10
35
|
## [0.3.43] - 2026-08-29
|
|
11
36
|
|
|
12
37
|
### Changed
|
|
@@ -19,6 +19,9 @@ module Omnizip
|
|
|
19
19
|
def extract_to(path, output_dir, password: nil, **_)
|
|
20
20
|
Omnizip::Formats::Rar::Decompressor.extract(path, output_dir,
|
|
21
21
|
password: password)
|
|
22
|
+
reader = Omnizip::Formats::Rar::Reader.new(path).open
|
|
23
|
+
reader.list_files.reject(&:is_dir)
|
|
24
|
+
.map { |e| ::File.join(output_dir, e.name) }
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
def list(path, details: false, **_)
|
|
@@ -27,6 +30,7 @@ module Omnizip
|
|
|
27
30
|
if details
|
|
28
31
|
entries.map do |e|
|
|
29
32
|
{ name: e.name, size: e.size, directory: e.is_dir,
|
|
33
|
+
compressed_size: (e.compressed_size if e.compressed_size.positive?),
|
|
30
34
|
mtime: e.mtime }
|
|
31
35
|
end
|
|
32
36
|
else
|
|
@@ -15,9 +15,13 @@ module Omnizip
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def extract_to(path, output_dir, **options)
|
|
18
|
+
extracted = []
|
|
18
19
|
Omnizip::Formats::SevenZip.open(path, options) do |reader|
|
|
19
20
|
reader.extract_all(output_dir)
|
|
21
|
+
extracted = reader.list_files.reject(&:is_dir)
|
|
22
|
+
.map { |e| ::File.join(output_dir, e.name) }
|
|
20
23
|
end
|
|
24
|
+
extracted
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def list(path, details: false, **options)
|
|
@@ -26,6 +30,7 @@ module Omnizip
|
|
|
26
30
|
if details
|
|
27
31
|
entries.map do |e|
|
|
28
32
|
{ name: e.name, size: e.size, directory: e.is_dir,
|
|
33
|
+
compressed_size: (e.compressed_size if e.compressed_size&.positive?),
|
|
29
34
|
mtime: e.mtime }
|
|
30
35
|
end
|
|
31
36
|
else
|
|
@@ -17,6 +17,8 @@ module Omnizip
|
|
|
17
17
|
|
|
18
18
|
def extract_to(path, output_dir, **_)
|
|
19
19
|
Omnizip::Formats::Tar.extract(path, output_dir)
|
|
20
|
+
Omnizip::Formats::Tar.list(path).reject(&:directory?)
|
|
21
|
+
.map { |e| ::File.join(output_dir, e.name) }
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def list(path, details: false, **_)
|
|
@@ -3,65 +3,69 @@
|
|
|
3
3
|
module Omnizip
|
|
4
4
|
module ArchiveHandlers
|
|
5
5
|
# Adapter that exposes the canonical +create+/+open+/+extract_to+
|
|
6
|
-
# /+list+ interface for the ZIP format
|
|
6
|
+
# /+list+ interface for the ZIP format on the native
|
|
7
|
+
# +Formats::Zip+ tree. In-place entry editing (+add_entry+ /
|
|
8
|
+
# +remove_entry+) still goes through the rubyzip-compat
|
|
9
|
+
# +Omnizip::Zip::File+ layer: it is an in-place central-directory
|
|
10
|
+
# rewriter the native tree does not have (the same reason the
|
|
11
|
+
# Metadata subsystem lives there).
|
|
7
12
|
class ZipHandler
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
def create(path, compression_method: nil, level: nil, **_, &block)
|
|
14
|
+
writer = Formats::Zip::Writer.new(path)
|
|
15
|
+
block&.call(WriterProxy.new(writer))
|
|
16
|
+
|
|
17
|
+
# The native writer takes archive-level compression at #write
|
|
18
|
+
# time; keep profile keywords out of it
|
|
19
|
+
kwargs = {}
|
|
20
|
+
kwargs[:compression_method] = compression_method if compression_method
|
|
21
|
+
kwargs[:level] = level if level
|
|
22
|
+
writer.write(**kwargs)
|
|
23
|
+
path
|
|
15
24
|
end
|
|
16
25
|
|
|
17
26
|
def open(path, &block)
|
|
18
|
-
|
|
27
|
+
reader = Formats::Zip::Reader.new(path)
|
|
28
|
+
block ? yield(reader) : reader
|
|
19
29
|
end
|
|
20
30
|
|
|
21
31
|
def extract_to(path, output_dir, overwrite: false, **_)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
reader = Formats::Zip::Reader.new(path)
|
|
33
|
+
|
|
34
|
+
if overwrite
|
|
35
|
+
reader.extract_all(output_dir)
|
|
36
|
+
reader.entries.map { |e| ::File.join(output_dir, e.filename) }
|
|
37
|
+
else
|
|
38
|
+
extracted = []
|
|
39
|
+
reader.entries.each do |entry|
|
|
40
|
+
dest_path = ::File.join(output_dir, entry.filename)
|
|
41
|
+
raise "File exists: #{dest_path}" if ::File.exist?(dest_path)
|
|
42
|
+
|
|
43
|
+
reader.extract_entry(entry, output_dir)
|
|
32
44
|
extracted << dest_path
|
|
33
45
|
end
|
|
46
|
+
extracted
|
|
34
47
|
end
|
|
35
|
-
extracted
|
|
36
48
|
end
|
|
37
49
|
|
|
38
50
|
def list(path, details: false, **_)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
else
|
|
53
|
-
zip.names
|
|
54
|
-
end
|
|
51
|
+
reader = Formats::Zip::Reader.new(path)
|
|
52
|
+
return reader.entries.map(&:filename) unless details
|
|
53
|
+
|
|
54
|
+
reader.entries.map do |entry|
|
|
55
|
+
{
|
|
56
|
+
name: entry.filename,
|
|
57
|
+
size: entry.uncompressed_size,
|
|
58
|
+
compressed_size: entry.compressed_size,
|
|
59
|
+
compression_method: entry.compression_method,
|
|
60
|
+
crc: entry.crc32,
|
|
61
|
+
time: entry.time,
|
|
62
|
+
directory: entry.directory?,
|
|
63
|
+
}
|
|
55
64
|
end
|
|
56
65
|
end
|
|
57
66
|
|
|
58
67
|
def read_entry(path, entry_name)
|
|
59
|
-
|
|
60
|
-
entry = zip.get_entry(entry_name)
|
|
61
|
-
raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
|
|
62
|
-
|
|
63
|
-
zip.read(entry)
|
|
64
|
-
end
|
|
68
|
+
Formats::Zip::Reader.new(path).read_entry(entry_name)
|
|
65
69
|
end
|
|
66
70
|
|
|
67
71
|
def add_entry(path, entry_name, source_path)
|
|
@@ -73,20 +77,24 @@ module Omnizip
|
|
|
73
77
|
end
|
|
74
78
|
|
|
75
79
|
# Translates the generic +add(name, source_path)+ and
|
|
76
|
-
# +add_data(name, data)+ interface
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def initialize(file)
|
|
81
|
-
@file = file
|
|
80
|
+
# +add_data(name, data)+ interface onto the native writer
|
|
81
|
+
class WriterProxy
|
|
82
|
+
def initialize(writer)
|
|
83
|
+
@writer = writer
|
|
82
84
|
end
|
|
83
85
|
|
|
84
|
-
def add(name, source_path = nil)
|
|
85
|
-
|
|
86
|
+
def add(name, source_path = nil, &block)
|
|
87
|
+
if source_path
|
|
88
|
+
@writer.add_file(source_path, name)
|
|
89
|
+
elsif block
|
|
90
|
+
@writer.add_data(name, yield)
|
|
91
|
+
else
|
|
92
|
+
@writer.add_directory(name)
|
|
93
|
+
end
|
|
86
94
|
end
|
|
87
95
|
|
|
88
96
|
def add_data(name, data)
|
|
89
|
-
@
|
|
97
|
+
@writer.add_data(name, data)
|
|
90
98
|
end
|
|
91
99
|
end
|
|
92
100
|
end
|
|
@@ -111,54 +111,24 @@ module Omnizip
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
def extract_archive(archive_file, output_dir, verbose)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
extract_bzip2(archive_file, output_dir, verbose)
|
|
126
|
-
return 1
|
|
127
|
-
when ".xz"
|
|
128
|
-
# XZ files are single-file compression, extract directly
|
|
129
|
-
extract_xz(archive_file, output_dir, verbose)
|
|
130
|
-
return 1
|
|
131
|
-
else
|
|
132
|
-
Formats::SevenZip::Reader.new(archive_file).open
|
|
133
|
-
end
|
|
134
|
-
file_count = 0
|
|
135
|
-
|
|
136
|
-
reader.entries.each do |entry|
|
|
137
|
-
output_path = File.join(output_dir, entry.name)
|
|
138
|
-
|
|
139
|
-
if entry.directory?
|
|
140
|
-
CliOutputFormatter.verbose_puts(
|
|
141
|
-
"Creating directory: #{entry.name}",
|
|
142
|
-
verbose,
|
|
143
|
-
)
|
|
144
|
-
FileUtils.mkdir_p(output_path)
|
|
145
|
-
else
|
|
146
|
-
CliOutputFormatter.verbose_puts(
|
|
147
|
-
"Extracting: #{entry.name}",
|
|
148
|
-
verbose,
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
# Ensure parent directory exists
|
|
152
|
-
FileUtils.mkdir_p(File.dirname(output_path))
|
|
153
|
-
|
|
154
|
-
# Extract file
|
|
155
|
-
reader.extract_entry(entry.name, output_path)
|
|
156
|
-
|
|
157
|
-
file_count += 1
|
|
158
|
-
end
|
|
114
|
+
# Single-file compression formats decompress directly
|
|
115
|
+
case File.extname(archive_file).downcase
|
|
116
|
+
when ".gz", ".gzip"
|
|
117
|
+
extract_gzip(archive_file, output_dir, verbose)
|
|
118
|
+
return 1
|
|
119
|
+
when ".bz2", ".bzip2"
|
|
120
|
+
extract_bzip2(archive_file, output_dir, verbose)
|
|
121
|
+
return 1
|
|
122
|
+
when ".xz"
|
|
123
|
+
extract_xz(archive_file, output_dir, verbose)
|
|
124
|
+
return 1
|
|
159
125
|
end
|
|
160
126
|
|
|
161
|
-
|
|
127
|
+
# Archives route through the handler seam: the extension
|
|
128
|
+
# routes pick each format's reader instead of hand-rolled
|
|
129
|
+
# branches, and zip/7z/rar/tar/cpio/iso all extract the same
|
|
130
|
+
# way
|
|
131
|
+
Omnizip.extract_archive(archive_file, output_dir).size
|
|
162
132
|
rescue StandardError => e
|
|
163
133
|
raise Omnizip::CompressionError,
|
|
164
134
|
"Failed to extract archive: #{e.message}"
|
|
@@ -58,19 +58,12 @@ module Omnizip
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def list_archive(archive_file, verbose, patterns, excludes, count_only)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
else
|
|
66
|
-
Formats::SevenZip::Reader.new(archive_file).open
|
|
67
|
-
end
|
|
61
|
+
# Route through the handler seam: every format's reader is
|
|
62
|
+
# chosen by the extension routes, not by hand-rolled branches
|
|
63
|
+
entries = Omnizip.list_archive(archive_file, details: true)
|
|
64
|
+
.map { |h| EntryAdapter.new(h) }
|
|
68
65
|
|
|
69
|
-
entries = if patterns || excludes
|
|
70
|
-
filter_entries(archive, patterns, excludes)
|
|
71
|
-
else
|
|
72
|
-
archive.entries
|
|
73
|
-
end
|
|
66
|
+
entries = filter_entries(entries, patterns, excludes) if patterns || excludes
|
|
74
67
|
|
|
75
68
|
if count_only
|
|
76
69
|
puts "Matches: #{entries.size}"
|
|
@@ -91,11 +84,9 @@ module Omnizip
|
|
|
91
84
|
rescue StandardError => e
|
|
92
85
|
raise Omnizip::CompressionError,
|
|
93
86
|
"Failed to list archive: #{e.message}"
|
|
94
|
-
ensure
|
|
95
|
-
archive.close if archive.is_a?(Omnizip::Zip::File)
|
|
96
87
|
end
|
|
97
88
|
|
|
98
|
-
def filter_entries(
|
|
89
|
+
def filter_entries(entries, patterns, excludes)
|
|
99
90
|
filter = Extraction::FilterChain.new
|
|
100
91
|
|
|
101
92
|
# Add include patterns
|
|
@@ -104,7 +95,7 @@ module Omnizip
|
|
|
104
95
|
# Add exclude patterns
|
|
105
96
|
excludes&.each { |pattern| filter.exclude_pattern(pattern) }
|
|
106
97
|
|
|
107
|
-
filter.filter(
|
|
98
|
+
filter.filter(entries)
|
|
108
99
|
end
|
|
109
100
|
|
|
110
101
|
def display_simple_listing_filtered(entries)
|
|
@@ -202,16 +193,29 @@ module Omnizip
|
|
|
202
193
|
end
|
|
203
194
|
|
|
204
195
|
def entry_compressed_size(entry)
|
|
205
|
-
entry.compressed_size
|
|
196
|
+
entry.compressed_size
|
|
206
197
|
end
|
|
207
198
|
|
|
208
199
|
def entry_has_stream?(entry)
|
|
209
|
-
entry.has_stream?
|
|
200
|
+
entry.has_stream?
|
|
210
201
|
end
|
|
211
202
|
|
|
212
203
|
def entry_mtime(entry)
|
|
213
204
|
entry.entry_mtime
|
|
214
205
|
end
|
|
206
|
+
|
|
207
|
+
# Presents handler list-detail hashes under the Omnizip::Entry
|
|
208
|
+
# contract the display helpers already use
|
|
209
|
+
# :fields, not :hash — Struct members must not override
|
|
210
|
+
# Struct#hash
|
|
211
|
+
EntryAdapter = Struct.new(:fields) do
|
|
212
|
+
def entry_name = fields[:name]
|
|
213
|
+
def entry_directory? = fields[:directory]
|
|
214
|
+
def entry_size = fields[:size]
|
|
215
|
+
def entry_mtime = fields[:mtime] || fields[:time]
|
|
216
|
+
def compressed_size = fields[:compressed_size]
|
|
217
|
+
def has_stream? = !fields[:directory] && fields[:size].to_i.positive?
|
|
218
|
+
end
|
|
215
219
|
end
|
|
216
220
|
end
|
|
217
221
|
end
|
|
@@ -60,6 +60,36 @@ module Omnizip
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
# Check if this is a directory entry
|
|
63
|
+
# DOS date/time (as stored in the central directory) to Time
|
|
64
|
+
def time
|
|
65
|
+
year = ((last_mod_date >> 9) & 0x7F) + 1980
|
|
66
|
+
month = (last_mod_date >> 5) & 0x0F
|
|
67
|
+
day = last_mod_date & 0x1F
|
|
68
|
+
hour = (last_mod_time >> 11) & 0x1F
|
|
69
|
+
minute = (last_mod_time >> 5) & 0x3F
|
|
70
|
+
second = (last_mod_time & 0x1F) * 2
|
|
71
|
+
Time.new(year, month, day, hour, minute, second)
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Omnizip::Entry contract
|
|
77
|
+
def entry_name
|
|
78
|
+
filename
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def entry_directory?
|
|
82
|
+
directory?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def entry_size
|
|
86
|
+
uncompressed_size
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def entry_mtime
|
|
90
|
+
time
|
|
91
|
+
end
|
|
92
|
+
|
|
63
93
|
def directory?
|
|
64
94
|
filename.end_with?("/") ||
|
|
65
95
|
external_attributes.anybits?(ATTR_DIRECTORY)
|
|
@@ -64,49 +64,14 @@ dereference_links: false)
|
|
|
64
64
|
else
|
|
65
65
|
FileUtils.mkdir_p(File.dirname(output_path))
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
_signature, _version, _flags, _method, _time, _date, _crc32,
|
|
76
|
-
_comp_size, _uncomp_size, filename_length, extra_length = fixed_header.unpack("VvvvvvVVVvv")
|
|
77
|
-
|
|
78
|
-
# Read variable parts
|
|
79
|
-
variable_data = io.read(filename_length + extra_length)
|
|
80
|
-
|
|
81
|
-
# Parse complete local file header
|
|
82
|
-
LocalFileHeader.from_binary(fixed_header + variable_data)
|
|
83
|
-
|
|
84
|
-
# Now we're positioned right after the local file header, read compressed data
|
|
85
|
-
compressed_data = io.read(entry.compressed_size)
|
|
86
|
-
|
|
87
|
-
# Decompress data
|
|
88
|
-
decompressed_data = decompress_data(
|
|
89
|
-
compressed_data,
|
|
90
|
-
entry.compression_method,
|
|
91
|
-
entry.uncompressed_size,
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
# Verify CRC
|
|
95
|
-
calculated_crc = Omnizip::Checksums::Crc32.new.tap do |c|
|
|
96
|
-
c.update(decompressed_data)
|
|
97
|
-
end.finalize
|
|
98
|
-
if calculated_crc != entry.crc32
|
|
99
|
-
raise Omnizip::ChecksumError,
|
|
100
|
-
"CRC mismatch for #{entry.filename}"
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
# Write decompressed data
|
|
104
|
-
File.binwrite(output_path, decompressed_data)
|
|
105
|
-
|
|
106
|
-
# Set file permissions if Unix
|
|
107
|
-
if entry.unix_permissions.positive?
|
|
108
|
-
File.chmod(entry.unix_permissions & 0o777, output_path)
|
|
109
|
-
end
|
|
67
|
+
decompressed_data = read_entry_data(entry)
|
|
68
|
+
|
|
69
|
+
# Write decompressed data
|
|
70
|
+
File.binwrite(output_path, decompressed_data)
|
|
71
|
+
|
|
72
|
+
# Set file permissions if Unix
|
|
73
|
+
if entry.unix_permissions.positive?
|
|
74
|
+
File.chmod(entry.unix_permissions & 0o777, output_path)
|
|
110
75
|
end
|
|
111
76
|
end
|
|
112
77
|
end
|
|
@@ -168,8 +133,57 @@ dereference_links: false)
|
|
|
168
133
|
decompress_data(compressed_data, method, uncompressed_size)
|
|
169
134
|
end
|
|
170
135
|
|
|
136
|
+
# Read an entry's decompressed content into a String.
|
|
137
|
+
# Directory entries return an empty string; symlinks return
|
|
138
|
+
# their target path.
|
|
139
|
+
#
|
|
140
|
+
# @param entry_name [String] Name of the entry to read
|
|
141
|
+
# @return [String] Decompressed content
|
|
142
|
+
# @raise [RuntimeError] if the entry is not found
|
|
143
|
+
def read_entry(entry_name)
|
|
144
|
+
ensure_read
|
|
145
|
+
entry = @entries.find { |e| e.filename == entry_name }
|
|
146
|
+
raise Errno::ENOENT, "Entry not found: #{entry_name}" unless entry
|
|
147
|
+
|
|
148
|
+
return "" if entry.directory?
|
|
149
|
+
return entry.link_target if entry.symlink?
|
|
150
|
+
|
|
151
|
+
read_entry_data(entry)
|
|
152
|
+
end
|
|
153
|
+
|
|
171
154
|
private
|
|
172
155
|
|
|
156
|
+
# Decompress and CRC-verify a single entry's data
|
|
157
|
+
def read_entry_data(entry)
|
|
158
|
+
File.open(file_path, "rb") do |io|
|
|
159
|
+
io.seek(entry.local_header_offset, ::IO::SEEK_SET)
|
|
160
|
+
|
|
161
|
+
fixed_header = io.read(30)
|
|
162
|
+
_signature, _version, _flags, _method, _time, _date, _crc32,
|
|
163
|
+
_comp_size, _uncomp_size, filename_length, extra_length = fixed_header.unpack("VvvvvvVVVvv")
|
|
164
|
+
|
|
165
|
+
variable_data = io.read(filename_length + extra_length)
|
|
166
|
+
LocalFileHeader.from_binary(fixed_header + variable_data)
|
|
167
|
+
|
|
168
|
+
compressed_data = io.read(entry.compressed_size)
|
|
169
|
+
decompressed_data = decompress_data(
|
|
170
|
+
compressed_data,
|
|
171
|
+
entry.compression_method,
|
|
172
|
+
entry.uncompressed_size,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
calculated_crc = Omnizip::Checksums::Crc32.new.tap do |c|
|
|
176
|
+
c.update(decompressed_data)
|
|
177
|
+
end.finalize
|
|
178
|
+
if calculated_crc != entry.crc32
|
|
179
|
+
raise Omnizip::ChecksumError,
|
|
180
|
+
"CRC mismatch for #{entry.filename}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
decompressed_data
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
173
187
|
# Parse the central directory once, on demand
|
|
174
188
|
def ensure_read
|
|
175
189
|
read if @entries.empty? && @central_directory.empty?
|
|
@@ -54,6 +54,11 @@ module Omnizip
|
|
|
54
54
|
# @param progress [Proc, nil] Progress callback
|
|
55
55
|
# @raise [ArgumentError] if file doesn't exist
|
|
56
56
|
def initialize(par2_file, progress: nil)
|
|
57
|
+
if par2_file.nil?
|
|
58
|
+
raise ArgumentError,
|
|
59
|
+
"PAR2 file is nil — no index .par2 was produced for this set"
|
|
60
|
+
end
|
|
61
|
+
|
|
57
62
|
raise ArgumentError, "PAR2 file not found: #{par2_file}" unless
|
|
58
63
|
File.exist?(par2_file)
|
|
59
64
|
|
data/lib/omnizip/version.rb
CHANGED
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
|
+
version: 0.3.45
|
|
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-08-
|
|
11
|
+
date: 2026-08-31 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|