omnizip 0.3.40 → 0.3.42
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 +89 -0
- data/CONTEXT.md +63 -0
- data/lib/omnizip/commands/archive_create_command.rb +11 -23
- data/lib/omnizip/converter/zip_to_seven_zip_strategy.rb +21 -63
- data/lib/omnizip/formats/iso.rb +6 -7
- data/lib/omnizip/formats/rar/block_parser.rb +42 -6
- data/lib/omnizip/formats/rar/decompressor.rb +44 -8
- data/lib/omnizip/formats/rar/header.rb +18 -1
- data/lib/omnizip/formats/rar/models/rar_entry.rb +1 -1
- data/lib/omnizip/formats/rar/reader.rb +17 -22
- data/lib/omnizip/formats/rar/writer.rb +1 -1
- data/lib/omnizip/formats/rar.rb +25 -11
- data/lib/omnizip/formats/rar3/reader.rb +66 -314
- data/lib/omnizip/formats/rar5/reader.rb +80 -297
- data/lib/omnizip/formats/rar5/writer.rb +33 -180
- data/lib/omnizip/formats/zip/reader.rb +13 -1
- data/lib/omnizip/temp.rb +6 -3
- data/lib/omnizip/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 060755f6c69bca46bcb999d0d36471045ae3d13e260cfa3c73601a8d440b1634
|
|
4
|
+
data.tar.gz: b524b77c579c824009821ca5d1bdb47327cfc55f3ebd1dab4ea8574d1ec3c631
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 237f14e0c479844a473d1830e956882a0d250f6c733522bcd9f3d36b7ba10b474b4564902791aecd52948ad330b82bb0bb7e61dd97db607bf3dcd85ab3dc847b
|
|
7
|
+
data.tar.gz: cfe4637c8ad1223ccb8b9735fc1494d61937b636f3d79dd7fbd2c1f59f6f35eb38686152ee56732f0cb29cfbf3c12f7ddfb6dae5bd6c146b8d833fca5315a00a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.41] - 2026-08-29
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- One RAR parser. `Formats::Rar3::Reader` and `Formats::Rar5::Reader`
|
|
14
|
+
are adapters over the primary `Formats::Rar::Reader` — the only
|
|
15
|
+
parser verified against real WinRAR archives — replacing ~600
|
|
16
|
+
lines of duplicate parsing. Both legacy interfaces gain real-
|
|
17
|
+
archive fidelity for free (exact mtimes, backslash normalization,
|
|
18
|
+
directory detection, CRC fields). The legacy RAR5 writer — whose
|
|
19
|
+
output not even unrar could list (garbage names, 2038 dates) —
|
|
20
|
+
now bridges onto the spec-conformant primary writer; its archives
|
|
21
|
+
pass `unrar t`. The primary Header learned minimal archives (file
|
|
22
|
+
block first, no main header) for RAR4 and RAR5, restoring a legacy
|
|
23
|
+
capability in one place.
|
|
24
|
+
- Direct-seek extraction: the parser records each entry's
|
|
25
|
+
data_offset, so native extraction is one seek + read instead of
|
|
26
|
+
re-parsing the archive per entry. unrar-backed extraction spills
|
|
27
|
+
the archive once per archive path instead of extracting the whole
|
|
28
|
+
archive per entry (O(n^2) -> O(n)). Fixed a latent off-by-two in
|
|
29
|
+
the fallback block walk.
|
|
30
|
+
- Converter symmetry: zip -> 7z uses the native reader with the
|
|
31
|
+
same extract-to-temp/write-from-tree shape as 7z -> zip; internal
|
|
32
|
+
code no longer calls the rubyzip-compat `Omnizip::Zip::File` seam.
|
|
33
|
+
- `omnizip archive create` passes one flat options hash;
|
|
34
|
+
`Formats::Rar.create` applies the RAR4/RAR5-specific semantics
|
|
35
|
+
(the RAR4 writer's recovery guard no longer trips on explicit
|
|
36
|
+
false).
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
- Native-decode CRC guard referenced `Compression::DecompressionError`
|
|
40
|
+
which does not exist (the classes live under
|
|
41
|
+
`Compression::Dispatcher`); the NameError was silently masked by
|
|
42
|
+
the fallback rescue.
|
|
43
|
+
- Unicode-flagged RAR4 names can carry non-UTF-8 bytes;
|
|
44
|
+
String#tr raised and aborted the block walk under non-UTF-8
|
|
45
|
+
locales (CI runners). Names are scrubbed before normalization.
|
|
46
|
+
- Corrupt size fields drove unbounded reads: a garbage pack_size
|
|
47
|
+
attempted a multi-gigabyte read (NoMemoryError on CI) or an
|
|
48
|
+
overflowing seek (EINVAL on Linux, silently clamped on macOS).
|
|
49
|
+
Packed data is now seeked past — never allocated to skip — with
|
|
50
|
+
sizes bounds-checked against the archive length and corrupt skips
|
|
51
|
+
clamped to EOF so entries still list.
|
|
52
|
+
|
|
53
|
+
## [0.3.40] - 2026-08-29
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
- Every CLI command now works end-to-end (each was exercised against
|
|
57
|
+
real archives and its failure fixed):
|
|
58
|
+
- `omnizip list` printed "No algorithms registered." — all builtin
|
|
59
|
+
algorithms register lazily, so the available list was empty until
|
|
60
|
+
each was first used. `AlgorithmRegistry.available` now includes
|
|
61
|
+
the builtins.
|
|
62
|
+
- `omnizip convert a.7z a.zip` crashed on a fictional reader/writer
|
|
63
|
+
API inside the conversion strategy; rewritten on the real APIs and
|
|
64
|
+
round-trips byte-identically through unzip.
|
|
65
|
+
- `omnizip archive create x.rar ...` raised NoMethodError (a
|
|
66
|
+
module-level `Formats::Rar.create` never existed). It exists now
|
|
67
|
+
(RAR5 default, `version: 4` for RAR4); directory inputs work —
|
|
68
|
+
the stale "RAR5 does not support directories" restriction is gone
|
|
69
|
+
and trees archive under their own name like `.7z`, verified with
|
|
70
|
+
unrar.
|
|
71
|
+
- `omnizip archive metadata` opened every input as a ZIP: `.7z`
|
|
72
|
+
crashed with a ZIP parse error, and runs with no edit option
|
|
73
|
+
still printed "Metadata updated successfully". 7z now shows real
|
|
74
|
+
read-only metadata, unknown formats raise truthful errors, and
|
|
75
|
+
ZIPs default to show mode when nothing is being edited.
|
|
76
|
+
- `omnizip decompress archive.zip out/` — the behavior the
|
|
77
|
+
command's own help documents — failed with "Is a directory".
|
|
78
|
+
Archive inputs (zip/7z/tar/rar/cpio/iso) and directory outputs
|
|
79
|
+
now extract through the convenience layer, content-verified with
|
|
80
|
+
diff for zip, 7z, rar, and tar.
|
|
81
|
+
- Deflate64.metadata returned a raw Hash, crashing the CLI algorithm
|
|
82
|
+
table; it returns `Models::AlgorithmMetadata` like every other
|
|
83
|
+
algorithm.
|
|
84
|
+
- Guides referenced a nonexistent `:zstd` algorithm-registry key;
|
|
85
|
+
corrected to `:zstandard` everywhere (the registry key differs from
|
|
86
|
+
RPM's `:zstd` compression type).
|
|
87
|
+
|
|
88
|
+
### Added
|
|
89
|
+
- `Omnizip::Buffer` 7z support: `create(:seven_zip)` /
|
|
90
|
+
`create_from_hash(hash, :seven_zip)`, `open`, `extract_to_memory`,
|
|
91
|
+
and `MemoryExtractor` all handle 7z through a temporary-file bridge
|
|
92
|
+
(the format has no in-memory stream implementation), including
|
|
93
|
+
explicit directory entries via a new
|
|
94
|
+
`SevenZip::Writer#add_directory_entry`. Format auto-detection fixed
|
|
95
|
+
to compare binary magic literals — UTF-8 literals never matched
|
|
96
|
+
binary data, so 7z buffers raised "Unknown archive format".
|
|
97
|
+
- README gains an In-Memory Archives section (examples run verbatim).
|
|
98
|
+
|
|
10
99
|
## [0.3.39] - 2026-08-28
|
|
11
100
|
|
|
12
101
|
### Fixed
|
data/CONTEXT.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Domain Glossary
|
|
2
|
+
|
|
3
|
+
Names for the concepts that keep coming up in omnizip. Use these
|
|
4
|
+
words in code, docs, and reviews; when a term sharpens, update it
|
|
5
|
+
here.
|
|
6
|
+
|
|
7
|
+
## Archive access
|
|
8
|
+
|
|
9
|
+
- **Archive facade** — `Omnizip::Archive`: the format-neutral entry
|
|
10
|
+
point. `create` yields a Builder (add_file/add_directory/add_data),
|
|
11
|
+
`open` yields a ReaderSession (entries/read/extract/extract_all).
|
|
12
|
+
- **ArchiveHandler** — per-format adapter registered in the handler
|
|
13
|
+
registry (create/extract_to/list/read_entry) plus its extension
|
|
14
|
+
route. The facade and the convenience layer dispatch through
|
|
15
|
+
handlers, never through format classes directly.
|
|
16
|
+
- **Extension route** — mapping from a file extension to a format
|
|
17
|
+
symbol. Writable routes (`ARCHIVE_FORMAT_EXTENSIONS`) may create
|
|
18
|
+
archives; read routes (`READ_ARCHIVE_FORMAT_EXTENSIONS`) only
|
|
19
|
+
extract/list/read. `.rar`, `.cpio`, `.iso` are read-only routes;
|
|
20
|
+
creating them raises truthfully.
|
|
21
|
+
|
|
22
|
+
## Reading and decoding
|
|
23
|
+
|
|
24
|
+
- **Native decode** — decompression implemented in Ruby inside
|
|
25
|
+
omnizip. RAR native decode is **CRC-gated**: the stored header CRC
|
|
26
|
+
must match the decoded bytes or the result is discarded.
|
|
27
|
+
- **External fallback** — delegating to an external tool (the unrar
|
|
28
|
+
command) when native decode cannot be trusted. RAR extraction
|
|
29
|
+
spills the archive once per archive path (the **extraction spill
|
|
30
|
+
cache**) and copies entries out of it.
|
|
31
|
+
|
|
32
|
+
## Writing and interop
|
|
33
|
+
|
|
34
|
+
- **Interop-verified** — output accepted by the official tool for
|
|
35
|
+
the format (unrar for RAR, 7zz for 7z/ISO, unzip for ZIP), checked
|
|
36
|
+
with `test` plus a byte-identical extraction round-trip.
|
|
37
|
+
- **Omnizip-internal codec** — a compression stream only omnizip can
|
|
38
|
+
decode (RAR methods above :store). Archives carrying them must be
|
|
39
|
+
labeled honestly; official tools fail loudly, never silently.
|
|
40
|
+
- **STORE** — the uncompressed method; the interoperability floor
|
|
41
|
+
for every archive writer in this library.
|
|
42
|
+
|
|
43
|
+
## Seams
|
|
44
|
+
|
|
45
|
+
- **Compat seam** — `Omnizip::Zip` (aliased into the global `Zip`
|
|
46
|
+
namespace by `omnizip/rubyzip_compat`): a rubyzip-compatible
|
|
47
|
+
layer for library consumers migrating off rubyzip. Internal code
|
|
48
|
+
must use the native `Formats::Zip` tree instead; the Metadata
|
|
49
|
+
subsystem is its one deliberate in-house consumer (in-place
|
|
50
|
+
central-directory editing).
|
|
51
|
+
- **Bridge** — an adapter that carries a file-based implementation
|
|
52
|
+
across to a non-file interface, spilling through a temporary file
|
|
53
|
+
(the RAR3/RAR5 legacy readers, `Buffer::SevenZipBridge`).
|
|
54
|
+
- **Primary parser** — the one parser per format family verified
|
|
55
|
+
against real archives (e.g. `Formats::Rar::Reader`). Legacy
|
|
56
|
+
interfaces are adapters over primary parsers, never parallel
|
|
57
|
+
implementations.
|
|
58
|
+
|
|
59
|
+
## In memory
|
|
60
|
+
|
|
61
|
+
- **Buffer** — `Omnizip::Buffer`: in-memory archive create/read for
|
|
62
|
+
ZIP (stream-based) and 7z (through the bridge). Format detection
|
|
63
|
+
compares binary magic literals.
|
|
@@ -120,35 +120,23 @@ module Omnizip
|
|
|
120
120
|
|
|
121
121
|
start_time = Time.now
|
|
122
122
|
|
|
123
|
+
# Format-agnostic options; Formats::Rar applies the
|
|
124
|
+
# version-specific semantics
|
|
123
125
|
writer_opts = {
|
|
124
126
|
version: version,
|
|
125
127
|
compression: compression,
|
|
126
128
|
level: level,
|
|
127
129
|
include_mtime: include_mtime,
|
|
128
130
|
include_crc32: include_crc32,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
writer_opts[:volume_naming] = volume_naming
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
# Add encryption options for RAR5
|
|
142
|
-
if version == 5 && password
|
|
143
|
-
writer_opts[:password] = password
|
|
144
|
-
writer_opts[:kdf_iterations] = kdf_iterations
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# Add recovery options for RAR5
|
|
148
|
-
if version == 5 && recovery
|
|
149
|
-
writer_opts[:recovery] = true
|
|
150
|
-
writer_opts[:recovery_percent] = recovery_percent
|
|
151
|
-
end
|
|
131
|
+
solid: solid,
|
|
132
|
+
multi_volume: multi_volume,
|
|
133
|
+
volume_size: volume_size,
|
|
134
|
+
volume_naming: volume_naming,
|
|
135
|
+
password: password,
|
|
136
|
+
kdf_iterations: kdf_iterations,
|
|
137
|
+
recovery: recovery,
|
|
138
|
+
recovery_percent: recovery_percent,
|
|
139
|
+
}.compact
|
|
152
140
|
|
|
153
141
|
result_files = Omnizip::Formats::Rar.create(output_file,
|
|
154
142
|
writer_opts) do |rar|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
3
5
|
module Omnizip
|
|
4
6
|
module Converter
|
|
5
7
|
# Convert ZIP archives to 7-Zip format
|
|
@@ -10,14 +12,20 @@ module Omnizip
|
|
|
10
12
|
start_time = Time.now
|
|
11
13
|
entry_count = 0
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
Dir.mktmpdir("omnizip_convert_zip") do |tmp|
|
|
16
|
+
# Extract the ZIP through the native reader; directories are
|
|
17
|
+
# implied by extracted file paths
|
|
18
|
+
Omnizip::Formats::Zip.extract(source_path, tmp)
|
|
19
|
+
|
|
20
|
+
writer = Omnizip::Formats::SevenZip::Writer.new(target_path,
|
|
21
|
+
writer_options)
|
|
22
|
+
Dir.glob(File.join(tmp, "**", "*")).each do |path|
|
|
23
|
+
next if File.directory?(path)
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
entry_count += 1
|
|
26
|
+
writer.add_file(path, path.delete_prefix("#{tmp}/"))
|
|
27
|
+
end
|
|
28
|
+
writer.write
|
|
21
29
|
end
|
|
22
30
|
|
|
23
31
|
create_result(start_time, entry_count)
|
|
@@ -45,62 +53,12 @@ module Omnizip
|
|
|
45
53
|
|
|
46
54
|
private
|
|
47
55
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
directory: entry.directory?,
|
|
55
|
-
mtime: entry.time,
|
|
56
|
-
content: nil,
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
unless entry.directory?
|
|
60
|
-
data[:content] = zip.get_input_stream(entry)
|
|
61
|
-
|
|
62
|
-
if options.preserve_metadata && entry.unix_perms.positive?
|
|
63
|
-
data[:unix_perms] = entry.unix_perms
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
entries << data
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
entries
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def create_seven_zip(entries)
|
|
74
|
-
writer = Omnizip::Formats::SevenZip::Writer.new(target_path)
|
|
75
|
-
|
|
76
|
-
# Set compression options
|
|
77
|
-
compression = options.compression || :lzma2
|
|
78
|
-
level = options.compression_level || 5
|
|
79
|
-
options.solid.nil? || options.solid
|
|
80
|
-
|
|
81
|
-
# Add each entry
|
|
82
|
-
entries.each do |entry_data|
|
|
83
|
-
if entry_data[:directory]
|
|
84
|
-
# 7z doesn't have explicit directory entries
|
|
85
|
-
# Directories are implied by file paths
|
|
86
|
-
next
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
writer.add_data(
|
|
90
|
-
entry_data[:name],
|
|
91
|
-
entry_data[:content],
|
|
92
|
-
algorithm: compression,
|
|
93
|
-
level: level,
|
|
94
|
-
)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Apply filter if specified
|
|
98
|
-
if options.filter
|
|
99
|
-
add_warning("Filters not yet supported in 7z conversion")
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Write the archive
|
|
103
|
-
writer.write
|
|
56
|
+
# SevenZip::Writer takes algorithm/level/solid as keywords
|
|
57
|
+
def writer_options
|
|
58
|
+
{
|
|
59
|
+
algorithm: options.compression || :lzma2,
|
|
60
|
+
level: options.compression_level || 5,
|
|
61
|
+
}
|
|
104
62
|
end
|
|
105
63
|
end
|
|
106
64
|
end
|
data/lib/omnizip/formats/iso.rb
CHANGED
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
module Omnizip
|
|
4
4
|
module Formats
|
|
5
5
|
# ISO 9660 CD-ROM filesystem format support
|
|
6
|
-
# Provides read-only access to ISO images
|
|
7
6
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# -
|
|
12
|
-
#
|
|
13
|
-
#
|
|
7
|
+
# Reads and writes ISO 9660 images: Primary Volume Descriptor
|
|
8
|
+
# parsing, directory traversal, file extraction, and image
|
|
9
|
+
# creation whose directory records point at allocated extents
|
|
10
|
+
# (verified against 7-Zip). Rock Ridge and Joliet are NOT
|
|
11
|
+
# implemented — the writer's options stay false rather than
|
|
12
|
+
# advertising extensions readers would misparse.
|
|
14
13
|
module Iso
|
|
15
14
|
# Nested classes - autoloaded
|
|
16
15
|
autoload :Reader, "omnizip/formats/iso/reader"
|
|
@@ -45,6 +45,7 @@ module Omnizip
|
|
|
45
45
|
# @return [Models::RarEntry, nil] Parsed entry or nil
|
|
46
46
|
def parse_rar4_file_block(io)
|
|
47
47
|
entry = Models::RarEntry.new
|
|
48
|
+
block_start = io.pos
|
|
48
49
|
|
|
49
50
|
# Read block header
|
|
50
51
|
read_uint16(io)
|
|
@@ -73,12 +74,23 @@ module Omnizip
|
|
|
73
74
|
unpack_size |= (high_unpack_size << 32)
|
|
74
75
|
end
|
|
75
76
|
|
|
77
|
+
# Sanity bounds: corrupt headers must not drive huge reads.
|
|
78
|
+
# head_size can never exceed the bytes left from the block
|
|
79
|
+
# start, and the name must fit inside the header.
|
|
80
|
+
if head_size > io.size - block_start ||
|
|
81
|
+
name_size + 32 > head_size
|
|
82
|
+
return nil
|
|
83
|
+
end
|
|
84
|
+
|
|
76
85
|
# Read file name
|
|
77
86
|
name_bytes = io.read(name_size)
|
|
78
87
|
entry.name = decode_filename(name_bytes, head_flags)
|
|
79
88
|
# RAR4 archives store DOS-style backslash separators;
|
|
80
|
-
# normalize to forward slashes like unrar on Unix.
|
|
81
|
-
|
|
89
|
+
# normalize to forward slashes like unrar on Unix. Scrub
|
|
90
|
+
# first: unicode-flagged names may carry bytes that are not
|
|
91
|
+
# valid UTF-8, and String#tr raises on them under any
|
|
92
|
+
# locale.
|
|
93
|
+
entry.name = entry.name.scrub.tr("\\", "/")
|
|
82
94
|
|
|
83
95
|
# Set entry properties
|
|
84
96
|
entry.size = unpack_size
|
|
@@ -111,7 +123,14 @@ module Omnizip
|
|
|
111
123
|
remaining = head_size - (name_size + 32)
|
|
112
124
|
remaining -= 8 if head_flags.anybits?(FILE_LARGE)
|
|
113
125
|
io.read(remaining) if remaining.positive?
|
|
114
|
-
|
|
126
|
+
# Record where the packed data starts so extraction can seek
|
|
127
|
+
# straight to it instead of re-parsing the archive
|
|
128
|
+
entry.data_offset = io.pos
|
|
129
|
+
# Seek past the data: never allocate the packed bytes just
|
|
130
|
+
# to skip them. A corrupt pack_size (bigger than the file)
|
|
131
|
+
# would overflow the seek offset on Linux — clamp to EOF so
|
|
132
|
+
# the entry still lists.
|
|
133
|
+
skip_past(io, pack_size)
|
|
115
134
|
|
|
116
135
|
entry
|
|
117
136
|
end
|
|
@@ -135,6 +154,10 @@ module Omnizip
|
|
|
135
154
|
extra_size = header_flags.anybits?(RAR5_FLAG_EXTRA_AREA) ? read_vint(io) : 0
|
|
136
155
|
data_size = header_flags.anybits?(RAR5_FLAG_DATA_AREA) ? read_vint(io) : 0
|
|
137
156
|
|
|
157
|
+
# Sanity bounds: corrupt headers must not drive huge reads
|
|
158
|
+
remaining_bytes = io.size - io.pos
|
|
159
|
+
return nil if extra_size > remaining_bytes || data_size > remaining_bytes
|
|
160
|
+
|
|
138
161
|
# Read file header
|
|
139
162
|
file_flags = read_vint(io)
|
|
140
163
|
unpack_size = read_vint(io)
|
|
@@ -155,7 +178,7 @@ module Omnizip
|
|
|
155
178
|
|
|
156
179
|
# Read file name
|
|
157
180
|
name_bytes = io.read(name_length)
|
|
158
|
-
entry.name = name_bytes.force_encoding("UTF-8")
|
|
181
|
+
entry.name = name_bytes.to_s.scrub.force_encoding("UTF-8")
|
|
159
182
|
|
|
160
183
|
# Set entry properties
|
|
161
184
|
entry.size = unpack_size
|
|
@@ -168,13 +191,26 @@ module Omnizip
|
|
|
168
191
|
entry.is_dir = file_flags.anybits?(RAR5_FLAG_IS_DIR)
|
|
169
192
|
entry.version = 5
|
|
170
193
|
|
|
171
|
-
# Skip extra area, then compressed data
|
|
194
|
+
# Skip extra area, then compressed data; record the data
|
|
195
|
+
# start for direct-seek extraction. The data is seeked past,
|
|
196
|
+
# never read: corrupt sizes must not drive huge allocations.
|
|
172
197
|
io.read(extra_size) if extra_size.positive?
|
|
173
|
-
|
|
198
|
+
entry.data_offset = io.pos
|
|
199
|
+
skip_past(io, data_size)
|
|
174
200
|
|
|
175
201
|
entry
|
|
176
202
|
end
|
|
177
203
|
|
|
204
|
+
# Skip forward without allocating: clamp corrupt sizes to EOF
|
|
205
|
+
# so an overflowing seek (EINVAL on Linux) cannot abort the
|
|
206
|
+
# block walk
|
|
207
|
+
def skip_past(io, bytes)
|
|
208
|
+
return if bytes <= 0
|
|
209
|
+
|
|
210
|
+
remaining = io.size - io.pos
|
|
211
|
+
io.seek([bytes, remaining].min, ::IO::SEEK_CUR)
|
|
212
|
+
end
|
|
213
|
+
|
|
178
214
|
# Decode filename from bytes
|
|
179
215
|
#
|
|
180
216
|
# @param bytes [String] Raw filename bytes
|
|
@@ -236,18 +236,54 @@ module Omnizip
|
|
|
236
236
|
end
|
|
237
237
|
|
|
238
238
|
# Extract entry with command
|
|
239
|
+
#
|
|
240
|
+
# unrar cannot extract a single entry without walking the
|
|
241
|
+
# archive, so the archive spills to a cache directory ONCE
|
|
242
|
+
# per archive path; subsequent extractions copy out of the
|
|
243
|
+
# cache instead of re-running unrar over the whole file.
|
|
239
244
|
def extract_entry_with_command(archive_path, entry_name,
|
|
240
245
|
output_path, password)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
246
|
+
cache_key = begin
|
|
247
|
+
File.realpath(archive_path)
|
|
248
|
+
rescue StandardError
|
|
249
|
+
archive_path
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
cached_dir = extract_cache[cache_key]
|
|
253
|
+
unless cached_dir
|
|
254
|
+
cached_dir = Dir.mktmpdir("omnizip_unrar")
|
|
255
|
+
cmd = build_extract_command(archive_path, cached_dir, password)
|
|
256
|
+
unless system(*cmd)
|
|
257
|
+
FileUtils.rm_rf(cached_dir)
|
|
258
|
+
raise "Command entry extraction failed: #{entry_name}"
|
|
259
|
+
end
|
|
260
|
+
extract_cache[cache_key] = cached_dir
|
|
261
|
+
install_exit_sweeper
|
|
245
262
|
end
|
|
246
263
|
|
|
247
|
-
source = File.join(
|
|
248
|
-
FileUtils.
|
|
249
|
-
|
|
250
|
-
|
|
264
|
+
source = File.join(cached_dir, entry_name)
|
|
265
|
+
FileUtils.cp(source, output_path) if File.exist?(source)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Extracted-archive cache: archive path => spill directory.
|
|
269
|
+
# Cleared by .clear_extract_cache!; an at_exit sweeper
|
|
270
|
+
# reclaims everything at normal process termination.
|
|
271
|
+
def extract_cache
|
|
272
|
+
@extract_cache ||= {}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Drop the extraction cache and remove its directories
|
|
276
|
+
def clear_extract_cache!
|
|
277
|
+
extract_cache.each_value { |dir| FileUtils.rm_rf(dir) }
|
|
278
|
+
extract_cache.clear
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Reclaim spill directories when the process exits normally
|
|
282
|
+
def install_exit_sweeper
|
|
283
|
+
return if @exit_sweeper_installed
|
|
284
|
+
|
|
285
|
+
at_exit { clear_extract_cache! }
|
|
286
|
+
@exit_sweeper_installed = true
|
|
251
287
|
end
|
|
252
288
|
|
|
253
289
|
# Build extract command
|
|
@@ -85,7 +85,9 @@ module Omnizip
|
|
|
85
85
|
# Parse RAR4 header
|
|
86
86
|
#
|
|
87
87
|
# The 7-byte signature consumed by #parse IS the marker
|
|
88
|
-
# block, so the main header follows immediately.
|
|
88
|
+
# block, so the main header follows immediately. Minimal
|
|
89
|
+
# archives skip the main header and start with a file block —
|
|
90
|
+
# accept those by rewinding to the block.
|
|
89
91
|
#
|
|
90
92
|
# @param io [IO] Input stream
|
|
91
93
|
def parse_rar4_header(io)
|
|
@@ -94,6 +96,12 @@ module Omnizip
|
|
|
94
96
|
head_flags = read_uint16(io)
|
|
95
97
|
head_size = read_uint16(io)
|
|
96
98
|
|
|
99
|
+
if head_type == BLOCK_FILE
|
|
100
|
+
io.seek(-7, ::IO::SEEK_CUR)
|
|
101
|
+
@flags = 0
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
|
|
97
105
|
unless head_type == BLOCK_ARCHIVE
|
|
98
106
|
raise "Expected archive block, got 0x#{head_type.to_s(16)}"
|
|
99
107
|
end
|
|
@@ -117,11 +125,20 @@ module Omnizip
|
|
|
117
125
|
# RAR5 uses variable-length integer encoding. Layout per the
|
|
118
126
|
# RAR 5.0 format: HeaderCRC32(4), Header size(vint), Header
|
|
119
127
|
# type(vint), Header flags(vint), then type-specific fields.
|
|
128
|
+
header_start = io.pos
|
|
120
129
|
read_uint32(io)
|
|
121
130
|
read_vint(io) # header size (redundant: areas carry their own)
|
|
122
131
|
header_type = read_vint(io)
|
|
123
132
|
header_flags = read_vint(io)
|
|
124
133
|
|
|
134
|
+
# Minimal archives skip the main header and start with a
|
|
135
|
+
# file header (type 2) — rewind so block iteration reads it
|
|
136
|
+
if header_type == RAR5_HEADER_FILE
|
|
137
|
+
io.seek(header_start, ::IO::SEEK_SET)
|
|
138
|
+
@flags = 0
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
125
142
|
unless header_type == RAR5_HEADER_MAIN
|
|
126
143
|
raise "Expected main header, got #{header_type}"
|
|
127
144
|
end
|
|
@@ -10,7 +10,7 @@ module Omnizip
|
|
|
10
10
|
attr_accessor :name, :size, :compressed_size, :crc, :is_dir,
|
|
11
11
|
:host_os, :mtime, :attributes, :method,
|
|
12
12
|
:version, :flags, :volume_index, :split_before,
|
|
13
|
-
:split_after, :encrypted
|
|
13
|
+
:split_after, :encrypted, :data_offset
|
|
14
14
|
|
|
15
15
|
# Initialize RAR entry
|
|
16
16
|
def initialize
|
|
@@ -268,7 +268,7 @@ module Omnizip
|
|
|
268
268
|
# fall back to unrar.
|
|
269
269
|
data = output.string
|
|
270
270
|
if entry.crc && Zlib.crc32(data) != entry.crc
|
|
271
|
-
raise Compression::DecompressionError,
|
|
271
|
+
raise Compression::Dispatcher::DecompressionError,
|
|
272
272
|
"CRC mismatch for #{entry.name}"
|
|
273
273
|
end
|
|
274
274
|
|
|
@@ -296,19 +296,26 @@ module Omnizip
|
|
|
296
296
|
# @param entry [Models::RarEntry] File entry
|
|
297
297
|
# @return [StringIO] Compressed data stream
|
|
298
298
|
def read_compressed_data(entry)
|
|
299
|
-
#
|
|
299
|
+
# The parser records each entry's data offset, so native
|
|
300
|
+
# extraction is a single seek + read
|
|
301
|
+
if entry.data_offset && entry.compressed_size
|
|
302
|
+
File.open(@file_path, "rb") do |io|
|
|
303
|
+
io.seek(entry.data_offset)
|
|
304
|
+
return StringIO.new(io.read(entry.compressed_size) || "")
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Fallback for entries parsed without offsets (e.g. by older
|
|
309
|
+
# parsers): walk the blocks to locate the entry
|
|
300
310
|
File.open(@file_path, "rb") do |io|
|
|
301
|
-
# Skip signature and headers
|
|
302
311
|
Header.read(io)
|
|
303
312
|
|
|
304
|
-
# Parse file blocks to find our entry
|
|
305
313
|
parser = BlockParser.new(@header.version)
|
|
306
314
|
|
|
307
315
|
loop do
|
|
308
316
|
block_start = io.pos
|
|
309
317
|
break if io.eof?
|
|
310
318
|
|
|
311
|
-
# Peek at block type
|
|
312
319
|
crc_bytes = io.read(2)
|
|
313
320
|
break unless crc_bytes
|
|
314
321
|
|
|
@@ -317,45 +324,33 @@ module Omnizip
|
|
|
317
324
|
|
|
318
325
|
head_type = type_byte.ord
|
|
319
326
|
|
|
320
|
-
# If end block, stop
|
|
321
327
|
break if head_type == BLOCK_ENDARC
|
|
322
328
|
|
|
323
|
-
# Reset to block start
|
|
324
329
|
io.seek(block_start)
|
|
325
330
|
|
|
326
|
-
# If not a file block, read and skip it
|
|
327
331
|
unless head_type == BLOCK_FILE
|
|
328
|
-
# Read header
|
|
329
332
|
io.read(2) # CRC
|
|
330
333
|
io.read(1) # TYPE
|
|
331
|
-
io.read(2)
|
|
334
|
+
io.read(2) # FLAGS
|
|
332
335
|
size = io.read(2)&.unpack1("v") || 0
|
|
333
336
|
|
|
334
|
-
#
|
|
335
|
-
remaining = size -
|
|
337
|
+
# 7 bytes consumed (CRC/TYPE/FLAGS/SIZE); skip the rest
|
|
338
|
+
remaining = size - 7
|
|
336
339
|
io.read(remaining) if remaining.positive?
|
|
337
340
|
next
|
|
338
341
|
end
|
|
339
342
|
|
|
340
|
-
# Parse this file block
|
|
341
343
|
test_entry = parser.parse_file_block(io)
|
|
342
344
|
|
|
343
|
-
# Check if this is our entry
|
|
344
345
|
if test_entry && test_entry.name == entry.name
|
|
345
|
-
|
|
346
|
-
# So we need to back up and read it
|
|
347
|
-
data_end = io.pos
|
|
348
|
-
data_start = data_end - entry.compressed_size
|
|
346
|
+
data_start = io.pos - entry.compressed_size
|
|
349
347
|
|
|
350
348
|
io.seek(data_start)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
return StringIO.new(compressed)
|
|
349
|
+
return StringIO.new(io.read(entry.compressed_size) || "")
|
|
354
350
|
end
|
|
355
351
|
end
|
|
356
352
|
end
|
|
357
353
|
|
|
358
|
-
# If we didn't find it, return empty
|
|
359
354
|
StringIO.new("")
|
|
360
355
|
end
|
|
361
356
|
end
|
|
@@ -169,7 +169,7 @@ module Omnizip
|
|
|
169
169
|
warn "RAR4 writer ignores volume_size: not implemented " \
|
|
170
170
|
"(no corresponding flag is written)"
|
|
171
171
|
end
|
|
172
|
-
return unless @options[:recovery]
|
|
172
|
+
return unless @options[:recovery]&.to_i&.positive?
|
|
173
173
|
|
|
174
174
|
warn "RAR4 writer ignores recovery: not implemented " \
|
|
175
175
|
"(no corresponding flag is written)"
|