rufio 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +74 -3
- data/README_ja.md +71 -2
- data/Rakefile +14 -1
- data/bin/rufio +31 -2
- data/docs/fast_zip_design_ja.md +167 -0
- data/info/help.md +2 -0
- data/info/keybindings.md +3 -1
- data/info/welcome.md +1 -1
- data/lib/rufio/application.rb +12 -1
- data/lib/rufio/archive_controller.rb +211 -0
- data/lib/rufio/background_command_executor.rb +9 -0
- data/lib/rufio/background_status.rb +49 -0
- data/lib/rufio/bookmark_controller.rb +38 -121
- data/lib/rufio/bookmark_manager.rb +77 -331
- data/lib/rufio/command_completion.rb +6 -1
- data/lib/rufio/command_history.rb +5 -2
- data/lib/rufio/command_mode.rb +40 -134
- data/lib/rufio/command_mode_ui.rb +4 -42
- data/lib/rufio/config.rb +88 -136
- data/lib/rufio/config_loader.rb +3 -11
- data/lib/rufio/dialog_renderer.rb +64 -152
- data/lib/rufio/dialog_support.rb +69 -0
- data/lib/rufio/document_text_extractor.rb +167 -0
- data/lib/rufio/file_operation_controller.rb +49 -222
- data/lib/rufio/file_preview.rb +46 -2
- data/lib/rufio/global_search.rb +121 -9
- data/lib/rufio/help_menu.rb +8 -2
- data/lib/rufio/interpreter_resolver.rb +6 -11
- data/lib/rufio/job_manager.rb +2 -5
- data/lib/rufio/key_input.rb +77 -0
- data/lib/rufio/keybind_handler.rb +81 -115
- data/lib/rufio/local_script_scanner.rb +6 -18
- data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
- data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
- data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
- data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
- data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
- data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
- data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
- data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
- data/lib/rufio/native_content_search.rb +153 -0
- data/lib/rufio/native_scanner.rb +13 -68
- data/lib/rufio/native_scanner_zig.rb +106 -56
- data/lib/rufio/native_zip.rb +121 -0
- data/lib/rufio/navigation_controller.rb +18 -20
- data/lib/rufio/screen.rb +2 -12
- data/lib/rufio/script_executor.rb +6 -73
- data/lib/rufio/script_file.rb +44 -0
- data/lib/rufio/script_path_manager.rb +9 -43
- data/lib/rufio/script_runner.rb +13 -50
- data/lib/rufio/search_controller.rb +1 -1
- data/lib/rufio/shell_command_completion.rb +1 -5
- data/lib/rufio/shell_init.rb +90 -0
- data/lib/rufio/task_status.rb +5 -1
- data/lib/rufio/terminal_ui.rb +55 -294
- data/lib/rufio/text_utils.rb +26 -3
- data/lib/rufio/ui_renderer.rb +63 -40
- data/lib/rufio/version.rb +1 -1
- data/lib/rufio/zip_archive.rb +314 -0
- data/lib/rufio/zip_extractor.rb +346 -0
- data/lib/rufio/zip_writer.rb +268 -0
- data/lib/rufio/zoxide_integration.rb +5 -43
- data/lib/rufio.rb +5 -0
- data/lib_zig/rufio_native/Makefile +54 -17
- data/lib_zig/rufio_native/bench.sh +36 -0
- data/lib_zig/rufio_native/build.zig +32 -35
- data/lib_zig/rufio_native/src/cli.zig +99 -0
- data/lib_zig/rufio_native/src/main.zig +187 -83
- data/lib_zig/rufio_native/src/os.zig +889 -0
- data/lib_zig/rufio_native/src/search.zig +919 -0
- data/lib_zig/rufio_native/src/search_test.zig +88 -0
- data/lib_zig/rufio_native/src/zip.zig +482 -0
- metadata +125 -3
- data/lib/rufio/native/rufio_zig.bundle +0 -0
data/lib/rufio/ui_renderer.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'text_utils'
|
|
4
|
+
require_relative 'background_status'
|
|
4
5
|
|
|
5
6
|
module Rufio
|
|
6
7
|
# UIレンダリング専用クラス
|
|
@@ -10,8 +11,12 @@ module Rufio
|
|
|
10
11
|
# - シンタックスハイライト(bat 連携)を担当
|
|
11
12
|
class UIRenderer
|
|
12
13
|
# Layout constants
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
PATH_LINE = 0 # 現在のパス(Y=0)
|
|
15
|
+
BOOKMARK_LINE = 1 # ブックマーク一覧(Y=1)
|
|
16
|
+
HEADER_FOOTER_MARGIN = 3 # パス(1行) + ブックマーク(1行) + モードタブ(1行) 分のマージン
|
|
17
|
+
CONTENT_START_LINE = 2 # コンテンツ開始行
|
|
18
|
+
JOB_HEADER_FOOTER_MARGIN = 2 # ジョブモード: ジョブフッタ(1行) + モードタブ(1行)
|
|
19
|
+
JOB_CONTENT_START_LINE = 1 # ジョブモードのコンテンツ開始行
|
|
15
20
|
CURSOR_OFFSET = 1 # カーソル位置のオフセット
|
|
16
21
|
ICON_SIZE_PADDING = 12 # アイコン、選択マーク、サイズ情報分
|
|
17
22
|
BOOKMARK_HIGHLIGHT_DURATION = 0.5 # ブックマークハイライト表示時間(秒)
|
|
@@ -24,7 +29,7 @@ module Rufio
|
|
|
24
29
|
|
|
25
30
|
attr_accessor :keybind_handler, :directory_listing, :file_preview
|
|
26
31
|
attr_accessor :background_executor, :test_mode
|
|
27
|
-
|
|
32
|
+
attr_reader :background_status
|
|
28
33
|
attr_reader :tab_mode_manager, :highlight_updated, :left_panel_ratio
|
|
29
34
|
|
|
30
35
|
def preview_enabled?
|
|
@@ -64,9 +69,8 @@ module Rufio
|
|
|
64
69
|
@highlighted_bookmark_index = nil
|
|
65
70
|
@highlighted_bookmark_time = nil
|
|
66
71
|
|
|
67
|
-
#
|
|
68
|
-
@
|
|
69
|
-
@completion_lamp_time = nil
|
|
72
|
+
# 通知とフッターで同じ完了状態を参照する。
|
|
73
|
+
@background_status = BackgroundStatus.new
|
|
70
74
|
|
|
71
75
|
# Tab mode manager
|
|
72
76
|
@tab_mode_manager = TabModeManager.new
|
|
@@ -124,17 +128,18 @@ module Rufio
|
|
|
124
128
|
# @param job_mode_instance [JobMode, nil] ジョブモードインスタンス
|
|
125
129
|
def draw_screen(screen, notification_message: nil, fps: nil,
|
|
126
130
|
in_job_mode: false, job_manager: nil, job_mode_instance: nil)
|
|
127
|
-
content_height = @screen_height - HEADER_FOOTER_MARGIN
|
|
128
|
-
|
|
129
131
|
if in_job_mode
|
|
130
|
-
# ジョブモード: フッタ y=0(上部)、コンテンツ y=1〜h-2、統合行 y=h-1
|
|
132
|
+
# ジョブモード: フッタ y=0(上部)、コンテンツ y=1〜h-2、統合行 y=h-1(下部・パス付き)
|
|
133
|
+
content_height = @screen_height - JOB_HEADER_FOOTER_MARGIN
|
|
131
134
|
log_mode = job_mode_instance&.log_mode? || false
|
|
132
135
|
draw_job_footer_to_buffer(screen, 0, job_manager, log_mode: log_mode)
|
|
133
136
|
draw_job_list_to_buffer(screen, content_height, job_manager, job_mode_instance)
|
|
134
137
|
draw_mode_tabs_to_buffer(screen, @screen_height - 1)
|
|
135
138
|
else
|
|
136
|
-
# 通常モード:
|
|
137
|
-
|
|
139
|
+
# 通常モード: パス y=0、ブックマーク y=1、コンテンツ y=2〜h-2、モードタブ y=h-1(下部)
|
|
140
|
+
content_height = @screen_height - HEADER_FOOTER_MARGIN
|
|
141
|
+
draw_path_line_to_buffer(screen, PATH_LINE)
|
|
142
|
+
draw_footer_to_buffer(screen, BOOKMARK_LINE, fps)
|
|
138
143
|
|
|
139
144
|
entries = get_display_entries
|
|
140
145
|
selected_entry = entries[@keybind_handler.current_index]
|
|
@@ -145,7 +150,7 @@ module Rufio
|
|
|
145
150
|
draw_directory_list_to_buffer(screen, entries, left_width, content_height)
|
|
146
151
|
draw_file_preview_to_buffer(screen, selected_entry, right_width, content_height, left_width)
|
|
147
152
|
|
|
148
|
-
draw_mode_tabs_to_buffer(screen, @screen_height - 1)
|
|
153
|
+
draw_mode_tabs_to_buffer(screen, @screen_height - 1, show_path: false)
|
|
149
154
|
end
|
|
150
155
|
|
|
151
156
|
# 通知メッセージがある場合は表示
|
|
@@ -223,7 +228,7 @@ module Rufio
|
|
|
223
228
|
# FilePreview の結果ハッシュからプレーンテキスト行を抽出する
|
|
224
229
|
def extract_preview_lines(preview)
|
|
225
230
|
case preview[:type]
|
|
226
|
-
when 'text', 'code'
|
|
231
|
+
when 'text', 'code', 'archive'
|
|
227
232
|
preview[:lines]
|
|
228
233
|
when 'binary'
|
|
229
234
|
["(#{ConfigLoader.message('file.binary_file')})", ConfigLoader.message('file.cannot_preview')]
|
|
@@ -473,7 +478,7 @@ module Rufio
|
|
|
473
478
|
|
|
474
479
|
def draw_footer_to_buffer(screen, y, fps = nil)
|
|
475
480
|
if @keybind_handler.filter_active?
|
|
476
|
-
if @keybind_handler.
|
|
481
|
+
if @keybind_handler.filter_mode?
|
|
477
482
|
help_text = "Filter mode: Type to filter, ESC to clear, Enter to apply, Backspace to delete"
|
|
478
483
|
else
|
|
479
484
|
help_text = "Filtered view active - Space to edit filter, ESC to clear filter"
|
|
@@ -518,13 +523,8 @@ module Rufio
|
|
|
518
523
|
if @background_executor.running?
|
|
519
524
|
command_name = @background_executor.current_command || "処理中"
|
|
520
525
|
right_parts << "\e[32m🔄\e[0m #{command_name}"
|
|
521
|
-
elsif @
|
|
522
|
-
|
|
523
|
-
right_parts << @completion_lamp_message
|
|
524
|
-
else
|
|
525
|
-
@completion_lamp_message = nil
|
|
526
|
-
@completion_lamp_time = nil
|
|
527
|
-
end
|
|
526
|
+
elsif @background_status.message
|
|
527
|
+
right_parts << @background_status.message
|
|
528
528
|
end
|
|
529
529
|
end
|
|
530
530
|
|
|
@@ -575,7 +575,41 @@ module Rufio
|
|
|
575
575
|
# モードタブ描画
|
|
576
576
|
# ============================
|
|
577
577
|
|
|
578
|
-
|
|
578
|
+
# 現在のパスを 1 行で表示する(ホームは ~、長い場合は左側を省略)
|
|
579
|
+
def draw_path_line_to_buffer(screen, y)
|
|
580
|
+
path_text = " #{format_header_path(@directory_listing.current_path, @screen_width - 2)}"
|
|
581
|
+
screen.put_string(0, y, path_text.ljust(@screen_width), fg: "\e[1m")
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def format_header_path(path, max_width)
|
|
585
|
+
home = Dir.home rescue nil
|
|
586
|
+
display = path.to_s
|
|
587
|
+
if home && !home.empty?
|
|
588
|
+
if display == home
|
|
589
|
+
display = '~'
|
|
590
|
+
elsif display.start_with?(home + '/')
|
|
591
|
+
display = "~#{display.delete_prefix(home)}"
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
return display if TextUtils.display_width(display) <= max_width
|
|
595
|
+
|
|
596
|
+
# 末尾(現在のディレクトリ側)を残して先頭を … に置き換える
|
|
597
|
+
ellipsis = '…'
|
|
598
|
+
budget = max_width - TextUtils.display_width(ellipsis)
|
|
599
|
+
tail = +''
|
|
600
|
+
tail_width = 0
|
|
601
|
+
display.reverse.each_char do |char|
|
|
602
|
+
char_width = TextUtils.display_width(char)
|
|
603
|
+
break if tail_width + char_width > budget
|
|
604
|
+
|
|
605
|
+
tail.prepend(char)
|
|
606
|
+
tail_width += char_width
|
|
607
|
+
end
|
|
608
|
+
"#{ellipsis}#{tail}"
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# show_path: Files モードではパスを最上段に出すため、タブ行には表示しない
|
|
612
|
+
def draw_mode_tabs_to_buffer(screen, y, show_path: true)
|
|
579
613
|
# タブモードマネージャの状態を同期
|
|
580
614
|
sync_tab_mode_with_keybind_handler
|
|
581
615
|
|
|
@@ -626,7 +660,7 @@ module Rufio
|
|
|
626
660
|
current_x += 1
|
|
627
661
|
|
|
628
662
|
path_end = @screen_width - 1 - version_w
|
|
629
|
-
path_str = " #{shorten_path(current_path)} "
|
|
663
|
+
path_str = show_path ? " #{shorten_path(current_path)} " : ''
|
|
630
664
|
path_str.each_char do |char|
|
|
631
665
|
break if current_x >= path_end
|
|
632
666
|
char_w = TextUtils.display_width(char)
|
|
@@ -674,7 +708,7 @@ module Rufio
|
|
|
674
708
|
selected_index = job_mode_instance&.selected_index || 0
|
|
675
709
|
|
|
676
710
|
(0...height).each do |i|
|
|
677
|
-
line_num = i +
|
|
711
|
+
line_num = i + JOB_CONTENT_START_LINE
|
|
678
712
|
|
|
679
713
|
if i < jobs.length
|
|
680
714
|
job = jobs[i]
|
|
@@ -687,16 +721,16 @@ module Rufio
|
|
|
687
721
|
|
|
688
722
|
def draw_job_log_to_buffer(screen, height, job)
|
|
689
723
|
unless job
|
|
690
|
-
screen.put_string(0,
|
|
724
|
+
screen.put_string(0, JOB_CONTENT_START_LINE, 'No job selected'.ljust(@screen_width), fg: "\e[90m")
|
|
691
725
|
return
|
|
692
726
|
end
|
|
693
727
|
|
|
694
728
|
log_lines = job.logs || []
|
|
695
729
|
title = "=== Log: #{job.name} ==="
|
|
696
|
-
screen.put_string(0,
|
|
730
|
+
screen.put_string(0, JOB_CONTENT_START_LINE, title.ljust(@screen_width), fg: "\e[1;36m")
|
|
697
731
|
|
|
698
732
|
(0...height - 1).each do |i|
|
|
699
|
-
line_num = i +
|
|
733
|
+
line_num = i + JOB_CONTENT_START_LINE + 1
|
|
700
734
|
line = log_lines[i] || ''
|
|
701
735
|
line = line[0...@screen_width].ljust(@screen_width)
|
|
702
736
|
screen.put_string(0, line_num, line, fg: "\e[37m")
|
|
@@ -762,20 +796,9 @@ module Rufio
|
|
|
762
796
|
private
|
|
763
797
|
|
|
764
798
|
def get_display_entries
|
|
765
|
-
|
|
766
|
-
all_entries = @directory_listing.list_entries
|
|
767
|
-
query = @keybind_handler.filter_query.downcase
|
|
768
|
-
query.empty? ? all_entries : all_entries.select { |entry| entry[:name].downcase.include?(query) }
|
|
769
|
-
else
|
|
770
|
-
@directory_listing.list_entries
|
|
771
|
-
end
|
|
799
|
+
return @keybind_handler.get_active_entries if @keybind_handler.respond_to?(:get_active_entries)
|
|
772
800
|
|
|
773
|
-
|
|
774
|
-
if @keybind_handler.help_mode? || @keybind_handler.log_viewer_mode?
|
|
775
|
-
entries.reject { |entry| entry[:name] == '..' }
|
|
776
|
-
else
|
|
777
|
-
entries
|
|
778
|
-
end
|
|
801
|
+
@directory_listing.list_entries
|
|
779
802
|
end
|
|
780
803
|
|
|
781
804
|
# キーバインドハンドラの状態とタブモードを同期
|
data/lib/rufio/version.rb
CHANGED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Rufio
|
|
6
|
+
# ZIPアーカイブの読み取り専用パーサー(FastZip Phase 1: Pure Ruby 実装)
|
|
7
|
+
#
|
|
8
|
+
# EOCD → (ZIP64 EOCD) → Central Directory の順に辿り、
|
|
9
|
+
# ファイル本体を展開せずにエントリ一覧(unzip -l 相当)を取得する。
|
|
10
|
+
# 将来の Zig ネイティブ実装はこのクラスと同じ結果を返すことを互換条件とする。
|
|
11
|
+
class ZipArchive
|
|
12
|
+
class FormatError < StandardError; end
|
|
13
|
+
|
|
14
|
+
Entry = Struct.new(
|
|
15
|
+
:name, :compressed_size, :size, :method, :crc32, :mtime,
|
|
16
|
+
:local_header_offset, :flags, :directory, :encrypted,
|
|
17
|
+
:version_made_by, :external_attributes,
|
|
18
|
+
keyword_init: true
|
|
19
|
+
) do
|
|
20
|
+
alias_method :directory?, :directory
|
|
21
|
+
alias_method :encrypted?, :encrypted
|
|
22
|
+
|
|
23
|
+
# Unix 系で作られたエントリのファイルモード(それ以外は nil)
|
|
24
|
+
def unix_mode
|
|
25
|
+
return nil unless (version_made_by >> 8) == HOST_UNIX
|
|
26
|
+
|
|
27
|
+
mode = external_attributes >> 16
|
|
28
|
+
mode.zero? ? nil : mode
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def symlink?
|
|
32
|
+
mode = unix_mode
|
|
33
|
+
!mode.nil? && (mode & S_IFMT) == S_IFLNK
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Listing = Struct.new(:entries, :total_entries, :comment, :zip64, :central_directory_offset, keyword_init: true) do
|
|
38
|
+
alias_method :zip64?, :zip64
|
|
39
|
+
|
|
40
|
+
def truncated?
|
|
41
|
+
entries.length < total_entries
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
EOCD_SIGNATURE = 0x06054b50
|
|
46
|
+
ZIP64_EOCD_SIGNATURE = 0x06064b50
|
|
47
|
+
ZIP64_LOCATOR_SIGNATURE = 0x07064b50
|
|
48
|
+
CENTRAL_HEADER_SIGNATURE = 0x02014b50
|
|
49
|
+
|
|
50
|
+
EOCD_SIZE = 22
|
|
51
|
+
ZIP64_LOCATOR_SIZE = 20
|
|
52
|
+
ZIP64_EOCD_MIN_SIZE = 56
|
|
53
|
+
CENTRAL_HEADER_SIZE = 46
|
|
54
|
+
MAX_COMMENT_SIZE = 0xFFFF
|
|
55
|
+
|
|
56
|
+
ZIP64_EXTRA_ID = 0x0001
|
|
57
|
+
UNICODE_PATH_EXTRA_ID = 0x7075
|
|
58
|
+
|
|
59
|
+
FLAG_ENCRYPTED = 0x0001
|
|
60
|
+
FLAG_UTF8 = 0x0800
|
|
61
|
+
|
|
62
|
+
HOST_UNIX = 3
|
|
63
|
+
S_IFMT = 0o170000
|
|
64
|
+
S_IFLNK = 0o120000
|
|
65
|
+
S_IFDIR = 0o040000
|
|
66
|
+
S_IFREG = 0o100000
|
|
67
|
+
|
|
68
|
+
METHOD_NAMES = {
|
|
69
|
+
0 => 'Stored',
|
|
70
|
+
8 => 'Deflate',
|
|
71
|
+
9 => 'Deflate64',
|
|
72
|
+
12 => 'BZip2',
|
|
73
|
+
14 => 'LZMA',
|
|
74
|
+
93 => 'Zstd',
|
|
75
|
+
95 => 'XZ',
|
|
76
|
+
99 => 'AES'
|
|
77
|
+
}.freeze
|
|
78
|
+
|
|
79
|
+
def self.method_name(method)
|
|
80
|
+
METHOD_NAMES.fetch(method, "Method#{method}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# ZIPのマジックナンバーで始まるか(空アーカイブはEOCDから始まる)
|
|
84
|
+
def self.zip_signature?(path)
|
|
85
|
+
head = File.binread(path, 4)
|
|
86
|
+
["PK\x03\x04".b, "PK\x05\x06".b].include?(head)
|
|
87
|
+
rescue SystemCallError
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @param path [String] ZIPファイルのパス
|
|
92
|
+
# @param limit [Integer, nil] 読み取るエントリ数の上限(プレビュー用)
|
|
93
|
+
# @return [Listing]
|
|
94
|
+
def self.list(path, limit: nil)
|
|
95
|
+
new(path).list(limit: limit)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def initialize(path)
|
|
99
|
+
@path = path
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def list(limit: nil)
|
|
103
|
+
File.open(@path, 'rb') do |io|
|
|
104
|
+
file_size = io.size
|
|
105
|
+
eocd = read_end_of_central_directory(io, file_size)
|
|
106
|
+
count = limit ? [eocd[:total_entries], limit].min : eocd[:total_entries]
|
|
107
|
+
entries = read_central_directory(io, file_size, eocd, count)
|
|
108
|
+
|
|
109
|
+
Listing.new(
|
|
110
|
+
entries: entries,
|
|
111
|
+
total_entries: eocd[:total_entries],
|
|
112
|
+
comment: eocd[:comment],
|
|
113
|
+
zip64: eocd[:zip64],
|
|
114
|
+
central_directory_offset: eocd[:cd_offset]
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# ファイル末尾から EOCD を後方探索する
|
|
122
|
+
def read_end_of_central_directory(io, file_size)
|
|
123
|
+
raise FormatError, 'File too small to be a ZIP archive' if file_size < EOCD_SIZE
|
|
124
|
+
|
|
125
|
+
tail_size = [file_size, EOCD_SIZE + MAX_COMMENT_SIZE].min
|
|
126
|
+
tail_offset = file_size - tail_size
|
|
127
|
+
tail = read_at(io, tail_offset, tail_size)
|
|
128
|
+
|
|
129
|
+
position = find_eocd_position(io, tail)
|
|
130
|
+
raise FormatError, 'End of central directory not found' unless position
|
|
131
|
+
|
|
132
|
+
fields = tail.byteslice(position, EOCD_SIZE).unpack('VvvvvVVv')
|
|
133
|
+
_sig, disk, cd_disk, _disk_entries, total_entries, cd_size, cd_offset, comment_length = fields
|
|
134
|
+
comment = tail.byteslice(position + EOCD_SIZE, comment_length)
|
|
135
|
+
eocd = {
|
|
136
|
+
total_entries: total_entries,
|
|
137
|
+
cd_size: cd_size,
|
|
138
|
+
cd_offset: cd_offset,
|
|
139
|
+
comment: decode_text(comment, utf8: false),
|
|
140
|
+
zip64: false
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if zip64_required?(fields)
|
|
144
|
+
apply_zip64_eocd(io, tail_offset + position, eocd)
|
|
145
|
+
elsif disk != 0 || cd_disk != 0
|
|
146
|
+
raise FormatError, 'Multi-disk ZIP archives are not supported'
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
eocd
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# コメント長と整合し、指す先に Central Directory がある最後の EOCD を探す
|
|
153
|
+
# (コメント内に EOCD と同じバイト列が含まれていても誤認しない)
|
|
154
|
+
def find_eocd_position(io, tail)
|
|
155
|
+
signature = [EOCD_SIGNATURE].pack('V')
|
|
156
|
+
position = tail.bytesize - EOCD_SIZE
|
|
157
|
+
|
|
158
|
+
while position >= 0
|
|
159
|
+
position = tail.rindex(signature, position)
|
|
160
|
+
return nil unless position
|
|
161
|
+
|
|
162
|
+
return position if plausible_eocd?(io, tail, position)
|
|
163
|
+
|
|
164
|
+
position -= 1
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def plausible_eocd?(io, tail, position)
|
|
171
|
+
fields = tail.byteslice(position, EOCD_SIZE).unpack('VvvvvVVv')
|
|
172
|
+
return false unless position + EOCD_SIZE + fields[7] == tail.bytesize
|
|
173
|
+
return true if fields[4].zero? || zip64_required?(fields)
|
|
174
|
+
|
|
175
|
+
io.seek(fields[6])
|
|
176
|
+
io.read(4)&.unpack1('V') == CENTRAL_HEADER_SIGNATURE
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def zip64_required?(fields)
|
|
180
|
+
fields[1..4].any? { |v| v == 0xFFFF } || fields[5] == 0xFFFFFFFF || fields[6] == 0xFFFFFFFF
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def apply_zip64_eocd(io, eocd_offset, eocd)
|
|
184
|
+
locator_offset = eocd_offset - ZIP64_LOCATOR_SIZE
|
|
185
|
+
raise FormatError, 'ZIP64 locator not found' if locator_offset.negative?
|
|
186
|
+
|
|
187
|
+
sig, _disk, zip64_eocd_offset, _total_disks = read_at(io, locator_offset, ZIP64_LOCATOR_SIZE).unpack('VVQ<V')
|
|
188
|
+
raise FormatError, 'ZIP64 locator not found' unless sig == ZIP64_LOCATOR_SIGNATURE
|
|
189
|
+
|
|
190
|
+
record = read_at(io, zip64_eocd_offset, ZIP64_EOCD_MIN_SIZE)
|
|
191
|
+
sig, _size, _made, _needed, _disk, _cd_disk, _disk_entries, total_entries, cd_size, cd_offset =
|
|
192
|
+
record.unpack('VQ<vvVVQ<Q<Q<Q<')
|
|
193
|
+
raise FormatError, 'ZIP64 end of central directory not found' unless sig == ZIP64_EOCD_SIGNATURE
|
|
194
|
+
|
|
195
|
+
eocd.merge!(total_entries: total_entries, cd_size: cd_size, cd_offset: cd_offset, zip64: true)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def read_central_directory(io, file_size, eocd, count)
|
|
199
|
+
cd_end = eocd[:cd_offset] + eocd[:cd_size]
|
|
200
|
+
raise FormatError, 'Central directory is out of range' if cd_end > file_size
|
|
201
|
+
|
|
202
|
+
offset = eocd[:cd_offset]
|
|
203
|
+
Array.new(count) do
|
|
204
|
+
raise FormatError, 'Central directory is truncated' if offset + CENTRAL_HEADER_SIZE > cd_end
|
|
205
|
+
|
|
206
|
+
entry, offset = read_central_header(io, offset, cd_end)
|
|
207
|
+
entry
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def read_central_header(io, offset, cd_end)
|
|
212
|
+
header = read_at(io, offset, CENTRAL_HEADER_SIZE)
|
|
213
|
+
sig, made_by, _needed, flags, method, dos_time, dos_date, crc32, compressed_size, size,
|
|
214
|
+
name_length, extra_length, comment_length, _disk, _int_attr, ext_attr, local_offset =
|
|
215
|
+
header.unpack('VvvvvvvVVVvvvvvVV')
|
|
216
|
+
raise FormatError, "Invalid central directory header at offset #{offset}" unless sig == CENTRAL_HEADER_SIGNATURE
|
|
217
|
+
|
|
218
|
+
variable_length = name_length + extra_length + comment_length
|
|
219
|
+
next_offset = offset + CENTRAL_HEADER_SIZE + variable_length
|
|
220
|
+
raise FormatError, 'Central directory is truncated' if next_offset > cd_end
|
|
221
|
+
|
|
222
|
+
variable = read_at(io, offset + CENTRAL_HEADER_SIZE, name_length + extra_length)
|
|
223
|
+
raw_name = variable.byteslice(0, name_length)
|
|
224
|
+
extra = parse_extra_fields(variable.byteslice(name_length, extra_length))
|
|
225
|
+
|
|
226
|
+
sizes = { size: size, compressed_size: compressed_size, local_header_offset: local_offset }
|
|
227
|
+
apply_zip64_extra(sizes, extra[ZIP64_EXTRA_ID]) if extra[ZIP64_EXTRA_ID]
|
|
228
|
+
|
|
229
|
+
name = unicode_path(extra[UNICODE_PATH_EXTRA_ID], raw_name) ||
|
|
230
|
+
decode_text(raw_name, utf8: (flags & FLAG_UTF8) != 0)
|
|
231
|
+
|
|
232
|
+
entry = Entry.new(
|
|
233
|
+
name: name,
|
|
234
|
+
compressed_size: sizes[:compressed_size],
|
|
235
|
+
size: sizes[:size],
|
|
236
|
+
method: method,
|
|
237
|
+
crc32: crc32,
|
|
238
|
+
mtime: dos_to_time(dos_date, dos_time),
|
|
239
|
+
local_header_offset: sizes[:local_header_offset],
|
|
240
|
+
flags: flags,
|
|
241
|
+
directory: name.end_with?('/'),
|
|
242
|
+
encrypted: (flags & FLAG_ENCRYPTED) != 0,
|
|
243
|
+
version_made_by: made_by,
|
|
244
|
+
external_attributes: ext_attr
|
|
245
|
+
)
|
|
246
|
+
[entry, next_offset]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def parse_extra_fields(data)
|
|
250
|
+
fields = {}
|
|
251
|
+
pos = 0
|
|
252
|
+
while pos + 4 <= data.bytesize
|
|
253
|
+
id, length = data.byteslice(pos, 4).unpack('vv')
|
|
254
|
+
break if pos + 4 + length > data.bytesize
|
|
255
|
+
|
|
256
|
+
fields[id] ||= data.byteslice(pos + 4, length)
|
|
257
|
+
pos += 4 + length
|
|
258
|
+
end
|
|
259
|
+
fields
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# ZIP64 拡張フィールドには 0xFFFFFFFF だった値だけが既定の順序で格納される
|
|
263
|
+
def apply_zip64_extra(sizes, data)
|
|
264
|
+
pos = 0
|
|
265
|
+
%i[size compressed_size local_header_offset].each do |key|
|
|
266
|
+
next unless sizes[key] == 0xFFFFFFFF
|
|
267
|
+
break if pos + 8 > data.bytesize
|
|
268
|
+
|
|
269
|
+
sizes[key] = data.byteslice(pos, 8).unpack1('Q<')
|
|
270
|
+
pos += 8
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Info-ZIP Unicode Path 拡張(元のファイル名のCRCが一致する場合のみ採用)
|
|
275
|
+
def unicode_path(data, raw_name)
|
|
276
|
+
return nil if data.nil? || data.bytesize < 5
|
|
277
|
+
|
|
278
|
+
version, name_crc = data.unpack('CV')
|
|
279
|
+
return nil unless version == 1 && name_crc == Zlib.crc32(raw_name)
|
|
280
|
+
|
|
281
|
+
name = data.byteslice(5..).force_encoding(Encoding::UTF_8)
|
|
282
|
+
name.valid_encoding? ? name : nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# UTFフラグがなければ UTF-8 → CP932(Windows日本語環境で作られたZIP)→ 置換 の順で解釈する
|
|
286
|
+
def decode_text(raw, utf8:)
|
|
287
|
+
text = raw.dup.force_encoding(Encoding::UTF_8)
|
|
288
|
+
return text if utf8 || text.valid_encoding?
|
|
289
|
+
|
|
290
|
+
raw.encode(Encoding::UTF_8, Encoding::Windows_31J)
|
|
291
|
+
rescue EncodingError
|
|
292
|
+
raw.encode(Encoding::UTF_8, Encoding::BINARY, invalid: :replace, undef: :replace, replace: '?')
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def dos_to_time(date, time)
|
|
296
|
+
return nil if date.zero?
|
|
297
|
+
|
|
298
|
+
Time.new(
|
|
299
|
+
((date >> 9) & 0x7F) + 1980, (date >> 5) & 0x0F, date & 0x1F,
|
|
300
|
+
(time >> 11) & 0x1F, (time >> 5) & 0x3F, (time & 0x1F) * 2
|
|
301
|
+
)
|
|
302
|
+
rescue ArgumentError
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def read_at(io, offset, length)
|
|
307
|
+
io.seek(offset)
|
|
308
|
+
data = io.read(length)
|
|
309
|
+
raise FormatError, 'Unexpected end of file' if data.nil? || data.bytesize < length
|
|
310
|
+
|
|
311
|
+
data
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|