omnizip 0.3.44 → 0.3.46
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 +50 -0
- data/lib/omnizip/archive_handlers/rar_handler.rb +55 -9
- 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/cli.rb +12 -0
- data/lib/omnizip/commands/archive_extract_command.rb +16 -46
- data/lib/omnizip/commands/archive_list_command.rb +22 -18
- data/lib/omnizip/formats/rar/rar5/header.rb +14 -7
- data/lib/omnizip/formats/rar/rar5/writer.rb +45 -2
- data/lib/omnizip/formats/rar/writer.rb +26 -4
- data/lib/omnizip/formats/rar.rb +2 -2
- 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: eb792b03b604528825ce30f9d2ef6c7b6ac077535f5a4bf027498a44138ab692
|
|
4
|
+
data.tar.gz: 777e4ed705e6aa51b53ca8bccf2b394ae24f6f9b4404325db398a24e13b5a811
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d512bfb76c29f2ca7a59bc6e46f558c2bc659b38dc1878b50ea879f91f93beff7c52a27a033b002f0a3e27ae2968484a8cb27b68aaa462a86aad9906f360a854
|
|
7
|
+
data.tar.gz: 25fdb5dacbe8dd8efb6835d916c8b455bcf07197845126dc8b832bbbf80418b80e904c5338a21e0300df6db83fd872fb6faa132f40e9b947ba19496a38eb44d2
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.45] - 2026-08-31
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- The ZIP ArchiveHandler — the seam the facade, the convenience
|
|
14
|
+
layer, and the CLI all route through — now sits on the native
|
|
15
|
+
`Formats::Zip` tree instead of the rubyzip-compat
|
|
16
|
+
`Omnizip::Zip::File` layer (which remains for in-place entry
|
|
17
|
+
editing and Metadata, by design). The native reader gains
|
|
18
|
+
`#read_entry` (decompress + CRC-verify, shared with extraction)
|
|
19
|
+
and the central-directory entry model gains the `Omnizip::Entry`
|
|
20
|
+
contract plus DOS-time decoding.
|
|
21
|
+
- CLI `archive list` and `archive extract` route through the
|
|
22
|
+
handler registry instead of hand-rolled format dispatch:
|
|
23
|
+
`archive list foo.tar` no longer falls into the 7z reader, zip
|
|
24
|
+
listing no longer opens the compat layer, and every handler's
|
|
25
|
+
`extract_to` returns the extracted file paths (7z/rar/tar
|
|
26
|
+
previously returned the reader or nil). Verbose listings show
|
|
27
|
+
real per-entry compressed sizes for the formats that track them.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Parity hardening: a nil PAR2 index crashed five frames deep as a
|
|
31
|
+
`TypeError`; the repairer boundary now raises a named
|
|
32
|
+
`ArgumentError` and the spec fixture can no longer select a nil
|
|
33
|
+
index (the order-coupled CI flake from 0.3.44).
|
|
34
|
+
|
|
35
|
+
## [0.3.44] - 2026-08-31
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- Non-solid 7z archives now compress: the writer advertised
|
|
39
|
+
`algorithm: :lzma2` but the `solid: false` path concatenated raw
|
|
40
|
+
data under COPY coders (30 KB of compressible input produced a
|
|
41
|
+
30 KB archive). Each file now compresses independently with the
|
|
42
|
+
same SDK encoder as the solid path, folders carry LZMA2 coders
|
|
43
|
+
with the dictionary property, and kSize holds packed sizes while
|
|
44
|
+
kCodersUnpackSize keeps originals. 7zz-verified, extraction
|
|
45
|
+
byte-identical (30 KB -> 532 B in the fixture).
|
|
46
|
+
- Latent 7z reader bug exposed by the above: multi-folder
|
|
47
|
+
non-solid archives read folder 1's packed stream for every entry
|
|
48
|
+
(pack positions never skipped earlier folders' bytes). Stored
|
|
49
|
+
archives masked it because pack sizes equalled unpack sizes, and
|
|
50
|
+
the tool-compat specs verify extraction through 7zz rather than
|
|
51
|
+
our reader. Fixed; new specs pin byte-exact multi-file
|
|
52
|
+
extraction through our own reader.
|
|
53
|
+
- `omnizip archive verify` is reachable: the command existed but
|
|
54
|
+
was registered in no CLI group, and its `Formats::Rar.verify`
|
|
55
|
+
call passed a kwarg the module method never accepted (an arity
|
|
56
|
+
crash on first contact). The module method forwards
|
|
57
|
+
`use_recovery:` now; RAR archives verify with per-file results
|
|
58
|
+
and unsupported formats exit non-zero with an honest message.
|
|
59
|
+
|
|
10
60
|
## [0.3.43] - 2026-08-29
|
|
11
61
|
|
|
12
62
|
### Changed
|
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "tmpdir"
|
|
4
|
+
require "tempfile"
|
|
4
5
|
|
|
5
6
|
module Omnizip
|
|
6
7
|
module ArchiveHandlers
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# archives truthfully instead of raising on every operation.
|
|
8
|
+
# Adapter for the RAR format: extraction/listing/reading, plus
|
|
9
|
+
# creation through Formats::Rar (RAR5 STORE is unrar-verified;
|
|
10
|
+
# compressed methods fall back to STORE with a warning — see the
|
|
11
|
+
# README interop table).
|
|
12
12
|
class RarHandler
|
|
13
|
-
def create(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
def create(path, **options, &block)
|
|
14
|
+
proxy = nil
|
|
15
|
+
result = Omnizip::Formats::Rar.create(path, options) do |writer|
|
|
16
|
+
proxy = WriterProxy.new(writer)
|
|
17
|
+
block&.call(proxy)
|
|
18
|
+
end
|
|
19
|
+
proxy&.cleanup
|
|
20
|
+
result
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
def extract_to(path, output_dir, password: nil, **_)
|
|
20
24
|
Omnizip::Formats::Rar::Decompressor.extract(path, output_dir,
|
|
21
25
|
password: password)
|
|
26
|
+
reader = Omnizip::Formats::Rar::Reader.new(path).open
|
|
27
|
+
reader.list_files.reject(&:is_dir)
|
|
28
|
+
.map { |e| ::File.join(output_dir, e.name) }
|
|
22
29
|
end
|
|
23
30
|
|
|
24
31
|
def list(path, details: false, **_)
|
|
@@ -27,6 +34,7 @@ module Omnizip
|
|
|
27
34
|
if details
|
|
28
35
|
entries.map do |e|
|
|
29
36
|
{ name: e.name, size: e.size, directory: e.is_dir,
|
|
37
|
+
compressed_size: (e.compressed_size if e.compressed_size.positive?),
|
|
30
38
|
mtime: e.mtime }
|
|
31
39
|
end
|
|
32
40
|
else
|
|
@@ -43,6 +51,44 @@ module Omnizip
|
|
|
43
51
|
File.binread(dest)
|
|
44
52
|
end
|
|
45
53
|
end
|
|
54
|
+
|
|
55
|
+
# Translates the generic +add(name, source_path)+ /
|
|
56
|
+
# +add_data(name, data)+ / +add_directory(name)+ interface onto
|
|
57
|
+
# the RAR writers. The writers take file paths, so in-memory
|
|
58
|
+
# content spills to a temporary file.
|
|
59
|
+
class WriterProxy
|
|
60
|
+
def initialize(writer)
|
|
61
|
+
@writer = writer
|
|
62
|
+
@spill = []
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def add(name, source_path = nil)
|
|
66
|
+
if source_path
|
|
67
|
+
@writer.add_file(source_path, name)
|
|
68
|
+
else
|
|
69
|
+
@writer.add_directory_entry(name)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def add_data(name, data)
|
|
74
|
+
file = Tempfile.new(["omnizip_rar_entry", File.extname(name)])
|
|
75
|
+
file.binmode
|
|
76
|
+
file.write(data)
|
|
77
|
+
file.close
|
|
78
|
+
@spill << file
|
|
79
|
+
@writer.add_file(file.path, name)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def add_directory(name)
|
|
83
|
+
@writer.add_directory_entry(name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Remove the temporary files add_data spilled
|
|
87
|
+
def cleanup
|
|
88
|
+
@spill.each(&:unlink)
|
|
89
|
+
@spill.clear
|
|
90
|
+
end
|
|
91
|
+
end
|
|
46
92
|
end
|
|
47
93
|
end
|
|
48
94
|
end
|
|
@@ -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
|
data/lib/omnizip/cli.rb
CHANGED
|
@@ -355,6 +355,18 @@ module Omnizip
|
|
|
355
355
|
rescue StandardError => e
|
|
356
356
|
handle_error(e)
|
|
357
357
|
end
|
|
358
|
+
|
|
359
|
+
desc "repair ARCHIVE OUTPUT", "Repair a damaged archive"
|
|
360
|
+
long_desc <<~DESC
|
|
361
|
+
Repair a damaged archive (currently RAR archives via recovery
|
|
362
|
+
records and .rev volumes). OUTPUT is the repaired archive path.
|
|
363
|
+
DESC
|
|
364
|
+
option :verbose, type: :boolean, default: false, aliases: "-v"
|
|
365
|
+
def repair(archive, output)
|
|
366
|
+
Omnizip::Commands::ArchiveRepairCommand.new(options).run(archive, output)
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
handle_error(e)
|
|
369
|
+
end
|
|
358
370
|
end
|
|
359
371
|
|
|
360
372
|
# Command-line interface for Omnizip.
|
|
@@ -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
|
|
@@ -106,8 +106,10 @@ header_data: "")
|
|
|
106
106
|
|
|
107
107
|
# File header
|
|
108
108
|
class FileHeader < Header
|
|
109
|
-
# File header flags
|
|
110
|
-
|
|
109
|
+
# File header flags. Per the RAR 5.0 spec, 0x0001 marks a
|
|
110
|
+
# directory entry (what this codebase once mislabeled as
|
|
111
|
+
# "has attributes" — attributes are an always-present field)
|
|
112
|
+
FILE_IS_DIRECTORY = 0x0001
|
|
111
113
|
FILE_HAS_MTIME = 0x0002
|
|
112
114
|
FILE_HAS_CRC32 = 0x0004
|
|
113
115
|
|
|
@@ -119,23 +121,27 @@ header_data: "")
|
|
|
119
121
|
|
|
120
122
|
def initialize(filename:, file_size:, compressed_size:,
|
|
121
123
|
compression_method: 0, dict_size: 0,
|
|
122
|
-
flags: 0, mtime: nil, crc32: nil,
|
|
124
|
+
flags: 0, mtime: nil, crc32: nil,
|
|
125
|
+
directory: false, extra_area: nil)
|
|
123
126
|
# Build file flags based on what's provided
|
|
124
127
|
file_flags = 0
|
|
128
|
+
file_flags |= FILE_IS_DIRECTORY if directory
|
|
125
129
|
file_flags |= FILE_HAS_MTIME if mtime
|
|
126
130
|
file_flags |= FILE_HAS_CRC32 if crc32
|
|
127
131
|
|
|
128
132
|
# Build header data with file information
|
|
129
133
|
data = build_file_data(filename, file_size,
|
|
130
134
|
compression_method, dict_size,
|
|
131
|
-
file_flags, mtime, crc32
|
|
135
|
+
file_flags, mtime, crc32,
|
|
136
|
+
directory: directory)
|
|
132
137
|
super(HEADER_TYPE_FILE, flags: flags, data_area_size: compressed_size, header_data: data, extra_area: extra_area)
|
|
133
138
|
end
|
|
134
139
|
|
|
135
140
|
private
|
|
136
141
|
|
|
137
142
|
def build_file_data(filename, file_size, compression_method,
|
|
138
|
-
dict_size, file_flags, mtime, crc32
|
|
143
|
+
dict_size, file_flags, mtime, crc32,
|
|
144
|
+
directory: false)
|
|
139
145
|
data = []
|
|
140
146
|
|
|
141
147
|
# File flags (VINT)
|
|
@@ -145,8 +151,9 @@ header_data: "")
|
|
|
145
151
|
data.concat(VINT.encode(file_size))
|
|
146
152
|
|
|
147
153
|
# Attributes (VINT) - ALWAYS present in RAR5.
|
|
148
|
-
# Unix host:
|
|
149
|
-
|
|
154
|
+
# Unix host: directory mode for directory entries,
|
|
155
|
+
# regular-file 0o100644 otherwise.
|
|
156
|
+
data.concat(VINT.encode(directory ? 0o040755 : 0o100644))
|
|
150
157
|
|
|
151
158
|
# mtime (optional) - only if FILE_HAS_MTIME flag is set
|
|
152
159
|
# RAR5 stores mtime as Unix timestamp (seconds since epoch)
|
|
@@ -121,6 +121,22 @@ module Omnizip
|
|
|
121
121
|
}
|
|
122
122
|
end
|
|
123
123
|
|
|
124
|
+
# Add a name-only directory entry (empty trees survive
|
|
125
|
+
# archive creation instead of vanishing)
|
|
126
|
+
#
|
|
127
|
+
# @param archive_path [String] Directory path inside the
|
|
128
|
+
# archive (trailing slash optional)
|
|
129
|
+
# @return [self]
|
|
130
|
+
def add_directory_entry(archive_path)
|
|
131
|
+
@files << {
|
|
132
|
+
input: nil,
|
|
133
|
+
archive: archive_path.chomp("/"),
|
|
134
|
+
mtime: Time.now,
|
|
135
|
+
directory: true,
|
|
136
|
+
}
|
|
137
|
+
self
|
|
138
|
+
end
|
|
139
|
+
|
|
124
140
|
# Add directory recursively
|
|
125
141
|
#
|
|
126
142
|
# @param dir_path [String] Directory path
|
|
@@ -227,11 +243,18 @@ module Omnizip
|
|
|
227
243
|
# @param files [Array<Hash>] Files to compress in solid mode
|
|
228
244
|
# @return [void]
|
|
229
245
|
def write_solid_block(io, files)
|
|
246
|
+
# Directory entries carry no data; write their headers
|
|
247
|
+
# separately so the solid stream indexes stay aligned
|
|
248
|
+
files.each do |file|
|
|
249
|
+
write_directory_file_entry(io, file) if file[:directory]
|
|
250
|
+
end
|
|
251
|
+
solid_files = files.reject { |f| f[:directory] }
|
|
252
|
+
|
|
230
253
|
# Create solid manager
|
|
231
254
|
manager = Solid::SolidManager.new(level: @options[:level])
|
|
232
255
|
|
|
233
256
|
# Add all files to solid stream
|
|
234
|
-
|
|
257
|
+
solid_files.each do |file|
|
|
235
258
|
data = File.binread(file[:input])
|
|
236
259
|
manager.add_file(file[:archive], data, mtime: file[:mtime],
|
|
237
260
|
stat: file[:stat])
|
|
@@ -242,7 +265,7 @@ module Omnizip
|
|
|
242
265
|
|
|
243
266
|
# Write each file header with references to the solid block
|
|
244
267
|
# In RAR5, all files share the same compressed data
|
|
245
|
-
|
|
268
|
+
solid_files.each_with_index do |file, idx|
|
|
246
269
|
file_info = result[:files][idx]
|
|
247
270
|
|
|
248
271
|
# Calculate CRC32 if needed (note: only for STORE in non-solid)
|
|
@@ -286,6 +309,8 @@ module Omnizip
|
|
|
286
309
|
# @param io [IO] Output stream
|
|
287
310
|
# @param file [Hash] File information
|
|
288
311
|
def write_file_entry(io, file)
|
|
312
|
+
return write_directory_file_entry(io, file) if file[:directory]
|
|
313
|
+
|
|
289
314
|
# Read file data
|
|
290
315
|
data = File.binread(file[:input])
|
|
291
316
|
|
|
@@ -350,6 +375,24 @@ module Omnizip
|
|
|
350
375
|
io.write(final_data)
|
|
351
376
|
end
|
|
352
377
|
|
|
378
|
+
# Write a directory entry: FileFlags directory bit, zero
|
|
379
|
+
# sizes, no data area
|
|
380
|
+
#
|
|
381
|
+
# @param io [IO] Output stream
|
|
382
|
+
# @param file [Hash] Entry with :archive name and :mtime
|
|
383
|
+
def write_directory_file_entry(io, file)
|
|
384
|
+
header = FileHeader.new(
|
|
385
|
+
filename: file[:archive],
|
|
386
|
+
file_size: 0,
|
|
387
|
+
compressed_size: 0,
|
|
388
|
+
compression_method: Compression::Store::METHOD,
|
|
389
|
+
dict_size: 0,
|
|
390
|
+
mtime: @options[:include_mtime] ? file[:mtime] : nil,
|
|
391
|
+
directory: true,
|
|
392
|
+
)
|
|
393
|
+
io.write(header.encode)
|
|
394
|
+
end
|
|
395
|
+
|
|
353
396
|
# Write End header
|
|
354
397
|
#
|
|
355
398
|
# @param io [IO] Output stream
|
|
@@ -93,6 +93,21 @@ module Omnizip
|
|
|
93
93
|
warn_unimplemented_options
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
# Add a name-only directory entry (empty trees survive
|
|
97
|
+
# archive creation instead of vanishing)
|
|
98
|
+
#
|
|
99
|
+
# @param archive_path [String] Directory path inside the
|
|
100
|
+
# archive (trailing slash optional)
|
|
101
|
+
# @return [self]
|
|
102
|
+
def add_directory_entry(archive_path)
|
|
103
|
+
@files << {
|
|
104
|
+
source: nil,
|
|
105
|
+
archive_path: archive_path.chomp("/"),
|
|
106
|
+
directory: true,
|
|
107
|
+
}
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
96
111
|
# Add file to archive
|
|
97
112
|
#
|
|
98
113
|
# @param file_path [String] Path to file
|
|
@@ -241,10 +256,17 @@ module Omnizip
|
|
|
241
256
|
(directory ? METHOD_STORE : select_compression_method(file_data))
|
|
242
257
|
compressed_data = compress_data(file_data, method)
|
|
243
258
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
259
|
+
if directory && file_path.nil?
|
|
260
|
+
# Name-only directory entry: no source to stat
|
|
261
|
+
file_attr = 0o040755
|
|
262
|
+
file_time = dos_time(Time.now)
|
|
263
|
+
data_crc = 0
|
|
264
|
+
else
|
|
265
|
+
stat = File.stat(file_path)
|
|
266
|
+
file_attr = directory ? 0o040755 : stat.mode
|
|
267
|
+
file_time = dos_time(stat.mtime)
|
|
268
|
+
data_crc = Zlib.crc32(file_data)
|
|
269
|
+
end
|
|
248
270
|
|
|
249
271
|
name_bytes = archive_path.encode("UTF-8").bytes
|
|
250
272
|
|
data/lib/omnizip/formats/rar.rb
CHANGED
|
@@ -174,8 +174,8 @@ module Omnizip
|
|
|
174
174
|
# @param archive_path [String] Path to corrupted RAR archive
|
|
175
175
|
# @param output_path [String] Path for repaired archive
|
|
176
176
|
# @return [ArchiveRepairer::RepairResult] Repair result
|
|
177
|
-
def repair(archive_path, output_path)
|
|
178
|
-
ArchiveRepairer.new.repair(archive_path, output_path)
|
|
177
|
+
def repair(archive_path, output_path, options = {})
|
|
178
|
+
ArchiveRepairer.new.repair(archive_path, output_path, options)
|
|
179
179
|
end
|
|
180
180
|
|
|
181
181
|
private
|
|
@@ -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.46
|
|
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
|